DEV Community

Cover image for Building a LinkedIn Outreach Agent with ConnectSafely.ai and Mastra
AMAAN SARFARAZ
AMAAN SARFARAZ

Posted on

Building a LinkedIn Outreach Agent with ConnectSafely.ai and Mastra

A deep dive into building production-ready LinkedIn automation using deterministic agents, clean tool abstractions, and API-first architecture

Built a LinkedIn job search automation agent that finds jobs, identifies hiring managers, and sends connection requests—without scraping, browser automation, or getting banned.

Stack:

  • 🤖 Mastra (agent orchestration)
  • 🔗 ConnectSafely.ai (LinkedIn API)
  • ⚡ TypeScript + Bun

Key principle: Agents orchestrate, APIs execute, tools stay dumb.

Full code on GitHub 👈


The Problem: LinkedIn Automation That Doesn't Suck

You want to automate LinkedIn outreach. You have three options:

Option 1: Scraping + Selenium

  • Breaks every UI update
  • Gets your account banned
  • Maintenance nightmare

Option 2: Sketchy Chrome Extensions

  • Security risks
  • Limited functionality
  • Still risk account bans

Option 3: Build It Right

  • API-driven
  • Compliant
  • Actually maintainable

This article is about Option 3.


Architecture: Keep It Stupid Simple

Here's the entire system in one diagram:

User Query
    ↓
Mastra Agent (orchestration)
    ↓
Tools (execution)
    ├─ Search jobs
    ├─ Resolve company
    ├─ Find hiring managers
    ├─ Fetch profile data
    ├─ Check connection status
    └─ Send connection request
    ↓
ConnectSafely.ai API (platform access)
    ↓
LinkedIn (the actual platform)
Enter fullscreen mode Exit fullscreen mode

Key insight: The agent never touches LinkedIn directly. All platform operations go through ConnectSafely.ai.

Why this matters:

  • No scraping = no bans
  • API changes are handled upstream
  • You write business logic, not LinkedIn reverse-engineering

What is Mastra?

Mastra is an agent framework, but it's not trying to simulate AGI or build an autonomous AI assistant.

What it does:

  • Provides structured agent definitions
  • Handles tool orchestration
  • Manages execution flow
  • Keeps things deterministic

What it doesn't do:

  • Run infinite loops
  • Make unpredictable decisions
  • Hide what's happening
  • Try to be "too smart"

Think of it as glue code with an LLM brain—the LLM decides what to do, but the tools define how to do it.


Agent Setup: Explicit Over Clever

Here's how you define an agent in Mastra:

import { createAgent } from "@mastra/core";
import { searchJobs } from "../tools/search-jobs";
import { searchHiringManagers } from "../tools/search-hiring-managers";
import { fetchProfileDetails } from "../tools/fetch-profile";
import { checkConnectionStatus } from "../tools/check-connection";
import { sendConnectionRequest } from "../tools/send-connection";

export const jobOutreachAgent = createAgent({
  name: "job-search-outreach-agent",

  instructions: `
    You help users find relevant jobs and connect with hiring managers on LinkedIn.

    Workflow:
    1. Search for jobs based on user criteria
    2. Get company details for each job
    3. Find hiring managers at those companies
    4. Fetch profile details to personalize outreach
    5. Check connection status before sending requests
    6. Send personalized connection requests

    Always verify connection status before attempting to connect.
    Never send duplicate requests.
  `,

  tools: [
    searchJobs,
    searchHiringManagers,
    fetchProfileDetails,
    checkConnectionStatus,
    sendConnectionRequest,
  ],
});
Enter fullscreen mode Exit fullscreen mode

What makes this powerful:

1. Fixed Instructions

No prompt injection. No hallucinated workflows. The agent knows its job.

2. Explicit Tool Set

You control exactly what the agent can do. No surprise API calls.

3. Clear Workflow

The sequence is documented and predictable.

4. Debuggable

When something breaks, you know which tool failed.


Tool Design: One Job, One Tool

Each tool is a single-purpose function that wraps a ConnectSafely.ai API call.

Tool #1: Search Jobs

