Giving an AI agent an email and domain lookup tool
· 7 min read · Tutorials
To give an AI agent an email and domain lookup tool, define a function with a JSON Schema input (one string for the email or domain, one flag for whether you want the company profile), describe in detail what it returns, and execute it in your own server code that calls the lookup API with a key the model never sees. The same schema works for Anthropic tool use, OpenAI function calling and an MCP server. Below are all three, built around pingutil's POST /v1/lookup, plus why you want a lookup here instead of letting the model guess.
Why a lookup beats asking the model
Ask a model "is jane@somedomain.com a disposable address?" and it will answer. The answer is based on whatever it saw in training and on how the name sounds. That works for Mailinator. It doesn't work for the long tail.
- Lists move, weights don't. pingutil's disposable category comes from the community disposable-email-domains list, about 9,900 domains as of 2026, re-synced weekly. A model trained months ago has never seen the domains added since, and many have names that sound like ordinary businesses.
- Same input, same output. A lookup returns the same category and
verdict.reasonevery time. A sampled model can flip between runs, which is bad for anything that gates a signup. - Auditable. "Blocked:
disposable_domain" with a request ID is something you can show a support agent. "The model thought so" isn't. - Fast and cheap. With
skip_enrichment: true, pingutil makes no LLM call and answers in milliseconds.
The model is still useful in the loop: deciding when to look something up, combining several results, explaining them to a human. It just shouldn't be the source of the facts. We go into that split for sales workflows in LLM lead qualification.
The tool executor (shared by every provider)
Write this once. It validates the arguments the model produced, calls the API with a server-side key, and returns only the fields the model needs. Trimming matters: smaller results cost fewer tokens and leave less room for the model to latch onto irrelevant fields.
// pingutil-tool.ts
export const LOOKUP_DESCRIPTION = [
'Classify an email address or domain using the pingutil lookup API.',
'Returns category (company, free, disposable, educational, government, isp, unknown),',
'the registrable domain, and a verdict: is_spam is true for disposable domains,',
'reputation-flagged domains and machine-generated local parts; low_trust flags role,',
'system and sales addresses. Use it whenever the user asks whether an address is',
'disposable, personal, a work email, or which company it belongs to.',
'Set include_company to true only when company details are needed; that is slower and',
'the profile is an AI-generated estimate with a confidence score.',
'It does NOT check whether the mailbox exists, and has no domain age, WHOIS or SPF/DMARC data.',
].join(' ');
export const LOOKUP_SCHEMA = {
type: 'object',
properties: {
input: {
type: 'string',
description: 'An email address (jane@stripe.com) or a bare domain (stripe.com).',
},
include_company: {
type: 'boolean',
description: 'true to include the company profile for company domains, false for a fast classification only.',
},
},
required: ['input', 'include_company'],
additionalProperties: false,
} as const;
export type LookupArgs = { input: string; include_company: boolean };
export async function runLookup(args: LookupArgs) {
if (typeof args?.input !== 'string' || args.input.length < 1 || args.input.length > 320) {
throw new Error('input must be an email or domain of 1 to 320 characters');
}
const res = await fetch('https://pingutil.com/v1/lookup', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.PINGUTIL_KEY}`, // never in a prompt
'content-type': 'application/json',
},
body: JSON.stringify({ input: args.input, skip_enrichment: !args.include_company }),
});
const data = await res.json();
if (!res.ok) throw new Error(`${data.error}: ${data.message ?? ''}`.trim());
const c = data.company;
return {
domain: data.domain,
category: data.category,
verdict: data.verdict,
is_role_based: data.email_signals?.is_role_based ?? null,
company: c && {
name: c.name,
industry: c.industry,
employee_size_estimate: c.employee_size_estimate,
country: c.headquarters?.country ?? null,
website: c.website,
confidence: c.confidence,
},
};
}
The description follows Anthropic's guidance on tool definitions: say what the tool does, when to use it, what each parameter means, and what it doesn't return. That last line stops the model from claiming "the mailbox exists" because a lookup came back clean.
Anthropic tool use
A Claude tool is name, description and input_schema. When the model wants to call it, the response has stop_reason: "tool_use" and a tool_use block. You run the tool and send back a tool_result block with the matching tool_use_id in a user message.
import Anthropic from '@anthropic-ai/sdk';
import { LOOKUP_DESCRIPTION, LOOKUP_SCHEMA, runLookup, type LookupArgs } from './pingutil-tool';
const client = new Anthropic(); // reads ANTHROPIC_API_KEY
const tools: Anthropic.Tool[] = [
{ name: 'lookup_email_or_domain', description: LOOKUP_DESCRIPTION, input_schema: LOOKUP_SCHEMA as any },
];
export async function ask(question: string) {
const messages: Anthropic.MessageParam[] = [{ role: 'user', content: question }];
for (let turn = 0; turn < 6; turn++) {
const response = await client.messages.create({
model: 'claude-opus-5-5',
max_tokens: 1024,
tools,
messages,
});
messages.push({ role: 'assistant', content: response.content });
if (response.stop_reason !== 'tool_use') return response;
const results: Anthropic.ToolResultBlockParam[] = [];
for (const block of response.content) {
if (block.type !== 'tool_use') continue;
try {
const out = await runLookup(block.input as LookupArgs);
results.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(out) });
} catch (err) {
results.push({ type: 'tool_result', tool_use_id: block.id, content: String(err), is_error: true });
}
}
messages.push({ role: 'user', content: results });
}
throw new Error('too many tool turns');
}
Returning failures with is_error: true lets the model tell the user the lookup failed instead of inventing an answer. The turn cap keeps a confused loop from burning through your rate limit. Swap the model ID for whichever current Claude model you use; the SDK also has a tool runner that handles this loop for you.
OpenAI function calling (Responses API)
OpenAI's function calling guide uses type: "function" with parameters for the schema. With strict: true, every property must be in required and every object needs additionalProperties: false, which is why the shared schema makes include_company required instead of optional.
import OpenAI from 'openai';
import { LOOKUP_DESCRIPTION, LOOKUP_SCHEMA, runLookup } from './pingutil-tool';
const openai = new OpenAI(); // reads OPENAI_API_KEY
const tools = [
{ type: 'function' as const, name: 'lookup_email_or_domain', description: LOOKUP_DESCRIPTION,
parameters: LOOKUP_SCHEMA as any, strict: true },
];
export async function ask(question: string) {
const input: any[] = [{ role: 'user', content: question }];
for (let turn = 0; turn < 6; turn++) {
const response = await openai.responses.create({ model: process.env.OPENAI_MODEL!, tools, input });
const calls = response.output.filter((item) => item.type === 'function_call');
if (calls.length === 0) return response.output_text;
input.push(...response.output);
for (const call of calls) {
let output: string;
try {
output = JSON.stringify(await runLookup(JSON.parse(call.arguments)));
} catch (err) {
output = JSON.stringify({ error: String(err) });
}
input.push({ type: 'function_call_output', call_id: call.call_id, output });
}
}
throw new Error('too many tool turns');
}
The shape differs (call_id instead of tool_use_id, outputs appended to input), but the executor is the same function.
Wrapping it as an MCP tool
If you want the lookup available in any MCP client (desktop assistants, IDE agents), wrap the same executor in a Model Context Protocol server. As of 2026, the TypeScript SDK's v2 ships the server as @modelcontextprotocol/server; v1 projects import McpServer from @modelcontextprotocol/sdk instead.
import { McpServer } from '@modelcontextprotocol/server';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import * as z from 'zod/v4';
import { LOOKUP_DESCRIPTION, runLookup } from './pingutil-tool';
const server = new McpServer({ name: 'pingutil-lookup', version: '1.0.0' });
server.registerTool(
'lookup_email_or_domain',
{
description: LOOKUP_DESCRIPTION,
inputSchema: z.object({ input: z.string().min(1).max(320), include_company: z.boolean() }),
},
async (args) => {
try {
return { content: [{ type: 'text', text: JSON.stringify(await runLookup(args)) }] };
} catch (err) {
return { content: [{ type: 'text', text: String(err) }], isError: true };
}
},
);
await server.connect(new StdioServerTransport());
With stdio, the server runs as a local process and reads PINGUTIL_KEY from its own environment, so the key still never enters the conversation. pingutil doesn't ship an official MCP server or SDK package; this is a thin wrapper you own.
Keep the key and the decision server-side
A few rules that hold for all three setups:
- The key lives with the executor. Not in the system prompt, not in the tool description, not in a client-side bundle. If the agent runs in a browser, the tool call must go through your backend.
- Treat tool arguments as untrusted input. The model fills them from user text. Validate length and type, as
runLookupdoes, before they reach any API. - Let code act on the verdict. If the agent screens signups, your code blocks on
verdict.is_spamand routeslow_trustto review. The agent can explain the outcome. It shouldn't be able to talk itself out of a block. - Mind the rate limit. Each key gets 60 requests per minute, and an agent working through a list can hit that fast. Cache results per registrable domain in the executor (caching domain lookups covers keys and TTLs) and surface a 429 as a tool error so the model can back off.
- Label the company profile as an estimate. It's generated by a model and carries a
confidencefield. How accurate LLM company enrichment is explains when to trust it.
Field names and the full response schema are in the API reference and the machine-readable OpenAPI spec. You can generate the tool schema from that spec if you'd rather not maintain it by hand.
Questions people ask
- Can an LLM tell whether an email domain is disposable without a tool?
- Only for well-known services that appeared in its training data. New disposable domains appear constantly and many have neutral names, so a model guessing from the name will miss them and may answer differently on each run. A lookup against a maintained list gives the same answer every time.
- Where should the API key live when an agent calls an API?
- In the code that executes the tool, read from an environment variable or secret store. The model only sees the tool's name, description, schema and the results you choose to return. Never put a key in the system prompt or tool description.
- Should the agent decide whether to block a signup?
- Usually not. Let deterministic code act on the API's verdict field and let the agent explain, summarize or ask follow-up questions. That keeps blocking decisions reproducible and auditable.