Goodnews Azonubi
Backend/DevOps Engineer
11 min read
Ever wish there was a smarter way to search for tech jobs? I built an AI agent that understands natural language queries like "Find me 5 latest Flutter jobs" and returns relevant postings from public job feeds.
This isn't a tutorial on how to build agents from scratch. Instead, I'm walking you through how this specific project works, the Mastra features I used, and how I integrated it with Telex.im — an AI agent platform like Make (you can also think of it as a Slack alternative for education, communities, or bootcamps).
The agent workflow is simple but practical:
['backend', 'remote']You'll need Node.js 18+, an OpenAI API key, and comfort reading TypeScript. We're not reinventing the wheel here—just building on top of some solid libraries.
Before diving in, have these ready:
If you've never used Mastra before, that's fine. I'll explain the key pieces as we go.
Imagine you're building a small app with AI. You don't just ask the AI a question and hope for the best. You:
Here's the folder structure I'm working with:
src/mastra/
├── agents/
│ └── jobs-agent.ts ← The agent + its instructions
├── tools/
│ └── rss-tool.ts ← The "search jobs" tool
├── workflows/
│ └── jobs-workflow.ts ← Compose steps that use the agent
├── utils/
│ ├── keyword-extractor.ts ← Parse "Find 5 Flutter jobs"
│ ├── feed-cache.ts ← Cache RSS data locally
│ └── feed-scheduler.ts ← Auto-refresh feeds every 30 min
├── scorers/
│ └── jobs-scorer.ts ← Grade agent responses
└── index.ts ← Wire it all togetherDon't worry about memorizing this. You'll see it in action.
When a user types a query, it goes to the jobsAgent. Here's what that agent looks like:
export const jobsAgent = new Agent({
name: 'Jobs Agent',
description: "Fetches recent remote and tech-related job listings from public RSS feeds",
instructions: `
You are a jobs search assistant. ALWAYS use the fetch-jobs tool to search for jobs.
Never make up job listings. If the tool returns 0 jobs, clearly state that no matches were found.
Format results with: Title, Company, Location, Description, Posted Date.
`,
model: 'openai/gpt-4o-mini',
tools: { rssTool },
memory: new Memory({
storage: new LibSQLStore({
url: 'file:../mastra.db',
}),
}),
});Agent: Defines the AI’s role, behavior, and model.
Tool: Lets the agent call external logic (RSS fetcher).
Memory: Persistent storage using LibSQLStore.
Scorers: Evaluate the agent’s performance after each response.
Workflows: Combine multiple steps (keyword extraction → tool call → response).
What's happening here:
fetch-jobs tool (this prevents hallucination)The key insight: the instructions are very explicit. We don't say "maybe search for jobs." We say "ALWAYS use the fetch-jobs tool." This keeps the agent honest.
Before calling the tool, we need to extract what the user wants. If someone says "Find me 5 latest Flutter jobs," we extract:
['flutter']5'remote'This happens in keyword-extractor.ts:
export function extractKeywords(input: string): string[] {
const stopwords = new Set([
"find","show","latest","remote","job","jobs","for","me","the",
// ... 40+ more common words
]);
const stemmer = natural.PorterStemmer;
const words = input
.toLowerCase()
.replace(/[^\w\s+.#]/g, "") // keep tech symbols like + and #
.split(/\s+/)
.filter(word => word.length > 2 && !stopwords.has(word))
.map(word => stemmer.stem(word)); // "jobs" → "job", "running" → "run"
return [...new Set(words)]; // remove duplicates
}Breaking this down:
Example:
Input: "Find 5 latest Flutter jobs"
Output: ['flutter'] // (removed: find, latest, jobs)
This is effective because job titles usually contain the tech you're searching for.
The rssTool is where the magic happens. It's a Mastra tool, which means it has:
This Mastra tool performs the actual job search from cached RSS feeds.
export const rssTool = createTool({
id: 'fetch-jobs',
inputSchema: z.object({
query: z.string().describe('e.g., "Flutter developer"'),
limit: z.number().default(10),
}),
outputSchema: z.object({
jobs: z.array(jobListingSchema),
total: z.number(),
query: z.string(),
}),
execute: async ({ context }) => {
const { query, limit } = context;
const keywords = extractKeywords(query);
// Load all cached jobs from RSS feeds
let allJobs = [];
for (const feedUrl of rssFeeds) {
const feedJobs = await fetchFeedWithCache(feedUrl);
allJobs = allJobs.concat(feedJobs);
}
// Remove duplicates (same job posted to multiple feeds)
const uniqueJobs = deduplicateJobs(allJobs);
// Filter: only keep jobs that match keywords
const matchedJobs = uniqueJobs.filter(job => {
const title = job.title.toLowerCase();
const desc = job.description.toLowerCase();
return keywords.some(kw => title.includes(kw) || desc.includes(kw));
});
// Sort by relevance (title matches > description matches), then by date
const sorted = matchedJobs
.sort((a, b) => {
const aScore = (a.title.toLowerCase().match(keywords[0]) ? 2 : 0);
const bScore = (b.title.toLowerCase().match(keywords[0]) ? 2 : 0);
return bScore - aScore;
})
.slice(0, limit);
return {
jobs: sorted,
total: sorted.length,
query,
};
},
});What this does:
.cache/jobs, refreshed every 30 minutes)Notice: we don't fetch live RSS here. We use a cache. This is important because:
Scorers are automated tests that evaluate Agents outputs using model-graded, rule-based, and statistical methods. It analyzes what the agent has produced and determines how relevant, accurate, or useful those outputs are to the agent’s goal or the user’s query. I have created 2 scorers for this project:
Evaluates if the agent extracted relevant keywords from user input and selected the correct tool (rssTool) with appropriate parameters (query + limit). Returns 0-1 score.
Evaluates how relevant, correct, and professionally toned a job-search assistant's response is. Returns a 0-1 score (higher is better).
The cache lives in .cache/jobs and stores raw job listings for ~4 hours. Here's how it works:
export async function fetchFeedWithCache(feedUrl: string): Promise<CachedJob[]> {
// Try cache first
const cached = await loadFromCache(feedUrl);
if (cached !== null) {
return cached; // Cache hit! Return instantly
}
// Cache miss: fetch live, then save
try {
const response = await axios.get(feedUrl, { timeout: 8000 });
const feed = await parseFeed(response.data);
const jobs = feed.items.map(item => ({
title: item.title || 'No title',
link: item.url || '',
description: item.description?.substring(0, 500) || 'No description',
pubDate: item.published?.toISOString(),
source: feedUrl,
}));
// Save to cache for next time
await saveToCache(feedUrl, jobs);
return jobs;
} catch (error) {
console.error(`Failed to fetch ${feedUrl}:`, error.message);
return []; // Return empty list, don't crash
}
}Why this matters:
We don't want stale data. So every 30 minutes, we automatically refresh all feeds:
export function startFeedScheduler(intervalMinutes: number = 30): void {
if (isSchedulerActive) return;
const cronExpression = `*/${intervalMinutes} * * * *`; // */30 = every 30 min
cron.schedule(cronExpression, async () => {
const result = await refreshAllFeeds();
console.log(`Refreshed: ${result.refreshed} feeds, Failed: ${result.failed}`);
});
isSchedulerActive = true;
}How it works:
node-cron to schedule a background jobEvery tool and workflow uses Zod schemas. This is important because TypeScript types disappear at runtime, but Zod validates at runtime:
const jobSchema = z.object({
title: z.string(),
link: z.string(),
description: z.string(),
pubDate: z.string().optional(),
source: z.string(),
});
const jobSearchResult = z.object({
jobs: z.array(jobSchema),
total: z.number(),
query: z.string(),
});Why this matters:
Let's get it working locally first:
npm install
npm run devThis starts a dev server on http://localhost:4111/ with a web playground. Go there, select jobsAgent from the dropdown, and try typing:
Find 5 latest Flutter jobs
You'll see the agent:
For production, build it:
npm run buildThis creates .mastra/output with all the bundled code, and tells you how to start it:
node --import=./.mastra/output/instrumentation.mjs .mastra/output/index.mjsCreate a .env file in the project root:
OPENAI_API_KEY=sk-your-key-here
That's it. Everything else has sensible defaults.
Say you want a tool that looks up company data. Here's the pattern:
// src/mastra/tools/company-lookup.ts
import { createTool } from '@mastra/core/tools';
import { z } from 'zod';
export const companyLookupTool = createTool({
id: 'lookup-company',
description: 'Get details about a company',
inputSchema: z.object({
companyName: z.string(),
}),
outputSchema: z.object({
name: z.string(),
foundingYear: z.number(),
description: z.string(),
}),
execute: async ({ context }) => {
const { companyName } = context;
// Call your API or database
return {
name: companyName,
foundingYear: 2020,
description: 'A cool company',
};
},
});Then register it in src/mastra/index.ts:
import { companyLookupTool } from './tools/company-lookup.js';
// In the mastra config:
agents: {
jobsAgent: new Agent({
// ...
tools: { rssTool, companyLookupTool }, // ← Add here
}),
},Same idea—create the agent, give it strict instructions, register it:
// src/mastra/agents/recruiter-agent.ts
export const recruiterAgent = new Agent({
name: 'Recruiter Agent',
instructions: `You help recruiters find candidates. Use the lookup-company and fetch-jobs tools.`,
model: 'openai/gpt-4o-mini',
tools: { companyLookupTool, rssTool },
// ... memory, scorers, etc
});Then in index.ts:
agents: {
jobsAgent,
recruiterAgent, // ← Add here
},The scorers are already set up to grade agent responses. But you can log what happened:
const response = await jobsAgent.generate("Find Flutter jobs");
console.log(`Agent response:`, response.text);
console.log(`Tool called:`, response.toolResults?.length);
console.log(`Artifacts:`, response.artifacts);1. Explicit instructions matter more than model size. A cheap model with crystal-clear instructions ("ALWAYS use this tool, NEVER make up jobs") beats a fancy model with vague instructions every time.
2. Cache external data aggressively. RSS feeds are slow and unreliable. Caching with a 4-hour TTL means users get instant responses after the first request.
3. Zod schemas reduce integration bugs. Catching bad data at the tool boundary prevents cascading failures downstream.
4. Schedulers are better than webhooks. Periodic refresh via cron is simpler than trying to monitor feed changes.
Now that you understand how this works, try:
src/mastra/data/rss-feeds.ts