import { createTool } from "@mastra/core";

export const searchJobs = createTool({
  id: "search-jobs",
  description: "Search for jobs on LinkedIn by keyword and location",

  inputSchema: {
    keyword: {
      type: "string",
      description: "Job title or keywords (e.g., 'Software Engineer', 'Product Manager')",
    },
    location: {
      type: "string",
      description: "Location (e.g., 'San Francisco, CA', 'Remote')",
    },
    limit: {
      type: "number",
      description: "Maximum number of results (default: 25)",
      optional: true,
    },
  },

  execute: async ({ context }) => {
    const { keyword, location, limit = 25 } = context;

    const response = await fetch(
      `${process.env.CONNECTSAFELY_API_URL}/jobs/search`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.CONNECTSAFELY_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ keyword, location, limit }),
      }
    );

    if (!response.ok) {
      throw new Error(`Job search failed: ${response.statusText}`);
    }

    return response.json();
  },
});
Enter fullscreen mode Exit fullscreen mode

Design principles:

Clear Interface

Input schema is explicitly defined. No guessing.

Single Responsibility

This tool only searches jobs. Nothing else.

Error Handling

Failures are explicit, not silent.

No Business Logic

The tool executes. The agent decides when to execute.

Tool #2: Find Hiring Managers

export const searchHiringManagers = createTool({
  id: "search-hiring-managers",
  description: "Find hiring managers and recruiters at a specific company",

  inputSchema: {
    companyId: {
      type: "string",
      description: "LinkedIn company ID",
    },
    jobTitle: {
      type: "string",
      description: "Filter by job title (e.g., 'Recruiter', 'Engineering Manager')",
      optional: true,
    },
  },

  execute: async ({ context }) => {
    const { companyId, jobTitle } = context;

    const url = new URL(
      `${process.env.CONNECTSAFELY_API_URL}/companies/${companyId}/people`
    );

    if (jobTitle) {
      url.searchParams.append("jobTitle", jobTitle);
    }

    const response = await fetch(url.toString(), {
      headers: {
        Authorization: `Bearer ${process.env.CONNECTSAFELY_API_KEY}`,
      },
    });

    if (!response.ok) {
      throw new Error(`Failed to find hiring managers: ${response.statusText}`);
    }

    const data = await response.json();

    // Return structured data the agent can work with
    return {
      companyId,
      managers: data.people.map((person: any) => ({
        profileId: person.id,
        name: person.name,
        title: person.headline,
        profileUrl: person.profileUrl,
      })),
    };
  },
});
Enter fullscreen mode Exit fullscreen mode

Why this structure?

Separation of Concerns

  • Agent: "I need hiring managers for this company"
  • Tool: "Here's how to get them from the API"
  • ConnectSafely: "Here's the actual data"

No Scraping Logic

Notice what's not in this code:

  • ❌ DOM selectors
  • ❌ Browser automation
  • ❌ Cookie management
  • ❌ Session handling

Just clean API calls.

Tool #3: Fetch Profile Details

export const fetchProfileDetails = createTool({
  id: "fetch-profile-details",
  description: "Get detailed information about a LinkedIn profile",

  inputSchema: {
    profileId: {
      type: "string",
      description: "LinkedIn profile ID or vanity URL",
    },
  },

  execute: async ({ context }) => {
    const { profileId } = context;

    const response = await fetch(
      `${process.env.CONNECTSAFELY_API_URL}/profiles/${profileId}`,
      {
        headers: {
          Authorization: `Bearer ${process.env.CONNECTSAFELY_API_KEY}`,
        },
      }
    );

    if (!response.ok) {
      throw new Error(`Failed to fetch profile: ${response.statusText}`);
    }

    const profile = await response.json();

    return {
      id: profile.id,
      name: profile.name,
      headline: profile.headline,
      location: profile.location,
      about: profile.about,
      experience: profile.experience,
      skills: profile.skills,
      profileUrl: profile.profileUrl,
    };
  },
});
Enter fullscreen mode Exit fullscreen mode

What this enables:

Personalization

You can craft messages based on actual profile data.

Validation

Check if the person matches your ICP before reaching out.

