linkedin-counterpart-profile.md57.8 KBView on GitHub # LinkedIn counterpart profile in the empty chat panel
## 1) Introduction — goal, present state, future state
When a rep is talking to someone on LinkedIn, the chat column beside the conversation should tell them who that person is — the role and company they hold now, where they worked before, and what they have been posting — so the reply can be written without leaving the app. Today that column resolves to the `channelChat` empty-chat surface, whose only body section is `conversationCards`, and those cards self-hide whenever the chat is not linked to a deal; for a LinkedIn DM that is the common case, so the panel renders empty. Nothing about the counterpart is stored either: every LinkedIn person in `crm_person` carries a name, a headline and an obfuscated `ACoAA…` profile URL, while `current_title`, `current_company_id` and `enrichment` are empty, because the seat's `profile_view` budget is capped at 50/day and there has never been a table to cache a fetched profile into. This design adds that cache, a cache-first read that costs nothing, a governed refresh that spends at most one profile view per person and only while the day's budget is comfortably under cap, and a `linkedinProfile` section in the empty-chat registry that renders current role, past history and recent posts whenever the open artifact is a `linkedin_chat`.
## 2) Present state
### 2.1 Architecture diagram
```text
user opens a LinkedIn chat
│
▼
┌───────────────────────────┐ ┌──────────────────────────────┐
│ selectedArtifact │ │ AppShell (2 columns) │
│ kind: 'linkedin_chat' │───────►│ [ context ] [ ChatColumn ] │
│ id: <unipile chatId> │ └──────────────┬───────────────┘
└───────────┬───────────────┘ │
│ artifactToContext() ▼
▼ ┌────────────────────────┐
{ kind: 'channelChat', │ EmbeddedCedarChat │
id: <chatId> } │ emptyChatSurface │
│ └──────────┬─────────────┘
│ computeEmptyChatSurface() │ resolveEmptyChatLayout()
▼ ▼
'channelChat' ────────────────► EMPTY_CHAT_LAYOUTS.channelChat
│
sections: [conversationCards,
tabbedPrompts]
│
▼
┌────────────────────────────────┐
│ EmptyStateSuggestions │
│ conversationCardsNode │
│ → <ThreadContextCards/> │
│ → null when no deal link ◄──┼── the empty panel
└────────────────────────────────┘
data available, none of it reaching the panel:
┌─────────────────────────────┐ ┌──────────────────────────┐ ┌───────────────────────┐
│ linkedin_chats │ │ linkedin_chat_ │ │ crm_person │
│ chat_id (PK) │──►│ participants │──►│ display_name │
│ person_id ────────────────────── participant_urn │ │ headline │
│ unipile_account_id │ │ public_identifier │ │ linkedin_provider_id │
└─────────────────────────────┘ │ name / headline / photo │ │ current_title = NULL │
└──────────────────────────┘ │ enrichment = {} │
└───────────────────────┘
never called for display:
UnipileClient.getUserProfile(sections:'*') → work_experience[] (governed: 50/day)
UnipileClient.listUserPosts(providerId) → recent posts (ungoverned)
```
### 2.2 Step-by-step walkthrough
1. **Artifact resolution** — `getDisplayArtifact` at [messagesSlice.ts:344](apps/mail/modules/cedar-os/src/store/messages/messagesSlice.ts) returns the active thread's open artifact.
- Receives: nothing (store read)
- Data after this step:
```json
{ "kind": "linkedin_chat", "id": "ghtYXSs8XKqonGeH1aO9gQ" }
```
2. **Context projection** — `artifactToContext` at [resolveContext.ts:13](apps/mail/modules/ux/layout/resolveContext.ts) collapses `slack_thread`, `linkedin_chat` and `whatsapp_chat` into one context kind, deliberately: all three open the same `ChannelThreadView`.
- Calls: nothing further
- Data after this step — **the provider is now lost**:
```json
{ "kind": "channelChat", "id": "ghtYXSs8XKqonGeH1aO9gQ" }
```
3. **Surface projection** — `computeEmptyChatSurface` at [selectEmptyChatSurface.ts:15](apps/mail/modules/ux/layout/selectEmptyChatSurface.ts) maps the context kind to the empty-chat surface. Its `selectEmptyChatSurface` wrapper must keep returning a primitive — consumers subscribe with no equality function, and `selectContext` allocates a fresh object per call.
- Data after this step:
```ts
'channelChat'
```
4. **Layout resolution** — `resolveEmptyChatLayout` at [emptyChatLayout.ts:952](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/emptyChatLayout.ts), called from [EmbeddedCedarChat.tsx:641](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx). The `channelChat` builder at [emptyChatLayout.ts:893](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/emptyChatLayout.ts) is a thread surface, so it passes through untouched.
- Data after this step:
```json
{
"surface": "channelChat",
"heading": "",
"sections": [
{ "id": "conversationCards", "kind": "conversationCards" },
{ "id": "suggestions", "kind": "tabbedPrompts", "title": "Suggestions" }
]
}
```
5. **Body render** — `EmptyStateSuggestions` at [EmptyStateSuggestions.tsx:58](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmptyStateSuggestions.tsx). `tabbedPrompts` is filtered out (it is rendered by `EmptyChatSuggestionsBar` above the composer), leaving one body section.
- `conversationCards` branch returns `<React.Fragment>{conversationCardsNode}</React.Fragment>`, or `null` when the node is absent.
6. **Cards** — `conversationCardsNode` is supplied at [EmbeddedCedarChat.tsx:1268](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx) as `<ThreadContextCards/>`, which renders the deal's Overview / Next steps. For a LinkedIn DM with `conversation_id IS NULL` it renders nothing.
- **Result: the body is empty.** This is the bug.
7. **What the seat could answer, but is never asked** — `UnipileClient.getUserProfile` at [unipile-client.ts:591](apps/server/src/services/integrations/linkedin/unipile-client.ts), with `sections: '*'`, returns the full profile.
- Data:
```json
{
"provider_id": "ACoAACDNrG0B9JTDvxf_lfJiVpc08IW8yLpwB2o",
"public_identifier": "molly-pilch",
"headline": "Senior Account Executive @ ClassPass",
"occupation": "Senior Account Executive",
"location": "New York, NY",
"work_experience": [
{ "company": "ClassPass", "company_id": "1866484", "position": "Senior Account Executive", "current": true, "start": "2022-03" },
{ "company": "Yelp", "company_id": "17876", "position": "Account Executive", "current": false, "start": "2019-01", "end": "2022-02" }
]
}
```
8. **Posts, likewise unasked** — `UnipileClient.listUserPosts` at [unipile-client.ts:357](apps/server/src/services/integrations/linkedin/unipile-client.ts), reachable through `fetchRecentPosts` at [messaging.ts:1835](apps/server/src/services/integrations/linkedin/messaging.ts) and exposed as `outbound.linkedin.posts` at [linkedin.ts:71](apps/server/src/trpc/routes/linkedin.ts). Ungoverned — no rate window applies.
- Data:
```json
{ "posts": [ { "id": "urn:li:activity:73…", "text": "We just shipped…", "date": "2026-08-29T14:02:00Z", "reaction_counter": 41, "comment_counter": 6 } ] }
```
9. **Why nothing is cached** — `resolveCounterpartCompany` at [messaging.ts:663](apps/server/src/services/integrations/linkedin/messaging.ts) spends the one governed `profile_view` this codebase allows per person, banks only `provider_id` plus three scalars, discards `work_experience`, and then refuses to ever look again (`if (person?.linkedinProviderId) return null`) — stated in its own comment as "one paid view per person, ever; otherwise a profile that simply lacks a hard company id drains the 50/day cap forever."
- Live data confirming the outcome, across every LinkedIn chat counterpart sampled:
```json
{ "display_name": "Molly Pilch", "headline": "Senior Account Executive @ ClassPass",
"linkedin_url": "https://www.linkedin.com/in/ACoAACDNrG0B9JTDvxf_lfJiVpc08IW8yLpwB2o",
"current_title": null, "current_company_id": null, "enrichment": {}, "enriched_at": null }
```
10. **The gate the refresh must pass** — `governedSend` at [messaging.ts:1581](apps/server/src/services/integrations/linkedin/messaging.ts) applies `assertSeatHealthy` then `rateCheckAndIncrement` at [governor.ts:76](apps/server/src/services/integrations/linkedin/governor.ts), against `DEFAULT_CAPS.profile_view` at [governor.ts:34](apps/server/src/services/integrations/linkedin/governor.ts).
- Data:
```ts
DEFAULT_CAPS.profile_view // [{ windowKind: 'day', cap: 50 }]
rateCheckAndIncrement(...) // → { allowed: true, remaining: 37, cap: 50 } — but it INCREMENTS
```
## 3) Designed state
### 3.1 Architecture diagram
```text
user opens a LinkedIn chat
│
▼
selectLinkedinChatId(store) ──► chatId | null (primitive — safe bare subscribe)
│ │
│ null → card renders nothing (Slack / WhatsApp / no artifact)
▼
┌──────────────────────────────────────────────────────────────────┐
│ EMPTY_CHAT_LAYOUTS.channelChat │
│ sections: [ linkedinProfile, ◄── NEW, first │
│ conversationCards, │
│ tabbedPrompts ] │
└──────────────────────────┬───────────────────────────────────────┘
▼
EmptyStateSuggestions( linkedinProfileNode )
▼
<LinkedInCounterpartCard chatId />
│
┌────────────────────┴─────────────────────┐
│ query │ mutation (auto or button)
▼ ▼
messaging.counterpartProfile messaging.refreshCounterpartProfile
(FREE — cache only) (governed — at most 1 profile_view)
│ │
▼ ▼
readCounterpartProfile() refreshCounterpartProfile()
│ │
│ ┌───────────────┴──────────────┐
│ │ no reserve — a rep is waiting │
│ │ (the 64/day cap is the limit) │
│ └───────┬──────────────┬───────┘
│ allowed │ │ cap reached
│ ▼ ▼
│ governedSend(profile_view) skip profile,
│ → getUserProfile('*') posts only,
│ │ reason:'budget'
│ ▼
│ listUserPosts (ungoverned, always)
│ │
│ ▼
│ splitWorkExperience() ── current + history
│ │
▼ ▼
┌───────────────────────────────────────────────────────────┐
│ linkedin_person_profiles (NEW cache table) │
│ (organization_id, provider_urn) PK · person_id · work_… │
│ posts jsonb · profile_fetched_at · posts_fetched_at │
└───────────────────────┬───────────────────────────────────┘
│ banks the paid view, same as resolveCounterpartCompany
▼
crm_person.current_company_id / current_title (coalesce, never clobber)
```
### 3.2 Step-by-step walkthrough
1. **Provider-aware selector** — `selectLinkedinChatId` in [selectChannelChat.ts](apps/mail/modules/ux/layout/selectChannelChat.ts) (new). Reads `getDisplayArtifact()` and returns the id only for `kind === 'linkedin_chat'`. Returns a **primitive**, honouring the gotcha documented on `selectEmptyChatSurface` — `getDisplayArtifact()` allocates a fresh object per call, so returning anything derived from it would re-render the chat on every store change.
- Receives: `CedarStore`
- Data after this step:
```ts
'ghtYXSs8XKqonGeH1aO9gQ' | null
```
2. **Registry section** — `EMPTY_CHAT_LAYOUTS.channelChat` at [emptyChatLayout.ts:893](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/emptyChatLayout.ts) gains a `linkedinProfile` section ahead of `conversationCards`. The person is the specific thing about this panel; the deal cards already self-hide.
- Data after this step:
```json
{ "surface": "channelChat", "heading": "",
"sections": [
{ "id": "linkedinProfile", "kind": "linkedinProfile" },
{ "id": "conversationCards","kind": "conversationCards" },
{ "id": "suggestions", "kind": "tabbedPrompts", "title": "Suggestions" }
] }
```
3. **Body render** — `EmptyStateSuggestions` at [EmptyStateSuggestions.tsx:58](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmptyStateSuggestions.tsx) gains a `linkedinProfileNode` prop and a branch that mirrors `conversationCards` exactly: absent node ⇒ `null`. `topAlign` also takes it, so the card starts at the top rather than floating mid-panel.
4. **Node supplied by the host** — [EmbeddedCedarChat.tsx:1268](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx) passes `<LinkedInCounterpartCard/>`, matching the pattern already used for `agendaNode`, `calendarRailNode` and `conversationCardsNode`. The registry stays a plain data module with no React and no import into `modules/linkedin`.
5. **Cache read** — `readCounterpartProfile` in [person-profile.ts](apps/server/src/services/integrations/linkedin/person-profile.ts) (new). Resolves the chat's non-self participant from `linkedin_chat_participants` (free — `participant_urn`, `public_identifier`, `name`, `headline`, `profile_picture_url` are already mirrored), then left-joins the cache. **Never calls Unipile.**
- Receives: `{ organizationId, chatId }`
- Data after this step (cold cache — what every counterpart looks like today):
```json
{ "urn": "ACoAACDNrG0B9JTDvxf_lfJiVpc08IW8yLpwB2o", "personId": "…", "name": "Molly Pilch",
"headline": "Senior Account Executive @ ClassPass", "publicIdentifier": null,
"current": null, "history": [], "posts": [],
"profileFetchedAt": null, "postsFetchedAt": null, "stale": true }
```
6. **Budget probe** — `rateRemaining` in [governor.ts](apps/server/src/services/integrations/linkedin/governor.ts) (new export). A read-only sibling of `rateCheckAndIncrement` at [governor.ts:76](apps/server/src/services/integrations/linkedin/governor.ts) — same window arithmetic via `windowStartFor` at [governor.ts:49](apps/server/src/services/integrations/linkedin/governor.ts), but it does not increment. Required because the reserve gate must be able to ask "how much is left?" without spending one to find out.
- Data after this step:
```json
{ "allowed": true, "remaining": 37, "cap": 50 }
```
7. **Reserve gate, pointing at the background** — `withinBackgroundProfileReserve` in [person-profile.ts](apps/server/src/services/integrations/linkedin/person-profile.ts). Pure, unit-tested: `remaining > cap * PROFILE_VIEW_INTERACTIVE_RESERVE` with `PROFILE_VIEW_INTERACTIVE_RESERVE = 0.25`. **The panel does not call it.** It is consulted by the BACKGROUND consumers of this budget — `resolveCounterpartCompany` on chat sync, `deobfuscateViaSeat`, the sequencer's `linkedin_view` warm-up step, `signalProfileViewers` — which stop at 16 of the 64/day cap so a rep opening cold chats still has views to spend.
The gate originally pointed the other way, and the justification did not survive contact with the data: sends book `dm` / `connect` / `inmail`, which are different buckets, so the panel was being starved to protect a queue that was not competing with it. What WAS competing was background enrichment, which drained the seat to exactly the panel's stop line (29 of 50) most mornings and left it there.
- Data after this step:
```ts
withinBackgroundProfileReserve({ remaining: 37, cap: 64 }) // true — crawler may spend
withinBackgroundProfileReserve({ remaining: 16, cap: 64 }) // false — the rest is the rep's
```
8. **Governed profile fetch** — inside `refreshCounterpartProfile`, via `governedSend` at [messaging.ts:1581](apps/server/src/services/integrations/linkedin/messaging.ts) with `action: 'profile_view'`, calling `getUserProfile(urn, { sections: '*' })` at [unipile-client.ts:591](apps/server/src/services/integrations/linkedin/unipile-client.ts). `notify` stays at its `false` default: reading a chat you are already in must not register as a profile visit.
- Branches:
- cache cold ⇒ fetch, with no reserve consulted — the cap is the only ceiling
- `force` absent and `profile_fetched_at` within `PROFILE_TTL_DAYS` (90) ⇒ skip, `skipped: 'fresh'`
- the day's 64 are spent ⇒ `governedSend` denies, `skipped: 'budget'`; posts still proceed
- `assertSeatHealthy` / provider error throws ⇒ caught, `skipped: 'unavailable'`; posts still proceed
9. **Work-experience split** — `splitWorkExperience` in [person-profile.ts](apps/server/src/services/integrations/linkedin/person-profile.ts). Current = the entry flagged `current`, or the sole entry when there is exactly one; history = every other entry, newest start first. This is the identical "hard signal only" rule `resolveCounterpartCompany` states at [messaging.ts:663](apps/server/src/services/integrations/linkedin/messaging.ts) — so it is **extracted here and imported back** by that function rather than copied, since a second opinion about which row is the current one is precisely what would drift. `company` is nullable: an entry carrying LinkedIn's hard org id but no display name is kept, because that id is what `resolveCounterpartCompany` links on — the card, not the normalizer, is what declines to render a role it cannot name.
**Corrected against the live API.** `start`/`end` come back as `M/D/YYYY` (`'6/1/2023'`), not the `YYYY-MM` this design assumed, so the original `localeCompare` sort was lexicographic — it placed `4/1/2017` above `3/1/2022` and `12/1/2016` above `10/1/2020`. Ordering now goes through a `Date.parse`-based key, applied on WRITE and again on READ so rows cached by an earlier build still come out newest-first. Unipile also frequently flags NO entry as `current`, so `current: null` is the common case, not the edge case — the card falls back to the most recent role with no end date, and then to the headline.
- Data after this step:
```json
{ "current": { "company": "ClassPass", "companyId": "1866484", "title": "Senior Account Executive", "start": "2022-03" },
"history": [ { "company": "Yelp", "companyId": "17876", "title": "Account Executive", "start": "2019-01", "end": "2022-02" } ] }
```
10. **Ungoverned posts fetch** — `listUserPosts(urn, { limit: 10 })` at [unipile-client.ts:357](apps/server/src/services/integrations/linkedin/unipile-client.ts), filtered to posts this member actually authored (`author.id === urn` or `reposted_by.id === urn`), capped at 5 stored. Runs on its own TTL (`POSTS_TTL_DAYS = 7`) and on its own try/catch, so a profile skipped for budget still gets fresh posts.
**Corrected against the live API.** The design assumed `date` was a timestamp and that a permalink had to be built from `social_id`. Neither is true: `date` is a RELATIVE string (`'6mo'`, `'2yr'`), the ISO instant lives in `parsed_datetime` (and `repost_parsed_datetime` for a reshare — the instant that matters for "their activity"), and the provider hands back a real `share_url`. Sorting on `date` put an 8-month-old post above a 6-month-old one; `postedAt` now carries the ISO instant and the sort parses it.
- Data after this step:
```json
[ { "postId": "urn:li:activity:73…", "text": "We just shipped…", "postedAt": "2026-08-29T14:02:00Z",
"url": "https://www.linkedin.com/feed/update/urn:li:activity:73…",
"reactions": 41, "comments": 6, "isRepost": false } ]
```
11. **Cache upsert** — one `INSERT … ON CONFLICT (organization_id, provider_urn) DO UPDATE` into `linkedin_person_profiles`. Each half updates only the columns it fetched, so a posts-only refresh cannot blank a profile that was skipped.
Only `'budget'` and `'unavailable'` are PERSISTED to `last_skip_reason`. `'fresh'` is reported for the call that
skipped but never stored — the column exists so a turned-away client can avoid retrying, and "everything was already
cached" is not that. Storing it left a merely-refreshed chat reporting `skipped: 'fresh'` forever, which reads like a
failure and is not one.
12. **Bank the paid view** — the same coalescing write `resolveCounterpartCompany` already performs at [messaging.ts:663](apps/server/src/services/integrations/linkedin/messaging.ts): `crm_person.current_title` / `headline` / `location` gap-filled, and `current_company_id` resolved through `resolveCompanyByLinkedinHardKey` when the current entry carries a `company_id`. A view spent on the panel therefore also repairs the CRM link that `resolveCounterpartCompany` gave up on — which is the second reason this feature is worth its budget.
13. **tRPC surface** — two procedures on `outbound.linkedin.messaging` in [linkedin.ts](apps/server/src/trpc/routes/linkedin.ts), beside the existing `enrichCounterparty` at [linkedin.ts:178](apps/server/src/trpc/routes/linkedin.ts):
```ts
counterpartProfile: privateProcedure.input({ chatId }).query(…) // free
refreshCounterpartProfile: privateProcedure.input({ chatId, force? }).mutation(…) // seat comes off the chat
```
14. **Auto-fetch, client side** — `useCounterpartProfile` in [use-counterpart-profile.ts](apps/mail/modules/linkedin/hooks/use-counterpart-profile.ts) (new). Runs the free query on open; fires the refresh mutation exactly once per `chatId` per session when the read comes back `stale`, and reconciles the result into the query cache. When the server answers `skipped: 'budget'` the hook stops auto-firing and the card offers a "Load profile" button instead — the manual escape hatch, mirroring the deliberate-button precedent set by "Find company" in [ChatDetail.tsx:95](apps/mail/modules/linkedin/components/ChatDetail.tsx).
15. **Card render** — `LinkedInCounterpartCard` in [LinkedInCounterpartCard.tsx](apps/mail/modules/linkedin/components/LinkedInCounterpartCard.tsx) (new). Header (avatar · name · headline · profile link), then a **Current role** line, a **Past** disclosure, and up to three **Recent posts**. The role line resolves `current` → the most recent role with no `end` date → the headline, and the headline is rendered once: when it stands in as the role line it is dropped from the header subtitle. Per `CLAUDE.md`, the Past disclosure animates its height (`AnimatePresence` + `initial/animate/exit` on `height`, `overflow-hidden`, one shared tween — never a spring, which would bounce every row below it), the toggle and each post row carry `cursor-pointer`, padding lives inside the hit area, and the card is one surface — no bordered box nested inside the panel.
### 3.3 Schema
Full schema:
```sql
-- NEW. Applied as targeted additive DDL (never `pnpm db:push` — see CLAUDE.md §4a),
-- as apps/server/src/db/migrations/linkedin_person_profiles.sql
CREATE TABLE IF NOT EXISTS linkedin_person_profiles (
provider_urn text NOT NULL, -- Unipile member urn (ACoAA…); the stable LinkedIn identity
organization_id uuid NOT NULL, -- → organizations.id
person_id uuid, -- → crm_person.id; NULL until the chat resolves a person
public_identifier text, -- real vanity slug once a profile fetch reveals it
name text,
headline text,
occupation text,
location text,
profile_picture_url text,
work_experience jsonb NOT NULL DEFAULT '[]'::jsonb, -- WorkExperienceEntry[], newest start first
posts jsonb NOT NULL DEFAULT '[]'::jsonb, -- CachedPost[], max 5, newest first
profile_fetched_at timestamptz, -- NULL = never fetched; drives PROFILE_TTL_DAYS (90)
posts_fetched_at timestamptz, -- independent TTL; drives POSTS_TTL_DAYS
last_skip_reason text, -- budget|unavailable|NULL — why the last refresh did less
last_skip_at timestamptz, -- WHEN that reason was recorded; it expires with its rate window
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- The ORG leads the key. One LinkedIn member can be in chats with several of our customers,
-- and the row belongs to the org whose seat paid for the view: on `provider_urn` alone the
-- second org's refresh overwrote the first org's row — `person_id` included, which points at
-- that org's own `crm_person` — and either org's read could return the other's cache.
PRIMARY KEY (organization_id, provider_urn)
);
CREATE INDEX IF NOT EXISTS linkedin_person_profiles_person_idx ON linkedin_person_profiles (person_id);
-- No org index: the primary key already leads with organization_id.
```
```ts
// NEW — apps/server/src/db/outbound-schema.ts (drizzle mirror of the DDL above)
export const linkedinPersonProfiles = pgTable(
'linkedin_person_profiles',
{
providerUrn: text('provider_urn').notNull(),
organizationId: uuid('organization_id').notNull(),
personId: uuid('person_id'),
publicIdentifier: text('public_identifier'),
name: text('name'),
headline: text('headline'),
occupation: text('occupation'),
location: text('location'),
profilePictureUrl: text('profile_picture_url'),
workExperience: jsonb('work_experience').$type<WorkExperienceEntry[]>().notNull().default([]),
posts: jsonb('posts').$type<CachedPost[]>().notNull().default([]),
profileFetchedAt: timestamp('profile_fetched_at', { withTimezone: true }),
postsFetchedAt: timestamp('posts_fetched_at', { withTimezone: true }),
lastSkipReason: text('last_skip_reason'),
lastSkipAt: timestamp('last_skip_at', { withTimezone: true }),
createdAt: now(),
updatedAt: updated(),
},
(t) => [
primaryKey({ columns: [t.organizationId, t.providerUrn] }),
index('linkedin_person_profiles_person_idx').on(t.personId),
],
);
// NEW — apps/server/src/db/outbound-schema.ts (beside ChannelChatDraft, the existing precedent:
// a jsonb row shape is DEFINED in the schema file and imported FROM it by services — the reverse
// would make the db layer depend on the services layer and cycle once person-profile.ts imports
// the table.)
/** One role, from Unipile `work_experience[]`. `companyId` is LinkedIn's hard org id. */
export type WorkExperienceEntry = {
company: string | null; // nullable: an id-only entry still links a company, so it is kept
companyId: string | null; // LinkedIn org id; the hard key resolveCompanyByLinkedinHardKey wants
companyUrl: string | null;
title: string | null;
location: string | null;
start: string | null; // 'YYYY-MM' as the provider gives it; not parsed
end: string | null; // null while current
isCurrent: boolean;
};
/** One post, normalized from `UnipilePost` (unipile-client.ts:61). Also defined in the schema. */
export type CachedPost = {
postId: string;
text: string;
postedAt: string | null; // ISO
url: string | null;
reactions: number;
comments: number;
isRepost: boolean;
};
/** Why a refresh did less than everything. `null` = it did everything. */
export type RefreshSkipReason = 'budget' | 'fresh' | 'unavailable';
/** The panel's whole payload. Returned by BOTH the free read and the refresh. */
export interface CounterpartProfile {
chatId: string;
urn: string;
personId: string | null;
publicIdentifier: string | null;
name: string | null;
headline: string | null;
location: string | null;
profilePictureUrl: string | null;
current: WorkExperienceEntry | null; // null when no hard `current` signal — never guessed
history: WorkExperienceEntry[];
posts: CachedPost[];
profileFetchedAt: string | null;
postsFetchedAt: string | null;
stale: boolean; // true ⇒ the client may auto-refresh once
skipped: RefreshSkipReason | null; // 'budget' ⇒ the card shows "Load profile" instead
}
// NEW — apps/server/src/services/integrations/linkedin/governor.ts
/** Read-only sibling of rateCheckAndIncrement (governor.ts:76) — does NOT consume a slot. */
export async function rateRemaining(
db: DB, unipileAccountId: string, action: RateAction,
): Promise<{ allowed: boolean; remaining: number; cap: number }>;
// NEW — apps/mail/modules/ux/layout/selectChannelChat.ts
/** The open artifact's chat id when it is a LinkedIn chat, else null. Primitive by contract. */
export const selectLinkedinChatId: (s: CedarStore) => string | null;
// CHANGED — apps/mail/modules/cedar-os/.../chatComponents/emptyChatLayout.ts
export type EmptyChatSection =
| { id: string; kind: 'agenda'; title?: string }
| { id: string; kind: 'welcome'; title?: string }
| { id: string; kind: 'tabbedPrompts'; title?: string; tabs: EmptyChatTab[]; defaultTabId?: string }
| { id: string; kind: 'taskShortcuts'; title?: string }
| { id: string; kind: 'inboxShortcuts'; title?: string }
| { id: string; kind: 'calendarRail'; title?: string }
| { id: string; kind: 'conversationShortcuts'; title?: string }
| { id: string; kind: 'conversationCards'; title?: string }
| { id: string; kind: 'linkedinProfile'; title?: string }; // NEW
// CHANGED — apps/mail/modules/cedar-os/.../chatComponents/EmptyStateSuggestions.tsx
interface EmptyStateSuggestionsProps {
layout: EmptyChatLayout;
agendaNode?: React.ReactNode;
conversationCardsNode?: React.ReactNode;
calendarRailNode?: React.ReactNode;
linkedinProfileNode?: React.ReactNode; // NEW — self-hides when absent
}
```
Relationship diagram:
```text
┌──────────────────────────┐
│ linkedin_chats │
│ chat_id (PK) │
│ organization_id │
│ unipile_account_id │──────────────────┐
│ person_id │──────┐ │
│ conversation_id │ │ │
└───────────┬──────────────┘ │ │
│ 1:N chat_id │ │
▼ │ │
┌──────────────────────────┐ │ │
│ linkedin_chat_ │ │ │
│ participants │ │ │
│ id (PK) │ │ │
│ chat_id │ │ │
│ participant_urn ────────┼──┐ │ │
│ public_identifier │ │ │ │
│ name / headline / photo │ │ │ │
│ person_id ──────────────┼──┼───┤ │
│ is_self │ │ │ │
└──────────────────────────┘ │ │ │
│ │ │
participant_urn ──1:1──────┘ │ │
│ │ │
▼ │ │
┌───────────────────────────────┐ │ │
│ linkedin_person_profiles NEW │ │ │
│ provider_urn (PK) │ │ │
│ organization_id ─────────────┼─┼───FK──► organizations.id
│ person_id ───────────────────┼─┤ │
│ public_identifier │ │ │
│ name / headline / occupation │ │ │
│ location / profile_picture │ │ │
│ work_experience jsonb │ │ │
│ ▼ contains WorkExperienceEntry[] │
│ { company, companyId, companyUrl, │
│ title, location, start, end, │
│ isCurrent } │
│ posts jsonb │ │ │
│ ▼ contains CachedPost[] │ │ │
│ { postId, text, postedAt, url, │
│ reactions, comments, isRepost } │
│ profile_fetched_at │ │ │
│ posts_fetched_at │ │ │
│ last_skip_reason │ │ │
└───────────────────────────────┘ │ │
│ │
person_id ──N:1──FK──► │
▼ │
┌──────────────────────────────────────────┐ │
│ crm_person │ │
│ id (PK) │ │
│ display_name │ │
│ headline ◄── gap-filled │ │
│ current_title ◄── gap-filled │ │
│ location ◄── gap-filled │ │
│ linkedin_provider_id = provider_urn │ │
│ current_company_id ──N:1──FK──► crm_company.id
└──────────────────────────────────────────┘ │
│
unipile_account_id ──N:1──FK──► linkedin_accounts.unipile_account_id
│ │
│ │ 1:N (unipile_account_id, action, window)
│ ▼
│ ┌────────────────────────────┐
└──►│ linkedin_rate_limits │
│ action = 'profile_view' │
│ window_kind = 'day' │
│ cap 50 · reserve gate 40% │
└────────────────────────────┘
```
## 4) Implementation phases
### Phase 1 — Cache table and the pure rules
**Goal:** Land the storage and the two pure functions the rest of the design depends on, with no behaviour change anywhere.
- [x] Add `apps/server/src/db/migrations/linkedin_person_profiles.sql` with the `CREATE TABLE IF NOT EXISTS` + two `CREATE INDEX IF NOT EXISTS` statements from §3.3, and apply it as targeted additive DDL
- [x] Add the `linkedinPersonProfiles` drizzle table to [outbound-schema.ts](apps/server/src/db/outbound-schema.ts), in the LinkedIn messaging block beside `linkedinChatParticipants`
- [x] Create [person-profile.ts](apps/server/src/services/integrations/linkedin/person-profile.ts) exporting `WorkExperienceEntry`, `CachedPost`, `RefreshSkipReason`, `CounterpartProfile`, and the constants `PROFILE_TTL_DAYS = 90`, `POSTS_TTL_DAYS = 1`, `PROFILE_VIEW_INTERACTIVE_RESERVE = 0.25`
- [x] Implement `splitWorkExperience(work)` in that file — current = `find(e => e.current)` ?? the sole entry; history = the rest, newest `start` first; drop only entries with neither a company name nor a company id
- [x] Implement `withinBackgroundProfileReserve({ remaining, cap })` in that file — `remaining > cap * PROFILE_VIEW_INTERACTIVE_RESERVE`, asked by background callers only
- [x] Implement `normalizePosts(items, urn)` in that file — filter to posts authored or reposted by `urn`, map `UnipilePost` → `CachedPost`, newest first, slice to 5
- [x] Replace the inline current-employer pick inside `resolveCounterpartCompany` at [messaging.ts:663](apps/server/src/services/integrations/linkedin/messaging.ts) with a call to `splitWorkExperience`, so the rule has exactly one definition
**Tests:**
- [x] `apps/server/src/services/integrations/linkedin/__test__/person-profile.rules.test.ts` — `splitWorkExperience` returns `current: null` when several entries exist and none is flagged (the no-guessing rule), picks the sole entry when there is exactly one, and orders history newest-first
- [x] Same file — `withinBackgroundProfileReserve` is true at `{remaining:17,cap:64}` and false at `{remaining:16,cap:64}`
- [x] Same file — `normalizePosts` drops a post authored by someone else, keeps a repost by the urn, and caps the list at 5
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/integrations/linkedin/__test__/person-profile.rules.test.ts`
### Phase 2 — Read-only budget probe
**Goal:** Let a caller ask how much `profile_view` budget is left without consuming a slot.
- [x] Export `rateRemaining(db, unipileAccountId, action)` from [governor.ts](apps/server/src/services/integrations/linkedin/governor.ts), reusing `windowStartFor` at [governor.ts:49](apps/server/src/services/integrations/linkedin/governor.ts) and the same multi-window `every window under cap` rule as `rateCheckAndIncrement` at [governor.ts:76](apps/server/src/services/integrations/linkedin/governor.ts), with no write
- [x] Factor the shared window/cap resolution out of `rateCheckAndIncrement` so the two cannot disagree about which buckets an action maps to
**Tests:**
- [x] Extend `apps/server/src/services/integrations/linkedin/__test__/send-governor.test.ts` — `rateRemaining` reports the same `{remaining, cap}` as `rateCheckAndIncrement` would, and calling it twice does not change the count
- [x] Same file — a multi-window action (`dm`) reports the tightest of its windows
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/integrations/linkedin/__test__/send-governor.test.ts`
### Phase 3 — Cache-first read
**Goal:** A free server read that returns everything the panel needs, correct on a cold cache.
- [x] Implement `readCounterpartProfile(db, { organizationId, chatId })` in [person-profile.ts](apps/server/src/services/integrations/linkedin/person-profile.ts) — resolve the non-self participant from `linkedin_chat_participants`, left-join `linkedin_person_profiles` on `provider_urn`, and never call Unipile
- [x] Fall back to the participant row's `name` / `headline` / `profile_picture_url` for the header fields when the cache row is absent, so a cold panel still identifies the person
- [x] Compute `stale` — true when `profile_fetched_at` is null or older than `PROFILE_TTL_DAYS`, or `posts_fetched_at` is null or older than `POSTS_TTL_DAYS`
- [x] Carry `last_skip_reason` through as `skipped`, so a client that was budget-blocked earlier does not immediately retry
- [x] Add `counterpartProfile` as a `privateProcedure.query` on `messaging` in [linkedin.ts](apps/server/src/trpc/routes/linkedin.ts)
- [x] Add `linkedin messaging profile --chatId <id>` to the CLI switch in [cli.ts:308](apps/server/src/outbound/cli.ts) and its help line at [cli.ts:134](apps/server/src/outbound/cli.ts), so the read is drivable headlessly
**Tests:**
- [x] `apps/server/src/services/integrations/linkedin/__test__/person-profile.read.test.ts` — a chat with no cache row returns the participant's name/headline, `current: null`, `posts: []`, `stale: true`
- [x] Same file — a chat with a fresh cache row returns `current`, `history`, `posts` and `stale: false`
- [x] Same file — a cache row whose `posts_fetched_at` is 8 days old returns `stale: true` while `profile_fetched_at` is fresh
- [x] Same file — a group chat with several non-self participants resolves deterministically (first by `participant_urn`) rather than at random
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/integrations/linkedin/__test__/person-profile.read.test.ts`
### Phase 4 — Governed refresh
**Goal:** Spend at most one profile view per person, only under the reserve, and bank it into the CRM.
- [x] Implement `refreshCounterpartProfile(db, { organizationId, userId, chatId, force })` in [person-profile.ts](apps/server/src/services/integrations/linkedin/person-profile.ts) — the seat is read off `linkedin_chats`, never taken from the caller
- [x] Let the profile half run against the daily cap alone; on a denial set `skipped: 'budget'` and continue to posts rather than returning early
- [x] Fetch the profile through `governedSend` at [messaging.ts:1581](apps/server/src/services/integrations/linkedin/messaging.ts) with `action: 'profile_view'` and `getUserProfile(urn, { sections: '*' })`, leaving `notify` at its `false` default
- [x] Fetch posts through `listUserPosts(urn, { limit: 10 })` at [unipile-client.ts:357](apps/server/src/services/integrations/linkedin/unipile-client.ts) in its own try/catch, on its own `POSTS_TTL_DAYS` check
- [x] Upsert `linkedin_person_profiles` with `ON CONFLICT (organization_id, provider_urn) DO UPDATE`, writing only the columns the half that ran actually fetched
- [x] Bank the paid view into `crm_person` exactly as `resolveCounterpartCompany` does — `coalesce` gap-fills for `headline` / `current_title` / `location`, and `current_company_id` via `resolveCompanyByLinkedinHardKey` when the current entry carries a `company_id`
- [x] Emit a `createStructuredLog('info', 'linkedin.counterpartProfile.refresh', …)` carrying `skipped`, `remaining` and `cap`, so the reserve can be tuned from Axiom rather than guessed
- [x] Add `refreshCounterpartProfile` as a `privateProcedure.mutation` on `messaging` in [linkedin.ts](apps/server/src/trpc/routes/linkedin.ts)
- [x] Extend the CLI command from Phase 3 with `--refresh [--force]`
**Tests:**
- [x] `apps/server/src/services/integrations/linkedin/__test__/person-profile.refresh.test.ts` — with a stubbed client, a cold cache under reserve fetches both halves and writes one cache row
- [x] Same file — at `{remaining:18,cap:50}` the profile half is skipped with `skipped: 'budget'` while posts are still fetched and stored
- [x] Same file — a `getUserProfile` throw leaves the existing cached profile columns intact and returns `skipped: 'unavailable'`
- [x] Same file — a second refresh inside `PROFILE_TTL_DAYS` without `force` spends no profile view (`skipped: 'fresh'`)
- [x] Same file — a refresh that resolves a `company_id` sets `crm_person.current_company_id` and does not clobber a non-null `current_title`
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/integrations/linkedin/__test__/person-profile.refresh.test.ts`
### Phase 5 — Empty-chat registry slot
**Goal:** Give the `channelChat` surface a `linkedinProfile` section that self-hides, with no card built yet.
- [x] Add `{ id: string; kind: 'linkedinProfile'; title?: string }` to `EmptyChatSection` in [emptyChatLayout.ts:86](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/emptyChatLayout.ts)
- [x] Add the `linkedinProfile` section ahead of `conversationCards` in the `channelChat` builder at [emptyChatLayout.ts:893](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/emptyChatLayout.ts), and update its header comment to say why the person now leads
- [x] Add the `linkedinProfileNode` prop and its render branch to [EmptyStateSuggestions.tsx:58](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmptyStateSuggestions.tsx), mirroring `conversationCards` — absent node returns `null`
- [x] Include `linkedinProfileNode` in the `topAlign` calculation so the card starts at the top of the body
- [x] Add `selectLinkedinChatId` to [selectChannelChat.ts](apps/mail/modules/ux/layout/selectChannelChat.ts), returning a primitive and documenting the `getDisplayArtifact()` allocation gotcha
**Tests:**
- [x] Update `apps/mail/modules/cedar-os/__tests__/emptyChatLayout.surfaces.test.ts:122` — the `channelChat` layout's sections are `['linkedinProfile', 'conversationCards', 'tabbedPrompts']` in that order
- [x] `apps/mail/tests/modules/ux/layout/selectChannelChat.test.ts` — returns the id for a `linkedin_chat` artifact and `null` for `slack_thread`, `whatsapp_chat` and no artifact
- [x] Same file — the selector returns a primitive (two calls against an unchanged store are `===`), matching the pin on `emptyChatSurface.store.test.ts`
- [x] `npx jest tests/modules/ux/layout/selectChannelChat.test.ts` and `npx jest modules/cedar-os/__tests__/emptyChatLayout.surfaces.test.ts` from `apps/mail`
### Phase 6 — The card
**Goal:** Render current role, past history and recent posts in the panel, with the auto-fetch-under-reserve behaviour.
- [x] Add `useCounterpartProfile(chatId)` in [use-counterpart-profile.ts](apps/mail/modules/linkedin/hooks/use-counterpart-profile.ts) — the free query, plus a once-per-`chatId`-per-session refresh fired only when the read returns `stale` and `skipped !== 'budget'`
- [x] Reconcile the refresh result into the query cache via `setQueryData` rather than an invalidate, so the card does not flash back to its cold state
- [x] Build `LinkedInCounterpartCard` in [LinkedInCounterpartCard.tsx](apps/mail/modules/linkedin/components/LinkedInCounterpartCard.tsx) — renders nothing when `selectLinkedinChatId` is null
- [x] Header row: avatar, name, headline, and an external link to `https://www.linkedin.com/in/<publicIdentifier ?? urn>` with `cursor-pointer`
- [x] Current role: title and company, with tenure since `start`. When `current` is null — the COMMON case on live data — fall back to the most recent history entry with no `end` date, then to the headline. This fallback is display-only and is deliberately not fed back into `splitWorkExperience`, which stays strict because a wrong `current_company_id` caches permanently
- [x] Past history: a height-animated disclosure (`AnimatePresence`, `initial/animate/exit` on `height`, `overflow-hidden`, one shared tween duration, never a spring), collapsed by default, listing company · title · dates
- [x] Recent posts: up to three rows — relative date, two-line clamped text, reaction and comment counts — each a link to the post, with padding inside the hit area and `cursor-pointer`
- [x] Budget state: when `skipped === 'budget'`, render a "Load profile" button that calls the refresh with `force`, with the same deliberate-action framing as "Find company" at [ChatDetail.tsx:95](apps/mail/modules/linkedin/components/ChatDetail.tsx)
- [x] Loading and empty states: skeleton rows while the first read is pending; when the cache is warm but genuinely empty, show the header alone rather than an error
- [x] Supply `linkedinProfileNode={<LinkedInCounterpartCard/>}` at [EmbeddedCedarChat.tsx:1268](apps/mail/modules/cedar-os/src/cedar-os-components/chatComponents/EmbeddedCedarChat.tsx), wrapped in `<Suspense fallback={null}>` like `ThreadContextCards`
- [x] Export the card and hook from [index.ts](apps/mail/modules/linkedin/index.ts)
**Tests:**
- [x] `apps/mail/tests/modules/linkedin/LinkedInCounterpartCard.test.tsx` — renders current role, past roles and posts from a stubbed query
- [x] Same file — renders nothing when the open artifact is a `slack_thread`
- [x] Same file — shows "Load profile" and fires no auto-refresh when the read returns `skipped: 'budget'`
- [x] Same file — auto-fires the refresh exactly once for a `stale` read, and not again on re-render
- [x] Same file — with `current: null` it falls back to the most recent history entry whose `end` is null (an unended role is itself a hard signal), and to the headline when none is ongoing — never to `history[0]`
- [x] `npx jest tests/modules/linkedin/LinkedInCounterpartCard.test.tsx` from `apps/mail`
- [x] `timeout 300 pnpm --filter @zero/mail run types` and `timeout 300 pnpm --filter @zero/server run types`
## 5) Follow-up — the artifact every channel was missing
Shipped after the first live test, which found the panel never rendered at all.
**The bug.** `openChannelChat` ([use-open-channel-item.ts](apps/mail/modules/inbox/hooks/use-open-channel-item.ts))
set a display artifact only for Slack; LinkedIn and WhatsApp lived in a module store alone. §3.2
step 1 assumed the artifact was there, and it never is when a chat is opened from the unibox — so
`selectLinkedinChatId` returned null, the surface resolved to `mail` rather than `channelChat`, and
the card had no slot to render into. The same gap is why a LinkedIn row left the URL untouched.
**The fix.** All three channels now set `{kind, id}` via `CHANNEL_CONTEXT_KINDS`, and
`LayoutUrlSync`'s Slack block was extracted into `useChannelArtifactParam(param, kind)` and called
three times — `?slack=`, `?linkedin=`, `?whatsapp=`. One copy of the ref handshake instead of three.
**The behaviour this changes**, deliberately: a LinkedIn/WhatsApp chat now takes the single display
slot, so opening one displaces a conversation, and the back button closes it — exactly as Slack has
always behaved. Three tests in `openChannelItem.test.tsx` pinned the old asymmetry and were rewritten.
**Presentation**, from looking at real profiles in the browser:
- The unlinked `conversationCards` empty state is suppressed on `channelChat` (`hideWhenUnlinked`).
For an email thread "No conversation linked" is a useful offer; for a DM that will never be a deal
it is a permanent "no" above the only thing the surface has to say.
- The role shown as current is filtered out of Past (`pastRoles`) — `resolveDisplayRole` usually
picks it FROM history, so the same job rendered twice.
- Past is a timeline: a rail with a dot per employer, and consecutive roles at one company grouped
under it (`groupByCompany`), so a promotion reads as one job rather than three. Only consecutive
runs merge — someone who left and came back keeps two stints.
- Recent posts are a horizontal carousel (`HorizontalScrollContainer`), which fits all five cached
posts in one row's height instead of three stacked rows.
### 5.1 Company marks, and what we actually have
Checked before designing around them:
| source | covers |
| --- | --- |
| `crm_company.logo_url`, joined on `linkedin_id` = `work_experience[].company_id` | 16,461 companies — the ones we already track |
| `crm_company.primary_domain` → favicon (the `WebCitationPill` pattern) | fills in where a tracked company has no stored logo |
| the provider | **nothing** — the profile payload carries no logo, and `company_url` is null on every cached entry |
So a counterpart's PAST employers are usually not companies we hold, and there is no free way to
fetch their marks. The initials monogram is therefore the design, not a degraded state — and the
logo appears exactly where it matters most for a sales panel: when the employer is already a
prospect or customer of ours. Rendering goes through the app's own `CompanyAvatar`, so a company
here looks like the same company on a deal card.
Logos resolve at READ time and are never stored (`ResolvedWorkEntry`), so a company enriched next
week starts showing a mark on a profile cached today.
Name matching was considered and rejected: none of the sample's employers were in `crm_company` by
name either, and a same-named company would put the wrong logo on someone's history.
### 5.2 Layout
- **Posts above experience.** The career is background you skim once; what they posted last week is
what gives you an opening line today.
- **The timeline never collapses.** It was a disclosure; the collapse only put a click between the
reader and the shape of someone's career (how long, how many moves, which companies).
- **The avatar column is the timeline** — one mark per employer with a hairline behind them, so the
eye tracks one spine rather than a stack of rows.
## 6) Follow-up — the budget, measured
Shipped after the panel was found empty in daily use: header filled in, timeline and posts blank on
almost every chat. The panel was not broken. It was starving, and it was being starved by the part
of the system that had already bought what it needed.
**What the data said.** `linkedin_person_profiles` held **4 rows against 1,146 distinct chat
counterparts**, none written in ten days. Meanwhile **716 people carried a banked
`linkedin_provider_id`** — one `profile_view` spent on each, **701 of them chat counterparts** — of
which **8** yielded a `current_company_id` and **10** a `current_title`. `resolveCounterpartCompany`
makes the identical `getUserProfile(sections:'*')` call the panel makes, reads one field, and used
to drop the rest. Roughly seven hundred complete work histories, bought and binned.
Live confirmation on the newest chat in the org: the refresh returned `skipped: "budget"` with
posts fetched and `work_experience` empty — the exact shape of the empty panel.
**Three changes.**
1. **The payload is kept.** `resolveCounterpartCompany` writes the whole profile through
[person-profile-cache.ts](apps/server/src/services/integrations/linkedin/person-profile-cache.ts),
which both it and the refresh now share as the single payload→columns mapping. A chat that has
ever synced is warm for free; the panel stops being the thing that pays.
2. **The reserve is inverted.** It protected the sequencer, which does not spend from this bucket —
sends book `dm` / `connect` / `inmail`. It now protects the interactive read: background callers
(`resolveCounterpartCompany`, `deobfuscateViaSeat`, the sequencer's `linkedin_view` step,
`signalProfileViewers`) stop at 25% remaining; the panel consults no reserve and spends to the
cap. Two callers stay ungated on purpose — `resolveProviderId`, because a send must never be
blocked by a panel's reserve, and `viewProfile` itself, which also serves the CLI, the tRPC route
and the agent tool; the warm-up gate therefore sits on the sequencer's call, not inside it.
3. **The cap is 64, not 50.** 50 was never sourced: `sequencing.md` §4 cites limits for `connect`,
`dm` and `inmail` and does not list this action at all. The seat hit 50 outright on 2026-09-15
and came within a handful of it on three other days that week.
**Measured spend, one seat, the week before the change:** `4 · 33 · 50 · 12 · 22 · 29`. Every other
seat in the database ran 2–4/day. The stop line under the old reserve was 29.
**Also paused, separately from the code:** the two subscription-triggered Cedar Inputs on that seat
(`My LinkedIn Engagers FULL`, `My LinkedIn Post Engagers (live)` — `my_post_engagement`, 10 post
pages × 30 reaction pages), which fired 12–30 runs a day in bursts of seven and eight identical
runs within the same second. That burst pattern is its own bug and is not addressed here.