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.
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)
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,
],
});
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();
},
});
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,
})),
};
},
});
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,
};
},
});
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,
};
},
});
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,
};
},
});
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",
},
],
});
The agent's LLM brain parses this and decides:
- Need to search jobs first
- Then find hiring managers for each job
- Check connection status
- 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);
}
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
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
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 });
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,
],
});
Recruiting Pipelines
const recruitingAgent = createAgent({
name: "candidate-sourcer",
tools: [
searchCandidates,
assessSkillMatch,
sendOutreach,
trackResponses,
],
});
Sales Prospecting
const prospectingAgent = createAgent({
name: "lead-generator",
tools: [
findCompanies,
identifyDecisionMakers,
enrichLeadData,
queueOutreach,
],
});
Compliance-Heavy Automations
const complianceAgent = createAgent({
name: "compliant-outreach",
tools: [
checkConsentStatus,
validateDataPrivacy,
logAuditTrail,
sendMessage,
],
});
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
Get API Access
- Sign up at connectsafely.ai
- Get your API key from the dashboard
- Read the docs
Resources & Support
📂 Code: GitHub - Mastra Examples
📚 Documentation:
💬 Support:
- Email: support@connectsafely.ai
- Documentation: connectsafely.ai/docs
🌐 Connect:
LinkedIn • YouTube • Instagram • Facebook • X
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)