Rule-Based Filtering

"Only connect with people who have 5+ years experience in X"

Tool #4: Check Connection Status (CRITICAL)

This is the most important tool. Never skip this step.

export const checkConnectionStatus = createTool({
  id: "check-connection-status",
  description: "Check if you're already connected to someone on LinkedIn",

  inputSchema: {
    profileId: {
      type: "string",
      description: "LinkedIn profile ID",
    },
  },

  execute: async ({ context }) => {
    const { profileId } = context;

    const response = await fetch(
      `${process.env.CONNECTSAFELY_API_URL}/connections/status/${profileId}`,
      {
        headers: {
          Authorization: `Bearer ${process.env.CONNECTSAFELY_API_KEY}`,
        },
      }
    );

    if (!response.ok) {
      throw new Error(`Failed to check connection status: ${response.statusText}`);
    }

    const status = await response.json();

    return {
      profileId,
      isConnected: status.connected,
      connectionLevel: status.level, // 1st, 2nd, 3rd, etc.
      pendingRequest: status.pending,
      canConnect: status.canConnect,
    };
  },
});
Enter fullscreen mode Exit fullscreen mode

Why this matters:

Prevents Duplicates

Don't send connection requests to people you're already connected to.

Avoids Policy Violations

LinkedIn has rules about connection spam. This keeps you compliant.

Saves Credits

Most LinkedIn automation tools charge per action. Don't waste them.

Better UX

Respect people's time. Don't send redundant requests.

Tool #5: Send Connection Request

The final step—actual outreach.

export const sendConnectionRequest = createTool({
  id: "send-connection-request",
  description: "Send a personalized connection request on LinkedIn",

  inputSchema: {
    profileId: {
      type: "string",
      description: "LinkedIn profile ID",
    },
    note: {
      type: "string",
      description: "Personalized message (max 300 characters)",
      optional: true,
    },
  },

  execute: async ({ context }) => {
    const { profileId, note } = context;

    // Validation: Check note length
    if (note && note.length > 300) {
      throw new Error("Connection note must be 300 characters or less");
    }

    const response = await fetch(
      `${process.env.CONNECTSAFELY_API_URL}/connections/send`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${process.env.CONNECTSAFELY_API_KEY}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          profileId,
          note: note || undefined,
        }),
      }
    );

    if (!response.ok) {
      throw new Error(`Failed to send connection request: ${response.statusText}`);
    }

    const result = await response.json();

    return {
      success: true,
      profileId,
      sentAt: result.timestamp,
      messageId: result.id,
    };
  },
});
Enter fullscreen mode Exit fullscreen mode

Key safeguards:

Validation at Tool Level

Character limits are enforced before the API call.

Clear Error Messages

If something fails, you know why.

Audit Trail

Returns message IDs for tracking and debugging.

Rate Limiting Delegated

ConnectSafely.ai handles rate limits. You don't.


Why ConnectSafely.ai is the Right Abstraction

Let's be clear about what this system does not do:

❌ Use headless browsers (Puppeteer, Playwright)

❌ Scrape DOM elements

❌ Manage cookies or sessions

❌ Simulate user behavior

❌ Reverse-engineer LinkedIn's private APIs

❌ Risk your account with ToS violations

Instead:

✅ LinkedIn actions are clean API calls

✅ Authentication is key-based (no password handling)

✅ Rate limits are enforced externally

✅ Platform logic is isolated from your code

✅ Compliance is built-in

This keeps your agent:

  • Portable — Runs anywhere, no browser dependencies
  • Safe — No account ban risks
  • Maintainable — API changes are handled upstream
  • Scalable — No Selenium overhead

Why this matters:

Idempotency

Running the agent twice doesn't send duplicate requests.

Resume from Failure

If the agent crashes mid-workflow, pick up where it left off.

Audit Trail

Know exactly what the agent did and when.

Cost Efficiency

Don't re-process jobs or profiles you've already handled.


The Complete Workflow in Action

Here's what happens when a user asks: "Find software engineering jobs in San Francisco and connect me with hiring managers"

Step 1: Agent Reasoning

