LinkedIn Outreach Platform Agentic Workflow
Get Public Contact Info — LinkedIn Outreach Platform Agentic Workflow
Open a LinkedIn profile, open the Contact Info overlay, and return the public contact fields (URL, websites, phones, email, IM, birthday, connected-on) as structured JSON. Fully deterministic — no LLM call.
sidebutton install linkedin Given a LinkedIn profile URL, this workflow opens the profile, clicks the "Contact info" link on the top card, waits for the Contact Info dialog to mount, and extracts every visible row — public vanity URL, websites, phone numbers, email, IM handles, birthday, and connected-on date when the viewer is a 1st-degree connection.
The modal is a native <dialog aria-labelledby="dialog-header">. Class names are fully obfuscated, but LinkedIn gives each contact row an SVG icon with a stable semantic id (linkedin-bug-medium, link-medium, envelope-medium, etc.). The workflow maps those icon IDs to field names and parses row text directly in the browser, returning JSON via browser.injectJS — no LLM call, no token cost, and deterministic output.
Use it before drafting outreach or enriching a lead record. It complements linkedin_lead_health (which captures the main profile body) — run both and merge the results to get the full public picture of a lead.
Steps
- 1. Navigate to a URL
- url
- {{profile_url}}
browser.navigate - 2. Wait
- selector
- main
- timeout
- 12000
browser.wait - 3. Wait
- ms
- 600
browser.wait - 4. Wait
- selector
- a[href$="/overlay/contact-info/"]
- timeout
- 10000
browser.wait - 5. Click an element
- selector
- a[href$="/overlay/contact-info/"]
browser.click - 6. Wait
- selector
- dialog[aria-labelledby="dialog-header"]
- timeout
- 8000
browser.wait - 7. Wait
- ms
- 900
browser.wait - 8. browser injectJS
- id
- sb-linkedin-contact-parse
- as
- contact_json
- js
- |
browser.injectJS - 9. browser injectJS
- js
- |
browser.injectJS - 10. control stop
- message
- {{contact_json}}
control.stop
Workflow definition
schema_version: 1
version: "1.1.0"
last_verified: "2026-04-20"
id: linkedin_get_contact_info
title: "Get Public Contact Info"
description: "Open a LinkedIn profile, open the Contact Info overlay, and return the public contact fields (URL, websites, phones, email, IM, birthday, connected-on) as structured JSON. Fully deterministic — no LLM call."
overview: |
Given a LinkedIn profile URL, this workflow opens the profile, clicks the "Contact info" link on the top card, waits for the Contact Info dialog to mount, and extracts every visible row — public vanity URL, websites, phone numbers, email, IM handles, birthday, and connected-on date when the viewer is a 1st-degree connection.
The modal is a native `<dialog aria-labelledby="dialog-header">`. Class names are fully obfuscated, but LinkedIn gives each contact row an SVG icon with a stable semantic `id` (`linkedin-bug-medium`, `link-medium`, `envelope-medium`, etc.). The workflow maps those icon IDs to field names and parses row text directly in the browser, returning JSON via `browser.injectJS` — no LLM call, no token cost, and deterministic output.
Use it before drafting outreach or enriching a lead record. It complements `linkedin_lead_health` (which captures the main profile body) — run both and merge the results to get the full public picture of a lead.
category:
level: task
domain: sales
reusable: true
params:
profile_url:
type: string
description: "Full LinkedIn profile URL, e.g. https://www.linkedin.com/in/maxsvistunov/"
required: true
policies:
allowed_domains:
- "*.linkedin.com"
steps:
# 1. Load the profile
- type: browser.navigate
url: "{{profile_url}}"
- type: browser.wait
selector: main
timeout: 12000
# Small settle delay — let the top card finish hydrating
- type: browser.wait
ms: 600
# 2. Wait for the Contact info link on the top card
- type: browser.wait
selector: a[href$="/overlay/contact-info/"]
timeout: 10000
# 3. Open the Contact Info overlay
- type: browser.click
selector: a[href$="/overlay/contact-info/"]
# 4. Wait for the native <dialog> to mount (aria-labelledby is stable even though classes are obfuscated)
- type: browser.wait
selector: dialog[aria-labelledby="dialog-header"]
timeout: 8000
# 5. Settle delay so every row (birthday, connected-since, etc.) finishes rendering
- type: browser.wait
ms: 900
# 6. Parse the modal deterministically — icon id → field mapping
- type: browser.injectJS
id: sb-linkedin-contact-parse
as: contact_json
js: |
(() => {
const dlg = document.querySelector('dialog[aria-labelledby="dialog-header"]');
if (!dlg) return JSON.stringify({ error: 'contact_info_dialog_not_found', profile_url: "{{profile_url}}" });
// Icon-id → field-name map. LinkedIn assigns stable semantic ids even
// though CSS class names are obfuscated.
const ICON_MAP = {
'linkedin-bug-medium': 'profile_url',
'link-medium': 'websites',
'phone-handset-small': 'phones',
'envelope-medium': 'emails',
'comment-medium': 'im',
'calendar-medium': 'birthday',
'people-medium': 'connected_on',
'home-medium': 'address',
'location-marker-medium': 'address',
'twitter-bug': 'twitter'
};
// Split "value (Context)" → { value, context }. Lowercases context.
const splitCtx = (s) => {
const m = s.match(/^(.*)\s+\(([^)]+)\)\s*$/);
return m ? { value: m[1].trim(), context: m[2].trim().toLowerCase() } : { value: s.trim(), context: null };
};
// Collect every row element whose direct child is an svg[id] we know.
const rows = [];
dlg.querySelectorAll('svg[id]').forEach(svg => {
const row = svg.parentElement;
// must be a contact-info row, not a dialog-header close button
if (!row || svg.id === 'close-medium' || svg.id.startsWith('person-accent')) return;
if (!(svg.id in ICON_MAP)) return;
// The row's innerText is "Label\n\nValue1\n\nValue2..."; filter out blanks.
const lines = (row.innerText || '').split('\n').map(s => s.trim()).filter(Boolean);
if (lines.length < 2) return;
rows.push({ field: ICON_MAP[svg.id], values: lines.slice(1) });
});
const out = {
profile_url: null,
websites: [],
phones: [],
emails: [],
im: [],
twitter: [],
address: null,
birthday: null,
connected_on: null
};
// Canonicalise profile_url from the relative form LinkedIn shows ("linkedin.com/in/foo").
const canonProfile = (v) => {
if (!v) return null;
if (/^https?:/i.test(v)) return v.replace(/\/$/, '');
return 'https://www.' + v.replace(/^\/+/, '').replace(/\/$/, '');
};
rows.forEach(({ field, values }) => {
switch (field) {
case 'profile_url':
out.profile_url = canonProfile(values[0]);
break;
case 'websites':
out.websites = values.map(splitCtx);
break;
case 'phones':
out.phones = values.map(splitCtx);
break;
case 'emails':
out.emails = values.map(v => v.trim());
break;
case 'im':
out.im = values.map(splitCtx);
break;
case 'twitter':
out.twitter = values.map(v => v.replace(/^@/, '').trim());
break;
case 'address':
out.address = values.join(', ');
break;
case 'birthday':
out.birthday = values[0] || null;
break;
case 'connected_on':
// row text is "Connected since\nNov 11, 2019"
out.connected_on = values[0] || null;
break;
}
});
// Fallback: if we never found a profile_url (icon missing), use the input param.
if (!out.profile_url) out.profile_url = "{{profile_url}}";
return JSON.stringify(out);
})();
# 7. Close the dialog to leave the page in a clean state
- type: browser.injectJS
js: |
(() => {
const btn = document.querySelector('dialog[aria-labelledby="dialog-header"] button[aria-label="Dismiss"], dialog[aria-labelledby="dialog-header"] svg#close-medium');
if (btn) (btn.closest('button') || btn).click();
})();
# 8. Return the JSON
- type: control.stop
message: "{{contact_json}}"