AI Recruiter for a Ukrainian-to-EU placement agency

A Telegram intake agent that replaces a recruiter's 5–10-minute first call with a 3–5-minute structured conversation — wired end-to-end through a public landing, an admin SPA, and the existing SQL backend. Six days of solo build across two stacks; 195 tests before any live traffic.

build time
6 days build · pre-launch as of May 2026
first published
last updated

In one paragraph

A small placement agency was running candidate intake by hand: the prospect clicked a CTA, the human recruiter called back at her convenience (typically 24+ hours later), and spent 5–10 minutes on the phone collecting twelve structured fields — name, citizenship, work documents, experience, target country, languages — before judging fit against the ~237 live vacancies in her catalogue. The recruiter’s calendar was the bottleneck, and every unqualified prospect still ate her time. This project replaces that first call with a Telegram bot that runs the same intake in 3–5 minutes, 24/7, and a same-origin landing page that funnels candidates into it with full attribution. Built end-to-end in six solo days across two stacks: an existing Yii/PHP monorepo for the landing, a fresh Node/TS service for the agent and its admin. Pre-launch as of May 2026; 195 tests in place before any real candidate has talked to it.

0 days
solo build · two stacks
0 surfaces
landing · bot · admin · SQL
0 tests
before any live traffic
0 vacancies
live · ranked per candidate

The problem in business terms

A placement agency in Ukrainian-to-EU blue-collar recruitment has a straightforward bottleneck: the human recruiter’s calendar. Every inbound prospect — every welder, builder, warehouse worker, driver, electrician, CNC operator who taps a landing CTA — needs the same 3–5-minute structured conversation to reveal whether they’re a match for any of the live vacancies. The recruiter does that conversation by phone, one at a time, during business hours.

Two costs follow from this. The qualified candidate waits a day for a callback that might happen too late if they’ve already accepted another offer. The recruiter spends her hours on unqualified prospects — people in the wrong country, with the wrong documents, or not actually ready to leave within the month — because she can only find that out by talking to them.

This project replaces that first call with a Telegram bot. The bot greets the candidate immediately, walks them through the same twelve-field intake the recruiter would do, matches against live vacancies on the fly, and drops a pre-qualified ranked queue into an admin SPA the recruiter opens once or twice a day. The recruiter keeps the parts of her job that need her — outreach, judgement, human warmth — and stops doing the parts a structured form does just as well.

how a candidate gets to the recruiter before vs after the agent
without the agent
  • candidate clicks landing CTA, leaves contact info
  • recruiter calls back at her convenience — 24+ h is common
  • 5–10 minutes by phone collecting 12 structured fields
  • recruiter matches against ~237 vacancies in her head
  • pastes the result into the submission form, runs outreach
  • every unqualified prospect still costs her time
with the agent
  • candidate clicks landing CTA, lands in Telegram immediately
  • 3–5 min structured conversation, available 24/7
  • 12 fields captured in the order the recruiter pastes them
  • matcher ranks against live vacancies on the fly
  • pre-qualified queue lands in the recruiter's admin
  • recruiter spends her hours on outreach and judgement, not intake

What makes this different from “a chatbot wired to MySQL”

Plenty of teams have stood up a Telegram bot that answers questions about jobs. What separates that kind of demo from a system the agency can actually run their candidate pipeline through is a small set of decisions about what the system is — and what it deliberately isn’t — on day one.

Transparent automation, not a fake persona.

The bot greets candidates as «автоматична анкета» — automated questionnaire. It does not adopt a first-name persona or any other human-sounding identity. That decision is structural, not stylistic.

A persona is a confound. If conversion improves, the team won’t know whether the candidate trusted the bot or the name on it. If conversion drops, they won’t know whether the questions were wrong or the persona felt off. Shipping the impersonal version first means the business gets clean, measurable conversion numbers from day one — and keeps the option of A/B-testing a persona later, with data, if the evidence says it would help.

transparent automation vs branded persona why MVP is impersonal · persona is an A/B variant to be earned
criterion branded persona (typical chatbot) transparent automation (this MVP) chosen
measurable conversion confounded · rapport effect clean · only structure matters
honest with the candidate pretends to be human states what it is
trust failure mode betrayal when revealed no betrayal · nothing to reveal
compliance posture complicated clean
persona as upgrade already in the box available as A/B test if data demands

The discipline is the giveaway. A builder optimising for a flashy demo leans into the persona; a builder optimising for operational truth ships the impersonal version first and treats the persona as a feature to be earned with evidence.

The admin is the dev loop.

The 5-tab admin SPA has a tab labelled 💬 Тест-чат — an in-browser chat window that drives the same agent code a real Telegram conversation does. No bot token, no webhook, no staging dance, no second Telegram bot to keep alive. Edit the question tree, refresh the admin tab, talk to the bot for thirty seconds, ship.

For the business: every iteration on the question copy, the matching logic, or the side-question grounding goes from edit to verify in seconds. Telegram itself is reserved for the final smoke test before prod. The team can fix a candidate-flow bug while a candidate is still mid-conversation.

The landing is a same-origin React app inside the existing PHP monorepo.

The agency already runs a four-year-old Yii/PHP monorepo with two existing React SPAs (recruiter + employer), a shared UI package, and a multi-app CI pipeline. The landing didn’t fork that — it embedded into it via an nginx path prefix, built into the same GitLab CI job that builds the other two SPAs, and reused the recruiter’s MySQL through two new public cached endpoints on the existing Yii backend.

same-origin path prefix vs subdomain why the landing embeds rather than forking
criterion new subdomain (typical) same-origin path prefix (chosen) chosen
DNS + TLS cert new record · new cert none · same host
deploy pipeline second pipeline to maintain joins the existing build-front job
backend reads CORS dance same-origin · no headers
team coordination cost DevOps + CTO on the change one nginx config change
risk to existing apps new attack surface shared CSP · shared host

For the business: no new infrastructure to operate, no second deploy pipeline to keep alive, no CORS configuration on the recruiter API, no separate cert renewal. The cost of the landing is its own build artefact — nothing else changed.

Every LLM call has a deterministic fallback.

Three failure modes are designed in, not retrofitted:

  • Per-session token ceiling. A COST_CAP_TOKENS environment variable (default 50k) ceilings any one chat. Once exceeded, the bot stops calling the model for that chat and degrades to the deterministic question tree. The candidate still gets the full intake; the bot just stops being smart about normalisation and side-questions.
  • FX cache empty → honest “I don’t know”. When candidates ask «а скільки це в гривнях?», the bot has live UAH↔PLN rates fetched at startup and refreshed every six hours from the national-bank API. If that cache is empty, the model is instructed to fall back to «не знаю точний курс, рекрутер уточнить» — never invented numbers.
  • Compliance is a schema concern, not an application concern. All candidate-data tables (bot_chat_message, bot_answer, bot_attachment, bot_outreach) ON DELETE CASCADE from bot_questionnaire. Deleting one anketa erases the candidate’s full footprint in a single SQL statement. The compliance surface is enforced at the database, not in the application code that could be bypassed.

For the business: the system fails into honesty, not into hallucination. A single misbehaving user can’t drain the model budget; a regulator can verify deletion guarantees from the schema in seconds; the model is positioned as a thin layer over deterministic ground truth, not as the source of truth itself.

The funnel, end to end

four surfaces ad spend → landing → telegram → recruiter handoff
  1. 01
    marketing landing
    Yii host · same-origin · React/CRA
  2. 02
    telegram bot
    grammY · Node 20 · deep-link payload
  3. 03
    admin SPA
    5 tabs · Basic-auth · same process
  4. 04
    human recruiter
    ranked queue · outreach + judgement

The same process serves both the Telegram loop and the Basic-auth-gated admin — one Node 20 process, one port, two surfaces. The landing is a separate static artefact deployed alongside the existing recruiter SPA under the same GitLab CI job.

intake conversation every candidate · grounded in live DB + curated cheatsheet
  1. 01
    consent + RODO
    impersonal greeting · no persona
  2. 02
    universal block
    10 questions · conditional skips
  3. 03
    specialty tree
    branches per area · multi-choice support
  4. 04
    wrap-up + readiness
    free-text date normalised by the model
  5. 05
    matcher
    citizenship hard filter · 6 soft factors · top-N
  6. 06
    admin queue
    ranked anketas · status workflow · PL translation

A side-question can interrupt the flow at any point. The answerer injects two layers into the model’s prompt: a live snapshot of stats from the agency’s own database (average rate, hours buckets, housing %, transfer %), and a curated cheatsheet of Polish/EU blue-collar labour-law facts indexed by a regex router — RAG-shaped, without a vector store. Every answer is grounded in either this agency’s real numbers or a human-vetted reference. The bot returns to the questionnaire as if the interrupt never happened.

What’s shipped

Telegram bot (Node/TS, ~9.3k LOC): grammY scaffolding · /start, /restart, /help · deep-link payload parser · consent + RODO inline · LLM intent classifier for free-text area mentions · 10-question universal block with conditional skips · per-area specialty trees with multi-choice · wrap-up + readiness with model-side normalisation · two-layer side-QA grounding · per-session token cost cap with deterministic fallback · zod-typed MySQL persistence · prompt-injection probe.

Admin SPA (vanilla TS, ~70k characters): five tabs — per-anketa view with transcript and matcher score breakdown · per-candidate roll-up by phone · per-vacancy outreach log with status pills · in-browser dev-chat tab · eight stakeholder doc pages. Basic-auth gated, same process as the bot.

Landing (React/CRA, ~2.3k LOC, 63 files): seven-section funnel — hero with profession carousel, three-step how it works, live vacancy cards, trust block, country grid, FAQ, final CTA · three lazy-loaded legal pages imported deep from the shared UI package · same-origin fetch of two public endpoints with bundled fallback for offline tolerance · GTM event union typed end-to-end · Lighthouse 98 / 100 / 100 / 92 on first prod-like build.

Backend additions: two new public endpoints on the existing recruiter Yii app, 10-minute server-side cache under a single landing tag, CORS open, no auth. Six new MySQL tables (bot_*), all ON DELETE CASCADE from bot_questionnaire. Idempotent migration script, synthetic seeds for local dev.

Tests: 195 vitest tests across ~3.8k lines — admin, consent flow, restart flow, tree runner, normaliser, side-QA, cheatsheet router, FX cache, matcher, queries, observability hooks, prompt-injection probe. All green before the bot has talked to a real candidate.

Infrastructure: an 11-KB deployment runbook · CI integration into the existing build-front job (no new pipelines) · structured logging via pino · Sentry + Discord alerter both wired, both opt-in by env var · idempotent prod migration script.

What this says about the builder

The interesting choice isn’t writing a Telegram bot — there’s a template for that. The interesting choice is that this Telegram bot is built as the operations layer of a recruitment business, not as a demo.

That instinct shows up in places a CEO read would notice. The deployment runbook was written before the first deploy, not during a 2am triage. The compliance posture is enforced at the schema, not at the application layer that could be bypassed by a forgotten line of code. The dev loop is an in-browser chat tab so iteration takes seconds, not minutes. The same static artefact ships to staging and to prod, classified at runtime by hostname, so there’s nothing to mismatch between rings. Every model call has a deterministic fallback the candidate cannot tell from the smart path. The questionnaire was deliberately decoupled into structure (engineering) and wording (the human-conversation reviewer’s domain), so re-writing copy never requires code review.

And the smaller tells — the rounded-down public counters that stay defensible across cache windows, the Telegram start-payload sanitiser that respects the platform’s 64-character constraint, the GTM event union typed as a closed set so a typo in a CTA source becomes a TypeScript error rather than a silently-broken funnel, the deep-import trick that lets the landing reuse the shared UI package without dragging in FontAwesome Pro — these are the kinds of decisions a builder makes when they’re treating the system as something that has to live for years.

Six days. One builder. Two stacks. Four surfaces. A pre-launch recruitment funnel that the human recruiter on the other end has not yet seen a candidate from — by design. The Pilot 50 is next.


This case describes architecture and patterns. The agency, the codename, the Telegram bot’s username, internal URLs that would identify either, and the human recruiter’s name are deliberately out of scope by policy. The audience (Ukrainian-speaking blue-collar workers seeking EU placements) is part of the product’s public positioning and appears here for the same reason it appears on the landing.