// Agent receives the query
const response = await jobOutreachAgent.run({
  messages: [
    {
      role: "user",
      content: "Find software engineering jobs in San Francisco and connect me with hiring managers",
    },
  ],
});
Enter fullscreen mode Exit fullscreen mode

The agent's LLM brain parses this and decides:

  1. Need to search jobs first
  2. Then find hiring managers for each job
  3. Check connection status
  4. Send personalized requests

Step 2: Tool Execution Sequence

// 1. Search for jobs
const jobs = await searchJobs.execute({
  keyword: "Software Engineer",
  location: "San Francisco, CA",
  limit: 10,
});

// 2. For each job, find hiring managers
for (const job of jobs.results) {
  // Skip if already processed
  if (hasProcessedJob(job.id)) {
    continue;
  }

  // Find people at this company
  const managers = await searchHiringManagers.execute({
    companyId: job.companyId,
    jobTitle: "Recruiter",
  });

  // 3. For each hiring manager
  for (const manager of managers.managers) {
    // Check if already contacted
    if (hasContactedProfile(manager.profileId)) {
      console.log(`Already contacted ${manager.name}`);
      continue;
    }

    // 4. Check connection status
    const status = await checkConnectionStatus.execute({
      profileId: manager.profileId,
    });

    if (status.isConnected) {
      console.log(`Already connected to ${manager.name}`);
      continue;
    }

    if (!status.canConnect) {
      console.log(`Cannot connect to ${manager.name} (likely 3rd+ degree)`);
      continue;
    }

    if (status.pendingRequest) {
      console.log(`Already sent request to ${manager.name}`);
      continue;
    }

    // 5. Fetch profile for personalization
    const profile = await fetchProfileDetails.execute({
      profileId: manager.profileId,
    });

    // 6. Send personalized connection request
    const note = `Hi ${profile.name.split(" ")[0]}, I saw you're hiring for ${job.title} at ${job.company}. I'd love to connect and learn more about the role!`;

    const result = await sendConnectionRequest.execute({
      profileId: manager.profileId,
      note,
    });

    // 7. Track the attempt
    trackConnectionAttempt(manager.profileId, result.success);

    console.log(`✅ Sent connection request to ${manager.name}`);

    // Rate limiting: wait between requests
    await new Promise((resolve) => setTimeout(resolve, 2000));
  }

  // Mark job as processed
  markJobProcessed(job.id);
}
Enter fullscreen mode Exit fullscreen mode

Output:

🔍 Found 10 software engineering jobs in San Francisco
👥 Found 24 hiring managers across companies

Processing "Senior Software Engineer" at Stripe...
  ✅ Sent connection request to Sarah Johnson (Recruiter)
  ⏭️  Already connected to Mike Chen (Engineering Manager)

Processing "Backend Engineer" at Airbnb...
  ✅ Sent connection request to Alex Rivera (Technical Recruiter)
  ⏭️  Already sent request to Jessica Wang (Hiring Manager)

Processing "Full Stack Developer" at Uber...
  ⚠️  Cannot connect to David Kim (3rd+ degree connection)
  ✅ Sent connection request to Emily Zhang (Talent Acquisition)

Summary:
- Jobs analyzed: 10
- Hiring managers found: 24
- Connection requests sent: 5
- Already connected: 3
- Pending requests: 2
- Cannot connect: 1
Enter fullscreen mode Exit fullscreen mode

Runtime and Environment

Tech stack choices:

TypeScript

  • Type safety for tools and agent config
  • Better IDE support
  • Easier refactoring

Bun

  • Fast runtime (10x faster than Node for some tasks)
  • Built-in TypeScript support
  • Native SQLite bindings
  • Great developer experience

.env Configuration

# .env
CONNECTSAFELY_API_KEY=your_api_key_here
CONNECTSAFELY_API_URL=https://api.connectsafely.ai/v1
DATABASE_PATH=./mastra.db
MAX_JOBS_PER_RUN=20
MAX_CONNECTIONS_PER_JOB=5
Enter fullscreen mode Exit fullscreen mode

No Framework Lock-in

