Docs · page 08 of 09
The Network CRM
The Network CRM is the relationship half of Studio. When you are about to message, comment on or reconnect with someone, it tells you what relationship already exists — every message, invitation, endorsement, recommendation, comment and reaction you have with that person — and drafts an opener in your voice.
It never acts on your behalf. Four hard limits, enforced in code:
- No auto-send. Guidance produces text with a Copy button. The panel ends with the line "This is a draft — you send it. Nothing is sent, connected, or posted automatically."
- No auto-connect. There is no endpoint that sends or accepts an invitation.
- No profile scraping. The server cannot read a LinkedIn profile: profile pages answer this VPS with HTTP 999. Every profile fact arrives from your own export or from your own logged-in browser, one profile at a time, when you click.
- No bulk PII to a model. One contact's compact dossier goes to Claude per request — never the roster, never message bodies in aggregate.
Getting data in
To load your history: run the importer once on the VPS, against the local database. The script lives in the Studio app directory and the path is relative to it, so start there:
cd /home/mg/linkedin-post-gen/studio
node scripts/import-network.mjs \
--source "/home/mg/linkedin-export/LInkedIn" \
--self-slug <your-linkedin-slug> \
--dry-run
--source is an absolute path to the export folder on the VPS. --self-slug is how the importer decides whether a message was sent or received. --dry-run counts every row and writes nothing — and needs no database at all.
Drop --dry-run to commit, and the script needs a connection string: set DATABASE_URL (or STUDIO_DATABASE_URL) in the environment, or pass --database-url <url>. Without one it exits with DATABASE_URL not set (required unless --dry-run).
Five phases run in order — contacts, relationship scores, segments and tracks, interactions, stats snapshot — and each source file read appends a network_import_log row with rows seen, upserted and skipped (so one phase can write several).
Every write is an idempotent upsert on a natural key (the slug, the message's dedup hash), so a second run against a fresh export produces only the delta.
There is also POST /api/network/admin/import. It is double-gated: the x-studio-token header and NETWORK_IMPORT_ENABLED, which defaults off and answers forbidden without naming the flag. It accepts only an absolute server-side path — a URL is rejected with source must be a server-side path, not a URL, and traversal with source must be an absolute path without ".." segments. The CLI is the supported path.
To add a full profile: open someone's LinkedIn page, click 🔎 Who is this?, then 📥 Save full profile to CRM (or ➕ Add this profile to CRM if they are new). The extension scrolls the open tab, expands "…see more" and posts ~21 bounded fields. Curated columns are never overwritten: on a contact you already have, location, position and company fill only if blank, and the captured name is discarded entirely — a scraped name is used only when the contact is brand new. Studio shows the result as a collapsible 🔗 Captured LinkedIn profile block with 14 sections.
Good to know
If the page had not finished rendering, the save returns
profile looked empty — let the page finish loading, then retry. Scroll once and click again.
Commenters log themselves. When you right-click Capture post + comments to Studio, any commenter who resolves to a contact you already have gets an inbound comment interaction (Commented on your post: "…"), deduped per post and person. People you don't already know are never auto-added.
Finding someone
Open the Network tab and type into Search a contact by name or company…. Matching is trigram-fuzzy on both name and company, so a misspelling still lands; results rank by match score, then relationship-strength score, then name. The box debounces 250 ms, and Esc clears it.
Narrow with All tiers (T1–T4) and All segments (Champions, Reactivate, Pipeline). Both apply the moment you change them — Refresh is there to re-run the same query by hand.
Paging is client-side. The loader pulls the whole matched roster in one request, then Per page slices it — default 10, up to 1000, remembered across reloads — with page tabs and a Showing X–Y of Z summary. An empty state reads "Type a name or company to find someone in your network.", or "No contacts match these filters." once filters are set.
The contact drawer
Clicking a card opens a drawer built from a single request. The header carries the name, Title @ Company, the segment pill, and one score pill per scoring model — Rel, 100 and AI — each showing the score and its tier (for example Rel 62 T1), plus a Future <A–E> pill when the segment row carries one.
Below it:
- Topics discussed — chips of what you actually talked about, or "none detected in message history".
- Profile facts — LinkedIn, Email, Phone, Gender, Role / company, Connected, Region / industry, Location, Contact history, Reciprocity, Why you know them.
- 🔗 Captured LinkedIn profile, when the extension has saved one.
- Interaction history — newest first across eight channels: message ✉, invitation 🤝, endorsement 👍, recommendation ⭐, comment 💬, reaction ❤, meeting 📅, note 📝. Each row shows direction and date. Rows the importer could not attribute with certainty are muted and badged inferred (title: Attribution inferred, not certain).
- Notes — Add a private note… then Add note. The list shows your own notes only: guidance snapshots share the
contact_notestable under a different kind and are filtered out. The route caps a note at 8,000 characters, the store at 20,000.
The per-card 🔄 button is honest about its limits: it only sets a flag. The toast says "Refresh requested — the LinkedIn extension will re-harvest this profile."
"Guide me"
Click Guide me on engaging in the right rail. Before any model call, Studio assembles a deterministic dossier — tier, quadrant, reciprocity, the shared-history hook, message and endorsement counts, recency, topics, and at most five recent interaction summaries capped at 140 characters each — and picks one playbook locally:
| Situation | Play |
|---|---|
| Champion | comment-first co-sign |
| Reactivation candidate | warm reconnect (no ask for 1–3 months) |
| Shared past employer | ex-colleague reconnect |
| Strong tie (≥6 exchanges, or T1) | peer check-in |
| Everyone else | light touch |
One metered call then fills the panel: History, Relationship read, How to engage, Cadence, up to three Draft openers each with Copy, and a ⚠ banner for safety flags such as recruiter-pitch or sensitive-event. (The response also carries a confidence value; the panel does not display it.)
The result is cached on the contact — a reopened panel says "Showing your last saved guidance · Refresh for a fresh take." — and Refresh guidance forces a new call.
Spend is metered per contact against NETWORK_TOKEN_CAP_USD (default 20). A success toast reads Guidance ready · spend $0.01 / $20.00. Past the cap the panel still fills, but from the dossier alone with no model call: History, Relationship read, the play label and its cadence, no draft openers, flagged fallback: deterministic (no model output) and token cap $20 reached — guidance not refreshed.
Good to know
The cap fails open: if the spend ledger cannot be read, it reports $0 rather than blocking you. It is a budget guard, not a security control. With no key configured the endpoint returns
network guidance not configured — add your Anthropic key in Settings. If the model call itself fails or is refused, you still get that same openerless deterministic read, flaggedmodel call failed: …; theguidance failed: …error is reserved for a failure earlier in the request.
Segments, tracks and the warm rail
GET /api/network/segments/:name pulls a whole list to work through: champion, reactivation, pipeline, or a curated track A–D. Anything else returns unknown segment 'x' (expected champion|reactivation|pipeline or a track A-D). There is no screen for it — like every /api/ path it is called directly with the x-studio-token header.
In the Network tab itself, above the results list, sits 🔥 Strike while warm — engaged with your posts in the last 14 days: up to eight chips for contacts with inbound comments, reactions or messages in that window, newest first, each opening the drawer.
Identity resolution
A profile URL is the gold path: the slug is the key the whole dataset is built on, so a URL match resolves at confidence 1.0.
With only a name, candidates are ranked by similarity(name). When a company hint is sent too, the score becomes 0.7 × similarity(name) + 0.3 × similarity(company). Either way confidence is clamped to a maximum of 0.95 so a name can never claim a slug's certainty. A candidate counts as matched only when it is the sole result or beats the runner-up by 0.3. A tie always asks — the extension shows "Not sure who this is" with up to six candidates and never picks for you. No match at all reads "Not in your network CRM".
Name and company hints are sent only when no slug exists. Calling /api/network/resolve with neither returns provide a url or a name to resolve; a URL with no extractable slug returns could not extract a LinkedIn slug from url.
Privacy invariants
These are properties of the code, not policy statements:
- Anonymized rows are quarantined. Search, contact reads, both resolvers, the warm rail and the refresh endpoints all carry
is_anonymized = false. Those records exist; nothing surfaces them. - Message bodies are stored but never served.
interactions.bodyis written by the importer and selected by no ordinary read path — the timeline returnssubject,summary, channel, direction and confidence only. The single exception is the flag-gated DSAR export below. - At most five 140-character summaries for one contact ever reach the model, alongside the compact dossier and the playbook label. Never the roster, never a body.
- Workspace RLS underneath. Every request runs on a tenant-scoped handle, so another workspace's contact id simply matches zero rows.
Capture governance (flag-gated)
CAPTURE_GOVERNANCE_ENABLED is off by default; while off, /api/governance/policies answers { "ok": true, "enabled": false, "policies": null } and the active surfaces return capture governance is not enabled.
Switched on, it gives you:
- Retention policy per data class —
contacts_captured,feed_items,interactions, eachnull(keep forever) or 1–3650 days. Out of range returnsinteractions must be null or an integer between 1 and 3650 days. A daily sweep purges what is past the cutoff and writes apurge_logrow for every pass, dry or real. Purgingcontacts_capturedclears the scraped blob and keeps the CRM row. - A processing summary — counts of contacts, interactions, notes and feed items held, with oldest timestamps, alongside the policies.
- DSAR subject-access export —
GET /api/governance/export/:contactIdreturns the complete contact row, its interactions (bodies included) and its notes. - Hard erasure —
DELETE /api/governance/contact/:contactId?confirm=truedeletes the contact and cascades to interactions and notes, then audits it as adsar_contactrow inpurge_log. Without the flag it refuses; without the confirm it returnserasure is irreversible — repeat the request with ?confirm=true.
Good to know
The processing summary ships its own caveat: "Technical controls only; a DPA, lawful-basis records and legal sign-off are still required." These endpoints give you the mechanisms a data request needs. They are not a completed compliance programme.
Known gaps and direction
- Pipeline has no play. There is no pipeline branch in the decision tree, so a pipeline-flagged contact resolves through the normal tree — champion, reactivation, ex-colleague or peer check-in if one of those matches first, otherwise light touch. Mid-tier T2/T3 lands on light touch too, by design.
- Stats have no screen.
GET /api/network/statsreturns the importer's phase-5 snapshot; the Network tab never calls it. - No track filter in the UI. Curated tracks A–D are reachable through the segments API only; the tab offers tier and segment selects. Bulk refresh (≤500 ids) is likewise API-only.
- The timeline is a photograph until capture runs. In the reference instance the newest imported interaction is dated 2026-05-22. New rows arrive only from feed captures of your own posts.
- The unified inbox is an idea, not a feature. One console for everything said to you across platforms is the direction, and what each platform permits is already known: LinkedIn's
r_member_social_feedis restricted, so reading comments and DMs goes through your own browser session, not an API; Facebook Pages allow comment reads withpages_read_engagementand replies withpages_manage_engagement; Instagram usesinstagram_manage_comments; Threads hasthreads_read_repliesandthreads_manage_replies; TikTok's Display API exposes no comment read or reply for an unaudited app. Nothing there changes the draft-only rule.