Skip to content

Repository files navigation

Pinlyx Clipper: save a LinkedIn, X, Instagram or GitHub profile into your CRM in one click

Pinlyx Clipper

Chrome/Edge extension (Manifest V3) that saves a profile into Pinlyx in one click, from X, LinkedIn, Instagram, GitHub, or any other web page. Note, tags, pipeline stage and a dated follow-up, filled in before the contact is created rather than after.

Install from the Chrome Web Store - published 2026-08-27, and it runs on any Chromium browser (Chrome, Edge, Brave, Opera, Arc). The Edge Add-ons listing is not up yet.

This is the full source of the published extension. It is here to be read: an extension that reads the page you are on should be one you can check, and everything it does and refuses to do is in this repository, unminified, with the reasoning next to it.

It works with no account. The first contacts are kept in the browser, and the account is offered afterwards as the thing that moves them into a CRM rather than as a gate in front of the first save. It works on the Free plan after that.

Three languages: English, Turkish and Russian, panel and in-page alike.

What it looks like

The side panel opens next to the profile you are reading. Name, headline, company and picture are already filled in; you add the part a CRM actually needs.

Saving a LinkedIn profile to Pinlyx from the extension side panel, with note, tags and follow-up

Everything saved stays one click away, newest first, each row a link into the contact record in Pinlyx.

Recent captures list in the Pinlyx Clipper side panel

Without an account the first contacts are held in your own browser, and nothing leaves the machine until you connect one.

The clipper working with no account, contacts held locally in the browser

What it will not do

Worth reading before the build instructions, because it is the part that decides whether this belongs in your browser. Every claim here is enforced by the shape of the code, and each has a section further down:

  • Nothing is read until you click. No background sweep, no crawling, no pagination, no reading a page you did not open.
  • No in-page UI on LinkedIn, at all, and no reading of LinkedIn lists. Their User Agreement forbids inserting elements into their pages, and enforcement runs against your account, not ours.
  • One fetch() in the whole extension, and it only ever addresses api.crmsolid.com. No analytics, no third-party endpoint, no remotely hosted code.
  • No <all_urls>, no password, and no web_accessible_resources.

Licence

MIT, in LICENSE. That grant covers the code.

It does not cover the name or the mark: "Pinlyx" and the Pinlyx icons are ours, so a fork you publish to an extension store needs its own name and its own icons. Fork it, point src/lib/config.ts at your own API, and call it something else.

Build

npm install
npm run build      # typecheck, then content script, then side panel + worker
npm run zip        # dist/ -> crmsolid-clipper-<version>.zip, ready to upload to either store

Load dist/ through chrome://extensions → Developer mode → Load unpacked.

The build runs twice on purpose. MV3 content scripts cannot be ES modules, so vite.config.content.ts emits content.js as a single IIFE first (clearing dist/), and vite.config.ts then adds the side panel and the service worker without wiping it.

Output is unminified with source maps, which is a store requirement rather than a preference. Chrome forbids obfuscation and permits minification; Edge forbids obfuscation and grants no minification carve-out, reserving the right to ask for a refactor of anything a reviewer finds unreadable. Neither store asks for source maps or accepts one in place of readable code, so the answer to both is to ship what was authored. The package is installed once and never fetched over the wire, so the size costs nothing.

npm run zip writes the archive itself rather than shelling out to Compress-Archive, which on Windows PowerShell 5.1 stores _locales\en\messages.json with backslashes. That is invalid per the ZIP spec, and an uploaded package built that way has no _locales directory and no icons directory as far as the store is concerned. The failure surfaces as "missing default locale" with nothing pointing at the packaging step.

Architecture

side panel (React) ─┐
                    ├─► service worker ──► https://api.crmsolid.com/api/extension/*
content script ─────┘   (only holder of the token)
   ├── pill injected into the host page's own action row  (only where allowed)
   └── in-page card (shadow DOM, plain TS)
