Why Discord for Job Search
Job search tooling has a discovery problem: the best tools are web apps that require context-switching. Discord is already open. For engineers and developers who live in Discord communities, the activation energy to run a job search from a channel is near zero.
This project builds JobScout — a Discord bot that exposes a multi-source AI job search engine through slash commands. Behind the scenes, a Google ADK orchestrator routes each query to the most relevant combination of five job board sub-agents (LinkedIn, Google Jobs, Adzuna, Jooble, Remotive), aggregates and deduplicates results, validates links, and returns formatted listings with pagination.
The result is a job search that runs where you already are.
Project Overview
Architecture: Two-service. A Node.js/Express Discord bot handles the Discord interaction protocol; a Python FastAPI service runs the Google ADK orchestrator and all LLM/tool logic. Clean separation: Discord API knowledge lives in JavaScript, AI orchestration lives in Python.
Core Capabilities:
| Capability | Command | Description |
|---|---|---|
| Structured search | /jobsearch | Role, location, level, salary, skills, remote filters |
| Conversational search | /jobchat | Natural language multi-turn with session context |
| Pagination | Show More Jobs button | Continue session, load next batch |
| Link validation | Automatic | Dead links (404) removed before display |
| Recency filter | Automatic | Listings >30 days old removed |
| Help | /jobhelp | Usage guide with both modes explained |
The Google ADK Orchestrator runs Gemini 2.5 Flash as the routing brain, with 5 specialized sub-agents as its tools. Each sub-agent wraps one job board API. The orchestrator decides which combination of sub-agents to call based on the query's location, remote preference, and market signals.
System Architecture
Agent Workflow
Technical Deep Dive
The Two-Service Split
The split between Node.js and Python is not accidental. Discord's interaction protocol requires sub-100ms acknowledgment (otherwise Discord shows "This interaction failed"). The Express handler returns DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE immediately — a loading state that Discord shows to the user — then fires the Python backend call asynchronously. When the Python service returns, patchOriginal() updates the deferred message with the real content.
Python handles all the AI work because the Google ADK runs natively in Python. The Node.js bot is a thin protocol adapter: verify signatures, route to handlers, format results for Discord's embed API.
The Five Sub-Agents
LinkedInJobsAgent — Uses Apify's LinkedIn Jobs Scraper, which runs a browser automation against LinkedIn and returns structured JSON. Supports date filters: r86400 (24h), r604800 (week), r2592000 (month). Default: 24h, keeping results maximally fresh. Runs up to 10 concurrent queries (cross-product of job titles × locations). Called first in every routing strategy because LinkedIn freshness is highest.
GoogleJobsSearchAgent — SerpAPI's Google Jobs engine aggregates from LinkedIn, Glassdoor, Indeed, and ZipRecruiter simultaneously. Returns ~10 results per page with salary parsed from structured extensions (converts "$20/hr" to "$41.6k/yr"). Best coverage for US/UK/EU premium listings.
AdzunaSearchAgent — Official Adzuna REST API. Strong coverage for UK, AU, and IN markets. Returns salary_min/salary_max as structured fields (most reliable salary data of any source). Supports per-country routing: us, gb, au, in, ca, de.
JoobleSearchAgent — Global aggregator via POST API. Strongest coverage for India and Europe. Salary typically embedded in description text rather than a separate field. Best for "show me everything in Bangalore" queries.
RemoteJobSearchAgent — Remotive's free public API, no API key required. Category mapping: keywords are matched to Remotive categories (software-dev, data, devops, design, qa). Returns "Remote — Worldwide" or "Remote — US/EU only" location formatting. Zero cost.
Orchestrator Routing Strategy
The orchestrator's system prompt defines routing rules:
| Query Signal | Sub-agents Called |
|---|---|
| Any location (default) | LinkedIn always called first |
| US, UK, EU, AU, CA | LinkedIn + Google Jobs + Adzuna |
| India or Indian city | LinkedIn + Jooble + Adzuna (India) |
| "remote" or "WFH" | LinkedIn + Remotive + Google Jobs |
| "latest" or "just posted" | LinkedIn only, 24h filter |
| Ambiguous or broad | All 5 sub-agents |
Each sub-agent runs validate_job_listings() on its results before returning to the orchestrator. The validator runs parallel HTTP HEAD requests (10 concurrent, 5s timeout) and classifies each URL: live, dead (404), requires_login (401/403), or unverified (network error). Dead links are dropped; requires_login are kept with a 🔒 marker in the Discord embed.
Session Architecture
Chat sessions are keyed by chat_{userId}_{channelId} — DMs produce chat_{userId}_dm. The FastAPI InMemorySessionService stores the ADK conversation history per key. When a user sends a follow-up message ("Filter for remote only," "Show me more in the $130k range"), the orchestrator receives the full conversation history and understands the context without the user re-explaining.
Session isolation per channel matters: a user's search in #job-hunting channel does not share history with their DM conversation. This is the correct mental model — different channels are different contexts.
Workflow Execution Example
User types /jobchat message: Senior React engineer Austin $130k+.
Challenges & Solutions
| Challenge | Impact | Solution |
|---|---|---|
| Discord 3-second interaction timeout | Command shows "This interaction failed" if backend is slow | Return DEFERRED immediately; patch with real content when ready |
| Discord 2000-char message limit | Long job lists get truncated | truncate(text, 1900) utility + 5-listings-per-page limit |
| Dead links in listings | Users clicking 404 apply URLs | Parallel HEAD validation in validate_job_listings() |
| LinkedIn Apify scraper timeout | Sub-agent returns empty on slow runs | 180s Apify synchronous run timeout; Remotive or Adzuna always available as fallback |
| Session key collisions in DMs | Two users' DM sessions overwriting | Session key includes userId: chat_{userId}_dm is unique per user |
| Apify billing on high usage | Cost spikes with many searches | LinkedIn only called when other sources would be insufficient; Remotive (free) handles remote queries |
| Multiple users searching simultaneously | Session store contention | ADK's InMemorySessionService handles concurrent sessions; each key is independent |
The Discord interaction timeout is the constraint that shapes the entire architecture. Discord requires a response within 3 seconds or the interaction fails. The Python backend, with LLM calls and parallel API requests, cannot guarantee sub-3-second responses. The deferred response pattern (DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE) is the standard solution: acknowledge immediately, deliver async. The patchOriginal() call can run up to 15 minutes after the initial acknowledgment.
Observability
Node.js bot: console.log at key handler entry points, console.error on all async exceptions. Response latency is visible in Discord's own developer analytics.
FastAPI service: The /health endpoint returns service version, list of configured sub-agents, and API key availability for each source. Running /health immediately tells you which job boards are active.
ADK dev UI: During development, adk web . from the job_agent_service/ directory launches the Google ADK web interface showing the orchestrator → sub-agent graph with per-turn reasoning.
Key signals to watch:
- Listing count per sub-agent per search (is one source consistently returning zero?)
- Dead link rate post-validation (rising rate means boards are changing URL structure)
- Session reuse rate per channel (are users doing multi-turn
/jobchator single-shot/jobsearch?)
Security & Privacy
Discord signature verification: Every request from Discord is verified using the discord-interactions middleware with the app's PUBLIC_KEY. Unsigned requests return 401. This prevents replay attacks and spoofed interaction payloads.
API key isolation: All credentials live in .env files. The Discord bot reads APP_ID, DISCORD_TOKEN, PUBLIC_KEY, and JOB_AGENT_SERVICE_URL. The Python service reads GOOGLE_API_KEY plus optional job board keys. The Discord token never touches the Python service.
Graceful missing keys: If ADZUNA_APP_ID is not set, AdzunaSearchAgent returns {"message": "Adzuna not configured"} and the orchestrator routes around it. The bot stays functional with fewer sources rather than failing.
Session data: In-memory session storage. No user data persists across service restarts. Sessions hold conversation history (text only, no PII beyond what the user types). Not designed for persistent history across restarts — that's a Phase 2 addition.
Future Enhancements
Phase 1 — Richer Results
- Embed Discord rich embeds (company logo, salary bar, skill tags as chips)
/jobsavecommand to persist listings to a PostgreSQL watchlist per user- Background monitoring: alert on new listings matching a saved search
Phase 2 — Pipeline Integration
- Connect to the Job Scout Application Agent:
/jobapplytriggers the 4-agent CV-to-submission pipeline - Share session state between Discord and web app so users can switch between surfaces
Phase 3 — Community Features
- Aggregated (anonymized) salary data per role/location from search results
- Community job board: members post opportunities, Gemini categorizes them automatically
- Interview prep channel:
@JobScout prep [role]generates Q&A set
Key Learnings
The deferred response pattern is non-negotiable for AI Discord bots. Any Discord bot that calls an LLM must defer. The 3-second window is architectural, not a soft target. Design every command as: acknowledge instantly, deliver async. Everything else follows from this.
Google ADK's sub-agent routing is more maintainable than a monolithic agent. Early versions had a single Gemini agent calling all five job board tools. Debugging failures was hard because any tool failure silently degraded the response. Splitting into five specialized sub-agents makes each source independently observable, testable, and replaceable.
Link validation pays for itself immediately. Before adding validate_job_listings(), roughly 25% of results had broken apply URLs. Users were clicking "Apply" to 404 pages. The parallel HEAD checks add ~1–2 seconds to the response time and eliminate this entirely.
Free APIs are underrated. Remotive's free API has no auth, reliable uptime, and meaningful coverage for remote roles. It handles a significant share of queries without any cost. Always check for free tiers before defaulting to paid APIs.
Conclusion
The Discord Job Scout Bot demonstrates that the right interface for an AI tool is not necessarily a web app — it is wherever your users already are. Discord's slash command interaction model maps cleanly to structured queries, and the deferred response pattern makes async AI pipelines feel instant.
The technical architecture separates concerns correctly: Discord protocol in Node.js, AI orchestration in Python, clean HTTP boundary between them. Each sub-agent owns one data source. The orchestrator handles routing and aggregation. The validator handles data quality. No component does more than one thing.
The result is a job search that takes 15 seconds and surfaces validated, fresh listings from 5 sources — without switching tabs.
That's all for now, folks!