The agent code is portable:

  • Swap Mastra for another framework
  • Replace SQLite with Postgres
  • Deploy to any Node/Bun environment

Why This Pattern Scales

This architecture works because it's built on solid principles:

1. Separation of Concerns

Agent (Reasoning)

  • Decides what to do
  • Sequences operations
  • Handles errors

Tools (Execution)

  • Implement one action
  • Return structured data
  • Stay deterministic

ConnectSafely (Platform)

  • Manages LinkedIn access
  • Enforces rate limits
  • Handles compliance

2. Deterministic Tools Beat "Smart" Prompts

// ❌ Prompt-based approach (unpredictable)
const result = await llm.chat([
  { role: "system", content: "You are a LinkedIn automation expert" },
  { role: "user", content: "Search for jobs and send connection requests" }
]);

// ✅ Tool-based approach (deterministic)
const jobs = await searchJobs.execute({ keyword, location });
const managers = await searchHiringManagers.execute({ companyId });
await sendConnectionRequest.execute({ profileId, note });
Enter fullscreen mode Exit fullscreen mode

3. Platform Risk is Externalized

Your responsibility:

  • Business logic
  • Workflow sequencing
  • Data processing

ConnectSafely's responsibility:

  • LinkedIn API compliance
  • Rate limiting
  • Account safety
  • Platform updates

4. Workflows are Explicit

No hidden magic. No emergent behavior. Just clear, traceable execution.


Applying This Pattern to Other Use Cases

The same architecture works for:

CRM Enrichment

const crmAgent = createAgent({
  name: "crm-enricher",
  tools: [
    fetchCompanyData,
    enrichContactInfo,
    updateCrmRecord,
  ],
});
Enter fullscreen mode Exit fullscreen mode

Recruiting Pipelines

const recruitingAgent = createAgent({
  name: "candidate-sourcer",
  tools: [
    searchCandidates,
    assessSkillMatch,
    sendOutreach,
    trackResponses,
  ],
});
Enter fullscreen mode Exit fullscreen mode

Sales Prospecting

const prospectingAgent = createAgent({
  name: "lead-generator",
  tools: [
    findCompanies,
    identifyDecisionMakers,
    enrichLeadData,
    queueOutreach,
  ],
});
Enter fullscreen mode Exit fullscreen mode

Compliance-Heavy Automations

const complianceAgent = createAgent({
  name: "compliant-outreach",
  tools: [
    checkConsentStatus,
    validateDataPrivacy,
    logAuditTrail,
    sendMessage,
  ],
});
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

If you remember nothing else, remember these principles:

1. Agents Should Orchestrate, Not Improvise

Give your agent clear instructions and good tools. Don't ask it to be creative with platform interactions.

2. Platform Access Belongs Behind an API

Never scrape what you can API. ConnectSafely.ai handles LinkedIn so you don't have to.

3. Deterministic Tools Beat "Smart" Prompts

A well-designed tool with clear inputs/outputs is more reliable than prompting an LLM to figure it out.

4. State Management is Mandatory

Track what you've done. Idempotency isn't optional for production agents.

5. Debuggability Matters More Than Autonomy

You'll spend 80% of your time debugging. Make it easy.


Try It Yourself

Want to build your own LinkedIn automation agent?

Quick Start

# Clone the repo
git clone https://github.com/ConnectSafelyAI/agentic-framework-examples/tree/main/job-seekers-reach-out-to-hiring-managers/agentic/mastra

# Install dependencies
bun install

# Set up environment
cp .env.example .env
# Add your ConnectSafely API key

# Run it
bun run dev
Enter fullscreen mode Exit fullscreen mode

Get API Access

  1. Sign up at connectsafely.ai
  2. Get your API key from the dashboard
  3. Read the docs

Resources & Support

📂 Code: GitHub - Mastra Examples

📚 Documentation:

💬 Support:

🌐 Connect:
LinkedInYouTubeInstagramFacebookX


Building something cool with agents? Drop a comment—I'd love to see what you're working on!

Questions about the architecture? Hit me up. Always happy to nerd out about agent design patterns. 🤓

Top comments (0)