Now out of stealth: the missing link between AI agents and LinkedIn data.
All skills

Enrich & Save

extraction

Hand the agent a CSV or XLSX with LinkedIn profile links — it enriches every row with live profile data (headline, role, company, location, skills) and saves a clean, styled XLSX back. Built for CRM exports, event lists, and sourcing sheets.

Endpoints it uses

  • /api/v1/profile/full10 credits/call

Ballpark: ~100 credits for 10 typical runs. Signup includes 300 free credits.

Run this skill

Download comes with your API key baked in — sign up (300 free credits, one minute) or open this skill in your dashboard.

What's inside (full preview)

---
name: linkedin-enrich-and-save
description: Use when the user hands over a spreadsheet (CSV or XLSX) containing LinkedIn profile URLs and wants each row enriched with live profile data (headline, role, company, location, followers, skills) and saved as a new, nicely formatted XLSX. Triggers on "enrich this list", "fill in the details for these profiles", "clean up my leads sheet", "add LinkedIn data to this export".
---

# Enrich & Save (via Zooq)

You take a spreadsheet with LinkedIn profile links, enrich every row with live profile data through the Zooq API, and write a new, styled XLSX the user can open and share. You need code execution for this skill (pandas + openpyxl, or openpyxl alone).

## Inputs you need from the user

- **The file**: a `.csv` or `.xlsx` containing LinkedIn profile URLs somewhere in it. You auto-detect the column — don't make the user name it unless detection fails.
- Nothing else. Keep every original column; you only append.

## Step 0 — read the file and find the profiles

```python
import pandas as pd, re

df = pd.read_excel(PATH) if PATH.endswith(".xlsx") else pd.read_csv(PATH)

HANDLE_RE = re.compile(r"linkedin\.com/in/([^/?#\s]+)", re.I)

def extract_handle(cell):
    m = HANDLE_RE.search(str(cell))
    if not m:
        return None
    # strip trailing slashes and url-encoding leftovers
    from urllib.parse import unquote
    return unquote(m.group(1)).strip("/")

# pick the column with the most linkedin.com/in/ hits
best = max(df.columns, key=lambda c: df[c].astype(str).str.contains("linkedin.com/in/", case=False).sum())
df["_handle"] = df[best].map(extract_handle)
```

Report what you found before spending anything: "N rows, M with a LinkedIn profile URL (column '<name>'), K unique handles."

## Step 1 — the cost gate (do NOT skip)

Every enrichment is one call to `/api/v1/profile/full` at **10 credits per row**. Tell the user the exact total (unique handles × 10) and, if the list is over 50 rows, get an explicit yes before proceeding. Deduplicate first — the same handle twice should cost once (enrich once, fill both rows).

## Step 2 — enrich, one call per profile

Call profiles ONE AT A TIME. Do not attempt `/api/v1/profile/enrich-bulk` — that batch endpoint currently rejects submissions upstream; the per-profile GET below is the reliable path.

```
GET https://zooq.dev/api/v1/profile/full?handle=<HANDLE>
Headers:
  X-API-Key: REPLACE_WITH_YOUR_KEY
```

```python
import requests, time

def enrich(handle):
    r = requests.get(
        "https://zooq.dev/api/v1/profile/full",
        params={"handle": handle},
        headers={"X-API-Key": "REPLACE_WITH_YOUR_KEY"},
        timeout=60,
    )
    body = r.json()
    if r.status_code == 200 and body.get("success"):
        return "enriched", body["data"], r.headers.get("X-Zooq-Credits-Remaining")
    if r.status_code == 404:
        return "not found", None, r.headers.get("X-Zooq-Credits-Remaining")  # billed — a real lookup ran
    return f"error {r.status_code}", None, r.headers.get("X-Zooq-Credits-Remaining")  # 4xx validation errors are not billed

results = {}
for h in df["_handle"].dropna().unique():
    results[h] = enrich(h)
    time.sleep(0.6)  # default limit is 120 req/min — stay comfortably under it
```

Fields worth pulling from `data`: `first_name` + `last_name`, `headline`, current position from `full_positions[]` (the entry with `is_current: true` — its `title` and company name), `geo_city` + `geo_country_code`, `follower_count`, `connection_count`, top 5 `skills[].name`, most recent `education[]` school.

## Step 3 — write the styled XLSX

Append these columns to the original data, then save `<original-name>-enriched.xlsx` next to the input file: Full name · Headline · Current title · Company · Location · Followers · Connections · Top skills · Education · Status.

```python
from openpyxl.styles import Font, PatternFill, Alignment
from openpyxl.utils import get_column_letter

out = PATH.rsplit(".", 1)[0] + "-enriched.xlsx"
df.drop(columns=["_handle"]).to_excel(out, index=False, sheet_name="Enriched")

from openpyxl import load_workbook
wb = load_workbook(out); ws = wb.active

header_fill = PatternFill("solid", fgColor="1B543E")
for cell in ws[1]:
    cell.font = Font(bold=True, color="FFFFFF")
    cell.fill = header_fill
    cell.alignment = Alignment(vertical="center")
ws.freeze_panes = "A2"
ws.auto_filter.ref = ws.dimensions
for col in ws.columns:
    width = max(len(str(c.value or "")) for c in col[:50]) + 2
    ws.column_dimensions[get_column_letter(col[0].column)].width = min(width, 48)
wb.save(out)
```

## Step 4 — report

One short summary, not a wall of text: rows processed, enriched / not found / skipped counts, credits spent (start balance minus the last `X-Zooq-Credits-Remaining` you saw), and the output filename. If more than a handful of rows came back "not found", say so plainly — dead URLs in the source sheet are the usual cause, not an API problem.

## Failure rules

- A row with no LinkedIn URL is "skipped", never an error. Keep it in the output.
- Never fabricate profile data for failed rows — leave the enrichment columns empty and set Status.
- On `429` (rate limit), wait 30 seconds and continue where you left off.
- On `402` (out of credits), stop immediately, save what you have, and tell the user how many rows remain and that they can top up at https://zooq.dev/billing.