AI agents are transforming how we interact with APIs. But building production-ready agents that can actually execute multi-step workflows? That's where things get interesting.
In this post, I'll show you how I built a LinkedIn Group Premium Member Extractor using the Mastra framework and ConnectSafely.AI's LinkedIn automation API—creating an agent that intelligently extracts and exports premium/verified members from LinkedIn groups.
What We're Building
An AI agent that:
- ✅ Accepts natural language commands ("Extract premium members from the AI Innovators group")
- ✅ Automatically fetches group members using LinkedIn automation
- ✅ Filters for Premium/Verified accounts
- ✅ Exports results to Google Sheets
- ✅ Handles errors and provides progress updates
The result? A conversational interface to LinkedIn automation that would normally require complex scripting.
Why This Matters
Traditional LinkedIn automation requires:
- Writing custom scripts for each workflow
- Managing pagination and rate limits
- Handling authentication and error recovery
- Building export functionality from scratch
With ConnectSafely.AI + Mastra, you get:
- Natural language control ("Extract members from X group")
- Built-in LinkedIn automation tools
- Automatic error handling and retries
- Production-ready integrations
The Tech Stack
| Component | Purpose |
|---|---|
| Mastra | AI agent framework with built-in tooling |
| ConnectSafely.AI | LinkedIn automation API with MCP support |
| Google Sheets API | Data export and storage |
The Architecture
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ User Input │────▶│ Mastra Agent │────▶│ ConnectSafely │
│ (Natural Lang) │ │ (Claude) │ │ LinkedIn API │
└─────────────────┘ └────────┬────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ Google Sheets │
│ (Export) │
└─────────────────┘
The agent orchestrates the entire workflow:
- Understands user intent
- Calls LinkedIn automation tools
- Filters and processes data
- Exports to Google Sheets
Building the Agent
1. Setting Up ConnectSafely.AI Tools
First, we create tools that wrap ConnectSafely.AI's LinkedIn automation endpoints:
import { createTool } from '@mastra/core';
import { z } from 'zod';
// Tool to get group members by URL
const getGroupMembersByURL = createTool({
id: 'linkedin-get-group-members-url',
description: 'Fetch members from a LinkedIn group using the group URL',
inputSchema: z.object({
groupUrl: z.string().url().describe('LinkedIn group URL'),
count: z.number().min(1).max(100).default(20)
.describe('Number of members to fetch'),
start: z.number().min(0).default(0)
.describe('Pagination offset')
}),
execute: async ({ context, input }) => {
const response = await fetch(
'https://api.connectsafely.ai/linkedin/group-members/url',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CONNECTSAFELY_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(input)
}
);
if (!response.ok) {
throw new Error(`API Error: ${response.statusText}`);
}
return response.json();
}
});
Key Features:
- Zod validation: Ensures inputs are correct before making API calls
- Type safety: Full TypeScript support for autocomplete and error checking
- Error handling: Graceful failures with meaningful error messages
2. Creating the Filter Logic
The agent needs to identify premium and verified members:
const filterPremiumMembers = createTool({
id: 'filter-premium-members',
description: 'Filter group members to only include Premium or Verified accounts',
inputSchema: z.object({
members: z.array(z.object({
profileId: z.string(),
name: z.string(),
headline: z.string().optional(),
isPremium: z.boolean().optional(),
isVerified: z.boolean().optional()
}))
}),
execute: async ({ input }) => {
const premiumMembers = input.members.filter(member =>
member.isPremium || member.isVerified
);
return {
total: input.members.length,
premiumCount: premiumMembers.length,
members: premiumMembers
};
}
});
3. Google Sheets Export Tool
Export filtered results to a spreadsheet:
/// <reference types="node" />
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
async function getAccessToken() {
const res = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
refresh_token: process.env.GOOGLE_REFRESH_TOKEN!,
grant_type: "refresh_token",
}),
});
if (!res.ok) {
throw new Error("Failed to refresh Google access token");
}
const data = (await res.json()) as { access_token: string };
return data.access_token;
}
export const googleSheetsTool = createTool({
id: "google-sheets-members",
description:
"Create or update a Google Sheet with LinkedIn group members. Automatically skips duplicates by Profile ID.",
inputSchema: z.object({
accessToken: z
.string()
.optional()
.describe("Google OAuth token (overrides env)"),
spreadsheetId: z
.string()
.optional()
.describe("Existing spreadsheet ID"),
spreadsheetTitle: z
.string()
.optional()
.describe("Optional title for new spreadsheet"),
sheetName: z
.string()
.default("LinkedIn Members"),
members: z.array(
z.object({
profileId: z.string(),
firstName: z.string().optional(),
lastName: z.string().optional(),
fullName: z.string().optional(),
headline: z.string().optional(),
publicIdentifier: z.string().optional(),
profileUrl: z.string().optional(),
followerCount: z.number().optional(),
isPremium: z.boolean().optional(),
isVerified: z.boolean().optional(),
badges: z.array(z.string()).optional(),
relationshipStatus: z.string().optional(),
})
),
}),
outputSchema: z.object({
success: z.boolean(),
spreadsheetId: z.string(),
spreadsheetUrl: z.string(),
spreadsheetTitle: z.string(),
sheetName: z.string(),
membersAdded: z.number(),
membersSkipped: z.number(),
isNewSheet: z.boolean(),
summary: z.string(),
}),
execute: async ({ context }) => {
// ---------------------------------------------------------------------
// Access token resolution (CORRECT)
// ---------------------------------------------------------------------
const accessToken = await getAccessToken();
if (!accessToken) {
throw new Error("Google access token is required. Please ensure GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_REFRESH_TOKEN are set in environment variables.");
}
// ---------------------------------------------------------------------
// Auto-generate spreadsheet title if missing
// ---------------------------------------------------------------------
const now = new Date();
const autoTitle = `LinkedIn Premium Members - ${now
.toISOString()
.replace("T", " ")
.slice(0, 16)}`;
const spreadsheetTitle =
context.spreadsheetTitle ?? autoTitle;
const headers = [
"Profile ID",
"First Name",
"Last Name",
"Full Name",
"Headline",
"Public Identifier",
"Profile URL",
"Follower Count",
"Is Premium",
"Is Verified",
"Badges",
"Relationship Status",
];
let spreadsheetId = context.spreadsheetId;
let spreadsheetUrl: string;
let isNewSheet = false;
// ---------------------------------------------------------------------
// Create spreadsheet if needed
// ---------------------------------------------------------------------
if (!spreadsheetId) {
const createRes = await fetch(
"https://sheets.googleapis.com/v4/spreadsheets",
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken.trim()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
properties: { title: spreadsheetTitle },
sheets: [
{
properties: {
title: context.sheetName,
gridProperties: {
frozenRowCount: 1,
},
},
},
],
}),
}
);
if (!createRes.ok) {
const err = await createRes.text();
throw new Error(`Failed to create spreadsheet: ${err}`);
}
const data = (await createRes.json()) as { spreadsheetId: string; spreadsheetUrl: string };
spreadsheetId = data.spreadsheetId;
spreadsheetUrl = data.spreadsheetUrl;
isNewSheet = true;
// Add header row
await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${context.sheetName}!A1:append?valueInputOption=USER_ENTERED`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken.trim()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ values: [headers] }),
}
);
} else {
spreadsheetUrl = `https://docs.google.com/spreadsheets/d/${spreadsheetId}`;
}
// ---------------------------------------------------------------------
// Read existing rows (duplicate detection)
// ---------------------------------------------------------------------
const readRes = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${context.sheetName}`,
{
headers: { Authorization: `Bearer ${accessToken.trim()}` },
}
);
if (!readRes.ok) {
throw new Error("Failed to read spreadsheet");
}
const sheetData = (await readRes.json()) as { values?: any[][] };
const sheetValues = sheetData.values || [];
const existingProfileIds = new Set<string>();
sheetValues.slice(1).forEach((row: any[]) => {
if (row?.[0]) existingProfileIds.add(String(row[0]));
});
// ---------------------------------------------------------------------
// Prepare rows
// ---------------------------------------------------------------------
const rows = context.members
.filter((m) => !existingProfileIds.has(m.profileId))
.map((m) => [
m.profileId,
m.firstName ?? "",
m.lastName ?? "",
m.fullName ?? "",
m.headline ?? "",
m.publicIdentifier ?? "",
m.profileUrl ?? "",
m.followerCount ?? "",
m.isPremium ?? "",
m.isVerified ?? "",
(m.badges ?? []).join(", "),
m.relationshipStatus ?? "",
]);
const membersSkipped = context.members.length - rows.length;
let membersAdded = 0;
// ---------------------------------------------------------------------
// Append rows
// ---------------------------------------------------------------------
if (rows.length > 0) {
const appendRes = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${context.sheetName}:append?valueInputOption=USER_ENTERED`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken.trim()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ values: rows }),
}
);
if (!appendRes.ok) {
throw new Error("Failed to append rows");
}
membersAdded = rows.length;
}
// ---------------------------------------------------------------------
// Final response
// ---------------------------------------------------------------------
if (!spreadsheetId) {
throw new Error("Spreadsheet ID is required but was not created or provided");
}
return {
success: true,
spreadsheetId,
spreadsheetUrl,
spreadsheetTitle,
sheetName: context.sheetName,
membersAdded,
membersSkipped,
isNewSheet,
summary: `${isNewSheet ? "Created" : "Updated"} spreadsheet "${spreadsheetTitle}" with ${membersAdded} new members${
membersSkipped > 0 ? ` (${membersSkipped} duplicates skipped)` : ""
}`,
};
},
});
4. Creating the Mastra Agent
Now we orchestrate everything with an AI agent:
import { Agent } from "@mastra/core/agent";
import { Memory } from "@mastra/memory";
import { LibSQLStore } from "@mastra/libsql";
import {
fetchLinkedInGroupMembersTool,
fetchAllLinkedInGroupMembersTool,
fetchGroupMembersByUrlTool,
filterPremiumVerifiedMembersTool,
completeGroupMembersWorkflowTool,
} from "../tools/linkedin/index.js";
import { googleSheetsTool } from "../tools/googlesheet/index.js";
export const premiumMembersAgent = new Agent({
name: "LinkedIn Group Premium Member Extractor",
model: "google/gemini-2.5-flash",
instructions: `
You are a LinkedIn automation agent.
Your responsibilities:
- Extract LinkedIn group members
- Filter Premium / Verified profiles
- Optionally persist results to Google Sheets
────────────────────────────────────────
AVAILABLE TOOLS
────────────────────────────────────────
1. fetchLinkedInGroupMembersTool
- Fetch ONE paginated batch (low-level)
2. fetchAllLinkedInGroupMembersTool
- Fetch ALL members with auto-pagination
3. fetchGroupMembersByUrlTool
- Resolve LinkedIn group URL → groupId
4. filterPremiumVerifiedMembersTool
- Filter members for Premium / Verified
5. completeGroupMembersWorkflowTool
- Fetch + filter Premium / Verified members
- RETURNS DATA ONLY (no persistence)
6. googleSheetsTool
- Create or update Google Sheets
- Access token is automatically retrieved - no user input needed
────────────────────────────────────────
MANDATORY RULES
────────────────────────────────────────
1. googleSheetsTool automatically handles authentication - do NOT ask for access token
2. completeGroupMembersWorkflowTool NEVER handles Google Sheets
3. Use the simplest tool that satisfies the request
4. Return spreadsheet URL ONLY after successful write
────────────────────────────────────────
RECOMMENDED FLOWS
────────────────────────────────────────
User wants premium members only:
→ completeGroupMembersWorkflowTool
User wants premium members saved to Sheets:
→ completeGroupMembersWorkflowTool
→ googleSheetsTool (no access token needed)
User provides group URL:
→ fetchGroupMembersByUrlTool
→ continue workflow
────────────────────────────────────────
RESPONSE STYLE
────────────────────────────────────────
- Do not narrate internal reasoning
- Report progress only at meaningful milestones
- Be concise and deterministic
IMPORTANT:
When members are fetched, treat the result as the current working set.
If the user says "them", "those", or "add them", reuse the last fetched members.
Do NOT ask again for groupId unless explicitly requested.
`,
tools: {
fetchLinkedInGroupMembersTool,
fetchAllLinkedInGroupMembersTool,
fetchGroupMembersByUrlTool,
filterPremiumVerifiedMembersTool,
completeGroupMembersWorkflowTool,
googleSheetsTool,
},
memory: new Memory({
storage: new LibSQLStore({
url: "file:../mastra.db",
}),
}),
});
5. Running the Agent
Create a simple interface to interact with the agent:
#!/usr/bin/env node
import "dotenv/config";
import readline from "readline";
import { premiumMembersAgent } from "./agents/linkedin-group-members-fetcher-agent.js";
// Check if command-line argument is provided (non-interactive mode)
const args = process.argv.slice(2);
const query = args.join(" ");
if (query) {
// Non-interactive mode: execute query and exit
(async () => {
try {
const result = await premiumMembersAgent.generate(query);
console.log("\n" + result.text);
// If there are tool results, show summary
if (result.toolResults && result.toolResults.length > 0) {
console.log("\n--- Tool Execution Summary ---");
result.toolResults.forEach((toolResult: any) => {
if (toolResult.payload?.result && typeof toolResult.payload.result === 'object') {
const result = toolResult.payload.result;
if (result.spreadsheetUrl) {
console.log(`📊 Spreadsheet: ${result.spreadsheetUrl}`);
}
if (result.members) {
console.log(`👥 Members: ${result.members.length}`);
}
}
});
}
process.exit(0);
} catch (err: any) {
console.error("\n❌ Error:", err.message || err);
process.exit(1);
}
})();
} else {
// Interactive mode: REPL
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log("🧠 LinkedIn Group Premium Member Extractor");
console.log("Type 'exit' to quit.\n");
async function loop() {
rl.question("> ", async (input) => {
if (input.toLowerCase() === "exit") {
rl.close();
process.exit(0);
}
if (!input.trim()) {
loop();
return;
}
try {
const result = await premiumMembersAgent.generate(input);
console.log("\n" + result.text);
} catch (err: any) {
console.error("\n❌ Error:", err.message || err);
}
console.log();
loop();
});
}
loop();
}
Example Interaction
Here's what a real conversation with the agent looks like:
User: Extract premium members from the AI Innovators group (linkedin.com/groups/123456)
Agent: I'll help you extract premium members from that LinkedIn group. Let me start
by fetching the members.
→ Calling tool: getGroupMembersByURL
Agent: I've retrieved 100 members from the group. Now let me filter for Premium and
Verified accounts.
→ Calling tool: filterPremiumMembers
Agent: Great! I found 23 premium/verified members out of 100 total. Let me export
these to Google Sheets for you.
→ Calling tool: exportToGoogleSheets
Agent: Done! I've exported 23 premium LinkedIn members to your Google Sheet.
You can view it here: https://docs.google.com/spreadsheets/d/abc123...
Summary:
- Total members scanned: 100
- Premium/Verified found: 23
- Export format: Google Sheets
Advanced Features
1. Pagination for Large Groups
Handle groups with 1000+ members:
const extractAllMembers = createTool({
id: 'extract-all-members',
description: 'Extract ALL members from a group (handles pagination)',
inputSchema: z.object({
groupUrl: z.string().url(),
maxMembers: z.number().max(5000).default(1000)
}),
execute: async ({ input }) => {
const allMembers = [];
let start = 0;
const batchSize = 100;
while (start < input.maxMembers) {
const batch = await getGroupMembersByURL.execute({
context: {},
input: {
groupUrl: input.groupUrl,
count: batchSize,
start
}
});
if (batch.members.length === 0) break;
allMembers.push(...batch.members);
start += batchSize;
// Respect rate limits
await new Promise(resolve => setTimeout(resolve, 2000));
}
return { members: allMembers };
}
});
2. Progress Tracking
Stream progress updates during long operations:
const response = await linkedInAgent.generate(userMessage, {
onStepFinish: (step) => {
// Log progress
console.log(`[${new Date().toISOString()}] ${step.text}`);
// Track tool usage
if (step.toolCalls) {
const toolNames = step.toolCalls.map(c => c.toolName).join(', ');
console.log(` Tools used: ${toolNames}`);
}
}
});
3. Error Recovery
The agent automatically handles and recovers from errors:
// Agent's built-in retry logic
const response = await linkedInAgent.generate(userMessage, {
maxRetries: 3,
onError: (error) => {
console.error(`Error occurred: ${error.message}`);
// Agent will automatically retry with exponential backoff
}
});
Why ConnectSafely.AI?
1. Production-Ready LinkedIn Automation
ConnectSafely.AI handles all the complexity:
- ✅ Session management and authentication
- ✅ Rate limiting and anti-detection
- ✅ Proxy rotation for reliability
- ✅ CAPTCHA handling
- ✅ Account warmup and health monitoring
2. Comprehensive API Coverage
Every LinkedIn action is available via API:
- Group member extraction
- Profile data scraping
- Post engagement (comments, reactions)
- Connection requests and messaging
- Post search and analytics
3. Built for AI Agents
ConnectSafely.AI is designed for LLM tool use:
- Clean OpenAPI specification
- Descriptive tool documentation
- Predictable response formats
- Error messages optimized for AI understanding
Deployment Options
Option 1: Local Development
# Clone the repository
https://github.com/ConnectSafelyAI/agentic-framework-examples/tree/main/extract-linkedin-premium-users-from-linkedin-groups/agentic/mastra
cd /agentic/mastra
# Install dependencies
bun install
# Set environment variables
cp .env.example .env
# GOOGLE_GENERATIVE_AI_API_KEY=
# CONNECTSAFELY_API_TOKEN=
# GOOGLE_REFRESH_TOKEN=
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
# Run the agent
bun start
Option 2: Production Deployment
Deploy to Railway, Vercel, or any Node.js host:
import express from 'express';
const app = express();
app.use(express.json());
app.post('/extract-members', async (req, res) => {
const { groupUrl, spreadsheetId } = req.body;
const response = await linkedInAgent.generate(
`Extract premium members from ${groupUrl} and save to sheet ${spreadsheetId}`
);
res.json({ success: true, response: response.text });
});
app.listen(3000, () => {
console.log('Agent API running on port 3000');
});
Option 3: Integrate with n8n
Create an n8n workflow that calls your agent:
{
"nodes": [
{
"type": "n8n-nodes-base.httpRequest",
"name": "Call Mastra Agent",
"parameters": {
"url": "https://your-agent-api.com/extract-members",
"method": "POST",
"jsonParameters": true,
"bodyParameters": {
"groupUrl": "={{ $json.groupUrl }}",
"spreadsheetId": "={{ $json.spreadsheetId }}"
}
}
}
]
}
Real-World Use Cases
1. Lead Generation
Extract premium members from industry groups and export to CRM:
"Extract all premium members from the SaaS Founders group and
save to HubSpot as leads"
2. Competitor Analysis
Track who's joining competitor groups:
"Monitor the ProductHunt Makers group and alert me when
verified founders join"
3. Network Building
Find target connections in relevant groups:
"Extract members from AI/ML groups who work at Series A+
startups and export to a spreadsheet"
Cost Analysis
Running this agent is remarkably affordable:
| Component | Cost per 1000 members |
|---|---|
| gemini API | $0.0 - $0.0 |
| Google Sheets API | Free (quota: 100/min) |
For comparison, manual extraction or traditional automation tools cost:
- Manual work: $20-50/hour
- Scrapers: $100-500/month
- Enterprise tools: $1000+/month
Performance Benchmarks
Real-world performance with ConnectSafely.AI:
| Metric | Value |
|---|---|
| Members per request | Up to 100 |
| Average request time | 2-4 seconds |
| Success rate | 98%+ |
| Rate limit | 60 requests/minute |
| Max throughput | 6000 members/minute |
The Complete Code
Here's the full agent implementation:
import { Agent, createTool } from '@mastra/core';
import { google } from 'googleapis';
import { z } from 'zod';
// Environment setup
const CONNECTSAFELY_API_KEY = process.env.CONNECTSAFELY_API_KEY!;
const GOOGLE_GENERATIVE_AI_API_KEY = process.env.GOOGLE_GENERATIVE_AI_API_KEY!;
// LinkedIn Tools
const getGroupMembersByURL = createTool({
id: 'linkedin-get-group-members-url',
description: 'Fetch members from a LinkedIn group using the group URL',
inputSchema: z.object({
groupUrl: z.string().url(),
count: z.number().min(1).max(100).default(20),
start: z.number().min(0).default(0)
}),
execute: async ({ input }) => {
const response = await fetch(
'https://api.connectsafely.ai/linkedin/group-members/url',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${CONNECTSAFELY_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(input)
}
);
return response.json();
}
});
const filterPremiumMembers = createTool({
id: 'filter-premium-members',
description: 'Filter to only Premium or Verified accounts',
inputSchema: z.object({
members: z.array(z.object({
profileId: z.string(),
name: z.string(),
headline: z.string().optional(),
isPremium: z.boolean().optional(),
isVerified: z.boolean().optional()
}))
}),
execute: async ({ input }) => {
const filtered = input.members.filter(m => m.isPremium || m.isVerified);
return {
total: input.members.length,
premiumCount: filtered.length,
members: filtered
};
}
});
/// <reference types="node" />
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
async function getAccessToken() {
const res = await fetch("https://oauth2.googleapis.com/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID!,
client_secret: process.env.GOOGLE_CLIENT_SECRET!,
refresh_token: process.env.GOOGLE_REFRESH_TOKEN!,
grant_type: "refresh_token",
}),
});
if (!res.ok) {
throw new Error("Failed to refresh Google access token");
}
const data = (await res.json()) as { access_token: string };
return data.access_token;
}
export const googleSheetsTool = createTool({
id: "google-sheets-members",
description:
"Create or update a Google Sheet with LinkedIn group members. Automatically skips duplicates by Profile ID.",
inputSchema: z.object({
accessToken: z
.string()
.optional()
.describe("Google OAuth token (overrides env)"),
spreadsheetId: z
.string()
.optional()
.describe("Existing spreadsheet ID"),
spreadsheetTitle: z
.string()
.optional()
.describe("Optional title for new spreadsheet"),
sheetName: z
.string()
.default("LinkedIn Members"),
members: z.array(
z.object({
profileId: z.string(),
firstName: z.string().optional(),
lastName: z.string().optional(),
fullName: z.string().optional(),
headline: z.string().optional(),
publicIdentifier: z.string().optional(),
profileUrl: z.string().optional(),
followerCount: z.number().optional(),
isPremium: z.boolean().optional(),
isVerified: z.boolean().optional(),
badges: z.array(z.string()).optional(),
relationshipStatus: z.string().optional(),
})
),
}),
outputSchema: z.object({
success: z.boolean(),
spreadsheetId: z.string(),
spreadsheetUrl: z.string(),
spreadsheetTitle: z.string(),
sheetName: z.string(),
membersAdded: z.number(),
membersSkipped: z.number(),
isNewSheet: z.boolean(),
summary: z.string(),
}),
execute: async ({ context }) => {
// ---------------------------------------------------------------------
// Access token resolution (CORRECT)
// ---------------------------------------------------------------------
const accessToken = await getAccessToken();
if (!accessToken) {
throw new Error("Google access token is required. Please ensure GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, and GOOGLE_REFRESH_TOKEN are set in environment variables.");
}
// ---------------------------------------------------------------------
// Auto-generate spreadsheet title if missing
// ---------------------------------------------------------------------
const now = new Date();
const autoTitle = `LinkedIn Premium Members - ${now
.toISOString()
.replace("T", " ")
.slice(0, 16)}`;
const spreadsheetTitle =
context.spreadsheetTitle ?? autoTitle;
const headers = [
"Profile ID",
"First Name",
"Last Name",
"Full Name",
"Headline",
"Public Identifier",
"Profile URL",
"Follower Count",
"Is Premium",
"Is Verified",
"Badges",
"Relationship Status",
];
let spreadsheetId = context.spreadsheetId;
let spreadsheetUrl: string;
let isNewSheet = false;
// ---------------------------------------------------------------------
// Create spreadsheet if needed
// ---------------------------------------------------------------------
if (!spreadsheetId) {
const createRes = await fetch(
"https://sheets.googleapis.com/v4/spreadsheets",
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken.trim()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
properties: { title: spreadsheetTitle },
sheets: [
{
properties: {
title: context.sheetName,
gridProperties: {
frozenRowCount: 1,
},
},
},
],
}),
}
);
if (!createRes.ok) {
const err = await createRes.text();
throw new Error(`Failed to create spreadsheet: ${err}`);
}
const data = (await createRes.json()) as { spreadsheetId: string; spreadsheetUrl: string };
spreadsheetId = data.spreadsheetId;
spreadsheetUrl = data.spreadsheetUrl;
isNewSheet = true;
// Add header row
await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${context.sheetName}!A1:append?valueInputOption=USER_ENTERED`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken.trim()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ values: [headers] }),
}
);
} else {
spreadsheetUrl = `https://docs.google.com/spreadsheets/d/${spreadsheetId}`;
}
// ---------------------------------------------------------------------
// Read existing rows (duplicate detection)
// ---------------------------------------------------------------------
const readRes = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${context.sheetName}`,
{
headers: { Authorization: `Bearer ${accessToken.trim()}` },
}
);
if (!readRes.ok) {
throw new Error("Failed to read spreadsheet");
}
const sheetData = (await readRes.json()) as { values?: any[][] };
const sheetValues = sheetData.values || [];
const existingProfileIds = new Set<string>();
sheetValues.slice(1).forEach((row: any[]) => {
if (row?.[0]) existingProfileIds.add(String(row[0]));
});
// ---------------------------------------------------------------------
// Prepare rows
// ---------------------------------------------------------------------
const rows = context.members
.filter((m) => !existingProfileIds.has(m.profileId))
.map((m) => [
m.profileId,
m.firstName ?? "",
m.lastName ?? "",
m.fullName ?? "",
m.headline ?? "",
m.publicIdentifier ?? "",
m.profileUrl ?? "",
m.followerCount ?? "",
m.isPremium ?? "",
m.isVerified ?? "",
(m.badges ?? []).join(", "),
m.relationshipStatus ?? "",
]);
const membersSkipped = context.members.length - rows.length;
let membersAdded = 0;
// ---------------------------------------------------------------------
// Append rows
// ---------------------------------------------------------------------
if (rows.length > 0) {
const appendRes = await fetch(
`https://sheets.googleapis.com/v4/spreadsheets/${spreadsheetId}/values/${context.sheetName}:append?valueInputOption=USER_ENTERED`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken.trim()}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ values: rows }),
}
);
if (!appendRes.ok) {
throw new Error("Failed to append rows");
}
membersAdded = rows.length;
}
// ---------------------------------------------------------------------
// Final response
// ---------------------------------------------------------------------
if (!spreadsheetId) {
throw new Error("Spreadsheet ID is required but was not created or provided");
}
return {
success: true,
spreadsheetId,
spreadsheetUrl,
spreadsheetTitle,
sheetName: context.sheetName,
membersAdded,
membersSkipped,
isNewSheet,
summary: `${isNewSheet ? "Created" : "Updated"} spreadsheet "${spreadsheetTitle}" with ${membersAdded} new members${
membersSkipped > 0 ? ` (${membersSkipped} duplicates skipped)` : ""
}`,
};
},
});
// Create the Agent
import { Agent } from "@mastra/core/agent";
import { Memory } from "@mastra/memory";
import { LibSQLStore } from "@mastra/libsql";
import {
fetchLinkedInGroupMembersTool,
fetchAllLinkedInGroupMembersTool,
fetchGroupMembersByUrlTool,
filterPremiumVerifiedMembersTool,
completeGroupMembersWorkflowTool,
} from "../tools/linkedin/index.js";
import { googleSheetsTool } from "../tools/googlesheet/index.js";
export const premiumMembersAgent = new Agent({
name: "LinkedIn Group Premium Member Extractor",
model: "google/gemini-2.5-flash",
instructions: `
You are a LinkedIn automation agent.
Your responsibilities:
- Extract LinkedIn group members
- Filter Premium / Verified profiles
- Optionally persist results to Google Sheets
────────────────────────────────────────
AVAILABLE TOOLS
────────────────────────────────────────
1. fetchLinkedInGroupMembersTool
- Fetch ONE paginated batch (low-level)
2. fetchAllLinkedInGroupMembersTool
- Fetch ALL members with auto-pagination
3. fetchGroupMembersByUrlTool
- Resolve LinkedIn group URL → groupId
4. filterPremiumVerifiedMembersTool
- Filter members for Premium / Verified
5. completeGroupMembersWorkflowTool
- Fetch + filter Premium / Verified members
- RETURNS DATA ONLY (no persistence)
6. googleSheetsTool
- Create or update Google Sheets
- Access token is automatically retrieved - no user input needed
────────────────────────────────────────
MANDATORY RULES
────────────────────────────────────────
1. googleSheetsTool automatically handles authentication - do NOT ask for access token
2. completeGroupMembersWorkflowTool NEVER handles Google Sheets
3. Use the simplest tool that satisfies the request
4. Return spreadsheet URL ONLY after successful write
────────────────────────────────────────
RECOMMENDED FLOWS
────────────────────────────────────────
User wants premium members only:
→ completeGroupMembersWorkflowTool
User wants premium members saved to Sheets:
→ completeGroupMembersWorkflowTool
→ googleSheetsTool (no access token needed)
User provides group URL:
→ fetchGroupMembersByUrlTool
→ continue workflow
────────────────────────────────────────
RESPONSE STYLE
────────────────────────────────────────
- Do not narrate internal reasoning
- Report progress only at meaningful milestones
- Be concise and deterministic
IMPORTANT:
When members are fetched, treat the result as the current working set.
If the user says "them", "those", or "add them", reuse the last fetched members.
Do NOT ask again for groupId unless explicitly requested.
`,
tools: {
fetchLinkedInGroupMembersTool,
fetchAllLinkedInGroupMembersTool,
fetchGroupMembersByUrlTool,
filterPremiumVerifiedMembersTool,
completeGroupMembersWorkflowTool,
googleSheetsTool,
},
memory: new Memory({
storage: new LibSQLStore({
url: "file:./mastra.db",
}),
}),
});
// Run the agent
#!/usr/bin/env node
import "dotenv/config";
import readline from "readline";
import { premiumMembersAgent } from "./agents/linkedin-group-members-fetcher-agent.js";
// Check if command-line argument is provided (non-interactive mode)
const args = process.argv.slice(2);
const query = args.join(" ");
if (query) {
// Non-interactive mode: execute query and exit
(async () => {
try {
const result = await premiumMembersAgent.generate(query);
console.log("\n" + result.text);
// If there are tool results, show summary
if (result.toolResults && result.toolResults.length > 0) {
console.log("\n--- Tool Execution Summary ---");
result.toolResults.forEach((toolResult: any) => {
if (toolResult.payload?.result && typeof toolResult.payload.result === 'object') {
const result = toolResult.payload.result;
if (result.spreadsheetUrl) {
console.log(`📊 Spreadsheet: ${result.spreadsheetUrl}`);
}
if (result.members) {
console.log(`👥 Members: ${result.members.length}`);
}
}
});
}
process.exit(0);
} catch (err: any) {
console.error("\n❌ Error:", err.message || err);
process.exit(1);
}
})();
} else {
// Interactive mode: REPL
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
console.log("🧠 LinkedIn Group Premium Member Extractor");
console.log("Type 'exit' to quit.\n");
async function loop() {
rl.question("> ", async (input) => {
if (input.toLowerCase() === "exit") {
rl.close();
process.exit(0);
}
if (!input.trim()) {
loop();
return;
}
try {
const result = await premiumMembersAgent.generate(input);
console.log("\n" + result.text);
} catch (err: any) {
console.error("\n❌ Error:", err.message || err);
}
console.log();
loop();
});
}
loop();
}
What's Next?
This pattern opens up powerful possibilities:
1. Multi-Platform Agents
Combine LinkedIn with Twitter, email, and CRM:
"Find premium members in X group, email them using SendGrid,
and add to HubSpot as leads"
2. Intelligent Filtering
Use AI for semantic filtering:
"Extract members who mention 'AI' or 'machine learning'
in their headlines"
3. Automated Outreach
Chain with messaging tools:
"Extract premium members, draft personalized messages,
and send connection requests"
4. Scheduled Monitoring
Track group growth over time:
"Check this group weekly and alert me when 10+ verified
members join"
Try It Yourself
Step 1: Get Your API Keys
- ConnectSafely.AI: Sign up at connectsafely.ai for LinkedIn automation
- Google Gemini Api Key: Get gemini API access at [https://aistudio.google.com/api-keys]
- Google Cloud: Enable Sheets API at console.cloud.google.com
Step 2: Clone and Setup
git clone https://github.com/ConnectSafelyAI/agentic-framework-examples.git
cd /agentic/mastra
bun install
Step 3: Configure Environment
# .env
GOOGLE_GENERATIVE_AI_API_KEY=
CONNECTSAFELY_API_TOKEN=
GOOGLE_REFRESH_TOKEN=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
Step 4: Run
bun start
Resources
- ConnectSafely.AI Documentation: connectsafely.ai/docs
- Mastra Framework: mastra.dev
- Full Code Repository: https://github.com/ConnectSafelyAI/agentic-framework-examples/tree/main/extract-linkedin-premium-users-from-linkedin-groups/agentic/mastra
- MCP Server Integration: connectsafely.ai/integrations/mcp-server
- API Playground: api.connectsafely.ai/docs
Key Takeaways
✅ AI agents can orchestrate complex LinkedIn workflows through natural language
✅ ConnectSafely.AI handles all LinkedIn automation complexity (auth, rate limits, anti-detection)
✅ Mastra provides production-ready agent infrastructure with built-in tooling and error handling
✅ Cost-effective at scale ($0.60-$2.30 vs $100-500/month for traditional tools)
✅ Highly extensible - combine with other APIs for powerful automation workflows
What Will You Build?
With ConnectSafely.AI's LinkedIn automation API and Mastra's agent framework, you can build sophisticated LinkedIn workflows controlled by natural language. The possibilities are endless:
- Lead generation pipelines
- Network analysis tools
- Automated outreach systems
- Competitive intelligence bots
- Personal branding assistants
Ready to get started? Grab your API key at connectsafely.ai and start building!
Top comments (0)