L

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.

Available free v1.0.0 Browser LLM
$ sidebutton install linkedin
Download ZIP

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. 1.
    Navigate to a URL
    url
    {{profile_url}}
    browser.navigate
  2. 2.
    Wait
    selector
    main
    timeout
    12000
    browser.wait
  3. 3.
    Wait
    ms
    600
    browser.wait
  4. 4.
    Wait
    selector
    a[href$="/overlay/contact-info/"]
    timeout
    10000
    browser.wait
  5. 5.
    Click an element
    selector
    a[href$="/overlay/contact-info/"]
    browser.click
  6. 6.
    Wait
    selector
    dialog[aria-labelledby="dialog-header"]
    timeout
    8000
    browser.wait
  7. 7.
    Wait
    ms
    900
    browser.wait
  8. 8.
    browser injectJS
    id
    sb-linkedin-contact-parse
    as
    contact_json
    js
    |
    browser.injectJS
  9. 9.
    browser injectJS
    js
    |
    browser.injectJS
  10. 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}}"