Piece File Responsibility
Service worker src/background/index.ts Holds the token, calls the API, runs the pairing poll and the offline queue, opens the side panel
Side panel src/sidepanel/* Persistent surface: follows the active tab, capture form, recent list, settings
Content script src/content/index.ts Extracts the page, plants the in-page UI where permitted, tracks SPA navigation
In-page UI src/content/inline/* Pill button in the host's action row + the card it opens
Host policy src/content/inline/policy.ts Which hosts we may draw on at all, and why
Extractors src/extractors/* JSON-LD → platform DOM → OpenGraph, merged
Theme src/ui/theme.css Light-first tokens; dark follows the OS unless pinned
Dedup mirror src/lib/profileKey.ts Client copy of Services/Clipper/ProfileKey.cs

What we are allowed to draw, per host

src/content/inline/policy.ts is the single table. It is not a feature flag: it is where a platform's terms of service get turned into code, and both the content script and the anchor finder read it before anything reaches the page.

The rule: a platform gets no in-page UI when its own terms say so in terms, and keeps it when they do not. Not "when a broad clause might be stretched to cover it". Every clause below was read from the live primary source.

Host Pill in their action row Floating badge Why
LinkedIn no no User Agreement 8.2 forbids overlaying the Services and inserting elements into them, by name, and a separate policy page names browser extensions
X yes yes Their restrictions are about how you reach their servers. Nothing in the document mentions overlays or page appearance
Instagram yes yes Terms 4.2 has no injection clause. The one that did exist was deleted in the 2018 rewrite
Anywhere else no yes No action row we recognise, and no user agreement to breach

Capture is untouched everywhere. The side panel is browser chrome, drawn outside the tab, and reaches every host including LinkedIn.

One thing that is not about drawing belongs in the same table, because it is decided the same way. The List tab reads the people already rendered on a search or followers page and is available on X, Instagram and GitHub. It is off on LinkedIn, which is the second of the five constraints recorded in policy.ts: capture on an explicit click, one person at a time, no bulk export. Every LinkedIn extension that has been shut down was shut down for the list, not for the single save.

Two constraints hold wherever the pill is on, and they sit in policy.ts next to the table. Never reuse the host's own CSS classes to make our control look native: at least one competitor builds its LinkedIn button out of LinkedIn's artdeco-button classes, and that is the fact that turns a contract question into a trademark one. Ours lives in a shadow root with its own styles. Never touch the request path: read only what the page already loaded for the person looking at it. That distinction is what decided Meta v. BrandTotal, where the version that "merely logs information that Facebook transmits to the user" fell outside the CFAA and the earlier one that made the browser query Facebook did not.

LinkedIn draws nothing. Section 8.2 of the LinkedIn User Agreement lists, among the things a member must not do:

Overlay or otherwise modify the Services or their appearance (such as by inserting elements into the Services)

That sentence names both surfaces we had: "overlay" is the floating badge, "inserting elements" is the pill. The deciding factor was not the size of the risk but who carries it. Enforcement under 8.2 runs against the member's account, so the cost of being wrong lands on the person who installed us, not on us. One saved click is not worth somebody's LinkedIn account.

Nothing about capturing from LinkedIn changed. The side panel is browser chrome, drawn by the browser outside the tab, so it modifies no page and 8.2 does not reach it; the toolbar button, the keyboard shortcut and the right-click menu all still read the page and save it. On LinkedIn the content script is a reader and only a reader: it answers CLIPPER_EXTRACT when a surface the user opened asks it to, and it does not even run the "is this person already in your CRM?" lookup, because there is no in-page label for the answer to go into.

A separate LinkedIn policy page removes any doubt about whether an extension is what 8.2 means: it says they do not permit "browser plug-ins, or browser extensions that scrape, modify the appearance of, or automate activity on LinkedIn's website", enforced by accounts "restricted or shut down". This is not theoretical. Kleo, a LinkedIn extension with more than 70,000 users that injected into the feed, shut down in June 2025 after LinkedIn asked it to, and several vendors moved to side panels through 2026 for the same reason.

This extension declares no web_accessible_resources at all, and that should stay true. There is nothing to inject on LinkedIn, so there is nothing an injectable resource would be for.

There are no LinkedIn selectors anywhere in this package, neither for planting a control nor for reading a list. Both existed once, unreachable behind the policy table, on the argument that measured selectors are knowledge worth keeping. Both were deleted before the store submission, because the argument does not survive being read by someone else: the package ships unminified with source maps, and a working routine for finding LinkedIn's Connect button gated behind one boolean reads as a feature awaiting a flag, not as restraint. The defence is that we do not touch their pages, and it is only credible while there is nothing here to touch them with. If their terms change, both can be written again from the live DOM in an hour.

The reasoning behind each row of the table above, clause by clause and source by source, is kept with the product rather than in this file.

Instagram keeps both surfaces, and the reason is a deletion. Terms of Use 4.2 lists ten things you can't do and none is about overlaying or inserting. That absence is deliberate: the 2013 terms said, in terms, "You may not inject content or code or otherwise alter or interfere with the way any Instagram page is rendered or displayed in a user's browser or device." The 2018 rewrite deleted that sentence and never replaced it.

Which settles the one clause that looks like it might reach us, "You can't modify, translate, create derivative works of, or reverse engineer our products or their components." Read as covering browser-side rendering, it would have made the 2013 sentence redundant when it was written and its removal meaningless. "Our products or their components" means Instagram's software, not the DOM as it renders on somebody's screen.

The neighbouring bullets do not reach either surface. "Interfere with or impair the intended operation of the Service" describes breakage, and a passive button impairs nothing. "Access or collect information in unauthorized ways... including... in an automated way" describes collection that runs by itself, where nothing here is read until somebody clicks.

X keeps both surfaces. x.com answers scripted requests to /en/tos with HTTP 402, so its terms were read in a real browser rather than fetched. The operative restriction, item (iii) under "Misuse of the Services", is about the request path and nothing else:

access or search or attempt to access or search the Services by any means (automated or otherwise) other than through our currently available, published interfaces... (NOTE: crawling or scraping the Services in any form, for any purpose without our prior written consent is expressly prohibited)

There is no sentence anywhere in the document about overlays, framing, appearance, look and feel, or the user interface. The nearest candidate, "reproduce, modify, create derivative works... or otherwise use the Services or Content on the Services, you must use the interfaces and instructions we provide", sits in the intellectual-property paragraph beside the developer-terms link and is about exploiting the Services and their Content, not about what a browser renders locally. The page the pill appears on was loaded by the person reading it, through X's own published interface.

X's terms carry no clause numbers for any of this, so a citation of the form "X ToS 8.2" is invented and is a sign that whoever wrote it did not read the document.

The scraping sentence is the same open question as LinkedIn's third prohibition. It is about capture rather than about drawing, and it is recorded rather than resolved.

Side panel, not a popup

The toolbar icon opens a docked side panel (chrome.sidePanel), which stays open while the user browses and re-reads each tab they land on. There is no action.default_popup in the manifest on purpose: a popup always wins over setPanelBehavior, and that is the usual reason a side panel silently refuses to open. The behaviour is set from the worker, which also re-asserts it on every wake since MV3 tears the worker down after ~30s idle.

sidePanel.open() needs a live user gesture, so the OPEN_PANEL message is handled synchronously at the top of the message listener — awaiting anything first loses the gesture and the call is rejected.

In-page UI

On X and Instagram the extension plants a pill into the site's own action row (next to Follow / Message) rather than floating over the page, and opens a card from it: note, tags, pipeline stage, save. Everywhere else the floating badge is used, and on LinkedIn neither appears.

Keeping it planted is the hard part. Those sites re-render their headers constantly, and each re-render discards foreign nodes, so InlineButton watches the document with a MutationObserver (coalesced through requestAnimationFrame) and re-plants whenever its anchor is replaced. src/content/inline/anchors.ts holds every selector, ordered data-testid → semantic landmark → class name, each with a fallback behind it.

findAnchor() consults the host policy before it looks for anything. A host we may not modify has no anchor to find, so the rule cannot be bypassed by a future caller who forgets it.

The card is plain DOM, not React: it ships inside the content script that loads on every supported page, and a framework would multiply that cost for a form with four controls.

Authentication

Never a password. Two flows, one of them for a case the other cannot cover.

Default, one click, nothing typed. The extension opens app.crmsolid.com/extension?ext=<id>&nonce=<n>. The user presses Approve once; the page mints a token with its own session and hands it back over chrome.runtime.sendMessage. Only origins listed in externally_connectable can reach that listener, and the nonce proves the reply belongs to the handshake this extension started rather than a page replaying an old token.

Fallback, a typed code. POST /api/extension/pair/start returns an eight-character code the user approves in a browser we cannot message. This is what a device-code flow is actually for: a device that cannot host the session. Using it as the default imposed television-grade friction on a browser that was already signed in to us.

The token only reaches /api/extension/*. It is stored in chrome.storage.local (never sync — that would push a credential to every profile the user is signed into), and the user can revoke any browser from the panel.

Free tier

The binding limit is a lifetime stock of 300 contacts, counted from contact_social_profiles, not a monthly flow. A monthly reset teaches people to batch and wait: someone who hits the wall on the 8th goes dormant for three weeks, which is the opposite of the daily habit the product is built around. The stock cap arrives when the CRM has genuinely become theirs.

Deliberately a separate entitlement key (quota_extension_contacts) rather than quota_contacts, which covers every contact from every source and is unlimited on every plan. Capping that one would take a working feature away from existing free users.

The cap is soft, with one save of grace: the save that reaches the limit is accepted and flagged, and only the next one is refused. Refusing at the boundary destroys work the user had already typed.

Permissions, and why each is the minimum

Permission Why
storage The token, caches and the offline queue
activeTab Read the current page when the user clicks the toolbar icon. This is what makes "save any page" work without <all_urls>
scripting Inject the extractor into a tab that has no content script
contextMenus Right-click → save page / save selection as a note
alarms Poll the pairing handshake and retry the offline queue after MV3 kills the worker. setInterval does not survive a worker restart
sidePanel The docked panel
host_permissions: api.crmsolid.com The API, and nothing else
content_scripts on x / twitter / linkedin / instagram Read the profile in front of the user, and on X and Instagram draw the save control. Six exact hosts, not a wildcard

There is no <all_urls>, and no optional_host_permissions. The broad optional http/https pair used to be declared for "the badge everywhere" and was never requested by any code path, so it did nothing but sit in the manifest looking like a wildcard. Saving an arbitrary page already works without it: activeTab grants access to the current tab at the moment the user clicks the toolbar icon, presses the shortcut or uses the right-click menu, and scripting injects the extractor into that one tab. A permission nobody requests is not a smaller permission, it is an unexplained one, and an unexplained host permission is the most reliable way to add two weeks to a store review.

Because every API call goes through the service worker, which holds host_permissions for api.crmsolid.com, Chrome does not apply CORS to it. The content script must therefore never fetch the API directly — it would be subject to CORS, and the production Traefik answers preflights itself without reflecting extension origins.

Nothing happens without a click

Worth stating plainly, because it is the question every reviewer and every cautious buyer asks, and because the answer is enforced by the shape of the code rather than by intent:

  • Every save is a click. The card's Save button, the floating badge, the side panel's save button, and the two right-click menu items. There is no other caller of CAPTURE or QUICK_SAVE. The offline queue and the local-vault sync only re-send saves the user already made.
  • No crawling, no pagination, no background reading. The extractors read document and nothing else. There is exactly one fetch() in the whole extension (src/lib/api.ts), and it only ever addresses api.crmsolid.com. The extension never fetches a page, never opens a tab to read one, and never scrolls or pages through a list to find more of it.
  • The List tab reads a list, and this is what it does and does not do. On X, Instagram and GitHub, the List tab turns the rows already rendered on screen into checkboxes: the user searched, the browser drew the results, and one press files the ones they tick. It reads document at the moment the button is pressed and stops there, so what it can see is exactly what the person looking at the screen can see. It never scrolls, never clicks "next", never opens a profile to fill in a row, and arrives with nothing ticked, so opening the tab by accident does nothing at all. It is off on LinkedIn and not because of a selector: reading a result page is bulk export however carefully it is scoped, and no bulk export is one of the five constraints in policy.ts that make supporting LinkedIn defensible in the first place. extractors/listing.ts has no LinkedIn branch, and the tab says so on a LinkedIn page rather than pretending there is nothing there.
  • The one thing that is not a click is the "already in your CRM?" lookup, which sends the address of a profile page so the in-page control can label itself before you press it. It runs only where an in-page control is actually drawn, so on LinkedIn it does not run at all, and the server resolves the address to a dedup key and retains nothing. It is stated in the consent screen rather than left to be discovered in a network log.
  • CSV export covers only contacts held locally in a browser with no account attached, which exist nowhere else. It is an escape hatch out of the extension, not a way to bulk extract anybody's platform data.

Extraction

Three layers, merged with earlier layers winning:

  1. JSON-LD (Person / Organization / ProfilePage) — survives redesigns.
  2. Platform DOM — data-testid on X, semantic landmarks on LinkedIn, og:description parsing on Instagram (it carries counts, name, handle and bio in one string).
  3. OpenGraph — the floor, so even a hostile page yields a name and an image.

Whatever survives is editable in the side panel (or the in-page card) before it is saved. Extraction is best-effort by nature; a CRM full of half-right records is worse than one the user corrected on the way in.

Deduplication

ProfileKey turns a URL into a (platform, key) pair. x.com/Acme, twitter.com/acme/, mobile.x.com/acme and x.com/acme?utm_source=x all collapse to x:acme, while x.com/home is not a profile at all. A unique index on (UserId, Platform, ProfileKey) makes a double-click idempotent.

Saving the same person from LinkedIn and from X produces one contact with two profiles, matched first by profile key and then by email.

Store listings

The copy, the field-by-field answers and the review-form justifications are kept with the product, one document per store, because the two stores ask different questions and keeping one merged draft meant answering neither properly.

Chrome is live; Edge has not been submitted yet. The plan had been to ship to Edge first, because registration there is free where Chrome charges a one-off developer fee, certification turns around faster, and the Edge catalogue is small enough that a new extension is actually visible in it. In the event the Chrome developer account already existed and the listing went out there on 2026-08-26, passing review the next day.

Edge is still worth doing and costs one upload rather than a second port: it accepts the same Chromium zip and the same copy, and only needs its own 300x300 listing logo, which is already in store-listing/assets/. edge.md holds the field-by-field answers.

Release checklist

  1. Bump version in public/manifest.json and package.json (they must match).
  2. Check the store listing still describes what the extension actually does, especially the per-host behaviour table.
  3. npm run build && npm run zip. The build typechecks first and refuses to package a dist/ older than src/.
  4. The packager prints the entry count and how many source maps went in. If it says 0 source maps, the vite config was changed and the review just got harder.
  5. Upload to the Chrome Web Store dashboard (and to Edge Partner Center once that listing exists). Save the draft on every tab before switching to the next one: the dashboard's "leave site" prompt discards everything typed since the last save.
  6. TelegramSimple/changelog.json gets a user-facing line for the same release.
  7. Re-read the platform terms behind src/content/inline/policy.ts if a major version is going out. That table is the only thing standing between a user and an account restriction, and it ages.

About

Browser extension (MV3) that saves a LinkedIn, X, Instagram or GitHub profile into your CRM in one click. Notes, tags, pipeline stage and follow-up at capture time. Live on the Chrome Web Store.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages