Your signup pipeline already flags suspicious registrations: a burst from one network, an address pattern you have seen before, a datacenter IP on a consumer product. Somebody then has to look at each flag, run the same five checks, and decide. It is repetitive work, and it is exactly the kind of work people want to hand to an agent.
Hand over the investigation. Keep the decision.
This lesson builds an agent that does the legwork on a flagged signup: validates the email, checks the IP, looks for earlier accounts by the same person, and writes a short recommendation with every claim tied to a specific field from a specific tool. Then it stops. The recommendation lands in a review queue, a person makes the call, and only the person's decision changes anything. The agent has no tool that can approve, suspend or email anyone, and that absence is the most important design decision in the lesson.
Why recommend-only is the design, not a limitation
There are three separate reasons, and any one of them would be enough.
The signals are probabilistic and the costs are not symmetric. A datacenter IP means the traffic came from a cloud provider. That covers scrapers and fraud rings, and it also covers someone on a corporate VPN, a developer testing from a cloud workstation and a privacy-conscious person on a commercial VPN. Wrongly rejecting a real customer costs you the customer and probably a complaint. Wrongly approving a throwaway account costs you a few credits of free tier. A model weighing those signals will sometimes weigh them wrong, and you want a person between that mistake and the customer.
Automated decisions about people carry legal weight. In the EU and UK, GDPR Article 22 restricts decisions based solely on automated processing that significantly affect a person, and refusing someone service can qualify. Other jurisdictions have similar rules. A human who genuinely reviews the evidence and can disagree changes the nature of the decision. A human who clicks "approve all" does not, which is why the review screen below shows evidence first. Take advice on your own obligations; the design here is what makes compliance possible, not a guarantee of it.
The model's write-up can be wrong in ways that look right. It may say "the IP is a VPN" when the tool said datacenter, not VPN. You will catch that in code, by checking every evidence item against the actual tool results, but a mistake that slips through should land on a reviewer's screen, not on an account.
The evidence, and what each field is worth
Three APIs from the catalog, all already in your catalog.json from lesson 1.
Email Validator is the richest source. Try it on a sub-addressed Gmail address:
curl "https://api.apiverve.com/v1/emailvalidator?email=Jane.Doe%2Bnewsletter%40gmail.com" \
-H "x-api-key: YOUR_API_KEY"isSubaddressed is true and normalizedEmail is [email protected]: the dots and the +tag are gone.
normalizedEmail is the field that makes this agent useful. Gmail ignores dots and anything after a +, so [email protected] and [email protected] are the same inbox. Someone collecting repeat free trials relies on exactly that. Look up existing accounts by the normalised address and the repeat shows up immediately.
VPN Proxy Detector answers two yes-or-no questions about an IP:
curl "https://api.apiverve.com/v1/vpndetector?ip=173.172.81.20" \
-H "x-api-key: YOUR_API_KEY"Residential: is_vpn and is_datacenter are both false.
IP Lookup locates it:
curl "https://api.apiverve.com/v1/iplookup?ip=8.8.8.8" \
-H "x-api-key: YOUR_API_KEY"region and regionName come back null for this address. Null means unknown, not false.
That last result is a useful warning. Some fields are null for some inputs, and city, coordinates, ASN and the EU flag are premium on IP Lookup, as is_tor and risk_level are on the VPN detector. The agent must treat a missing value as unknown. A prompt that says "reject if is_tor is true" will behave strangely on a plan where is_tor never arrives.
Here is how the signals should be weighted. This table goes into the system prompt in condensed form, and it is the part of the agent you will tune most:
| Signal | Field | Weight |
|---|---|---|
| Throwaway email domain | isDisposable: true | Strong |
| Mail cannot be delivered | canReceiveMail: false or isMxValid: false | Strong |
| Same inbox already has an account | normalizedEmail matches an existing account | Strong for trial abuse, weak otherwise |
| Plus-addressing | isSubaddressed: true | Neutral alone; context for the match above |
| Cloud or hosting network | is_datacenter: true | Weak alone |
| Commercial VPN | is_vpn: true | Weak alone |
| Shared mailbox | isRoleAccount: true | Not a fraud signal |
| Country or region | countryName, regionName | Never a reason on its own |
The last row deserves a sentence of its own. Rejecting signups because of where an IP is located turns a fraud check into a discrimination problem, and IP geolocation is wrong often enough that it would not be a fair proxy even if that were acceptable. Location is context for a reviewer, for example when it contradicts other information the person gave you, never a verdict.
A flagged signup has is_vpn true and every email check is clean: a deliverable address, not disposable, no existing account. What should the agent recommend?
Answer the check above to continue.
The review queue: where the decision actually happens
Install Express 4 and put the agent behind three routes: one your signup pipeline calls when it flags someone, one that lists pending reviews, and one where a reviewer records a decision.
npm install express@4import express from 'express';
import { randomUUID } from 'node:crypto';
import { triage } from './triage.js';
const app = express();
app.use(express.json());
const reviews = new Map(); // use your database in production
// Called by your signup pipeline. Put all routes behind your admin authentication.
app.post('/flagged', async (req, res) => {
const { signupId, email, ip, flaggedBecause } = req.body;
if (!signupId || !email || !ip) return res.status(400).json({ error: 'signupId, email and ip are required' });
const result = await triage({ signupId, email, ip, flaggedBecause, createdAt: new Date().toISOString() });
const id = randomUUID();
reviews.set(id, { id, signupId, status: 'pending', ...result });
res.status(202).json({ id });
});
app.get('/reviews', (req, res) => {
res.json([...reviews.values()].filter((r) => r.status === 'pending'));
});
app.post('/reviews/:id/decision', async (req, res) => {
const review = reviews.get(req.params.id);
if (!review || review.status !== 'pending') return res.status(404).json({ error: 'No pending review with that id' });
const { decision, reviewer, note } = req.body;
if (!['approve', 'request_verification', 'reject'].includes(decision) || !reviewer) {
return res.status(400).json({ error: 'decision and reviewer are required' });
}
Object.assign(review, {
status: 'decided', decision, reviewer, note,
decidedAt: new Date().toISOString(),
agreedWithAgent: review.recommendation ? decision === review.recommendation.recommendation : null,
});
await applyDecision(review.signupId, decision);
res.json(review);
});
async function applyDecision(signupId, decision) {
// Your existing account code: unlock, require verification, or close.
console.log(`Applying ${decision} to ${signupId}`);
}
app.listen(3000, () => console.log('Review queue on http://localhost:3000'));Try it with a repeat-trial pattern. The address below normalises to [email protected], which already has an account in db.js:
node server.js
curl -s -X POST localhost:3000/flagged -H 'Content-Type: application/json' \
-d '{"signupId":"su_2002","email":"[email protected]","ip":"103.62.49.210","flaggedBecause":"datacenter IP"}'
curl -s localhost:3000/reviewsA sound recommendation here is reject or request_verification, citing isSubaddressed, normalizedEmail, find_accounts.count of 1 and is_datacenter, and saying in its summary that the datacenter IP is supporting rather than decisive. If it recommends reject on the datacenter IP alone, your weighting instructions need work. Lesson 4 shows how to catch that systematically instead of by eye.
Three properties of this queue matter more than its simplicity suggests.
Only applyDecision changes anything, and only a person reaches it. The agent's code path ends at reviews.set. There is no route, flag or timeout that turns a recommendation into a decision. Some teams add "auto-approve when the agent recommends approve with high confidence" a month in, because the queue is long. If you do that, you have built an automated decision system, and every argument at the top of this lesson applies to it.
The reviewer is recorded. reviewer and decidedAt are the audit trail. When a customer asks why their account was closed, "the agent decided" is not an answer you want to give.
Agreement is measured. agreedWithAgent costs one line and gives you the single most useful number for this agent: how often people overrule it. A high override rate means the agent needs work. An override rate near zero can mean the agent is excellent, or that reviewers have stopped reading. Look at how long reviews take as well: decisions made in two seconds are not reviews.
Keep retention in mind as well. A review stores an email address, an IP address and a written assessment of a person. Decide how long you keep decided reviews, who can read them, and how they are deleted when the account is, the same way you would for any other record about a user.
You now have two agents built on one loop. Before either goes near production traffic, you need to know how often they get things right and what stops them when they do not: Evaluate and guard your agents.