Function calling (OpenAI and Anthropic)
Expose Constaia as a validate_document tool for OpenAI and Anthropic models, run it on your server and return a trimmed result to the model.
If your product has an assistant or agent built on an LLM, you can give it a validate_document tool so it can
check documents with Constaia. The model decides when to call it and with which arguments; your code runs it by
calling the API with your key and returns only what's needed to the model.
user → model → tool call validate_document(file_url, expect, checks)
→ your server validates the arguments → Constaia /v1/analyze
→ trimmed result (verdict, reasons, key fields) → model → answerIf what you want is to use Constaia from Claude Desktop, Claude Code, Cursor or another desktop client, you don't need to write code: use the MCP server.
Prerequisites
- Node.js ≥ 18 or Python ≥ 3.9.
- A test key
ck_test_…from the dashboard in theCONSTAIA_API_KEYvariable. - An OpenAI key (
OPENAI_API_KEY) or an Anthropic key (ANTHROPIC_API_KEY). - The documents reachable at an
https://URL on your own storage (for example, a temporary signed link your application creates when the user uploads the file).
npm i @constaia/sdk openai @anthropic-ai/sdk1. Define the tool
This module holds everything that doesn't depend on the model provider: the tool's JSON schema, argument validation and the call to Constaia. The model never sees the key: it only sees the schema and the result.
import { Constaia, ConstaiaError } from "@constaia/sdk";
const constaia = new Constaia(); // reads CONSTAIA_API_KEY
export const ALLOWED_TYPES = ["es_dni", "es_nie", "passport", "medical_certificate_sport", "payment_receipt"];
const ALLOWED_URL_PREFIXES = ["https://files.example.com/uploads/"];
const KEY_FIELDS = [
"document_number", "nie_number", "first_name", "last_name", "last_name_1", "last_name_2",
"birth_date", "expiry_date", "issue_date", "amount", "currency",
];
export const TOOL_NAME = "validate_document";
export const TOOL_DESCRIPTION =
"Validates a document uploaded by the user (Spanish DNI, NIE, passport, sports medical certificate or " +
"payment receipt). Returns the verdict valid, invalid or review, the reasons and the key data.";
export const TOOL_SCHEMA = {
type: "object",
properties: {
file_url: { type: "string", description: "https URL of the document, exactly as the application provides it." },
expect: {
type: "array",
items: { type: "string", enum: ALLOWED_TYPES },
minItems: 1,
description: "Acceptable document types.",
},
checks: {
type: "object",
description: "Optional rules the document must meet.",
properties: {
not_expired: { type: "boolean" },
min_age_years: { type: "integer" },
max_age_days: { type: "integer", description: "Maximum age of the issue date, in days." },
holder: {
type: "object",
properties: {
full_name: { type: "string" },
document_number: { type: "string" },
birth_date: { type: "string", description: "YYYY-MM-DD" },
},
additionalProperties: false,
},
expected_amount: { type: "number" },
expected_reference: { type: "string" },
},
additionalProperties: false,
},
},
required: ["file_url", "expect"],
additionalProperties: false,
};
function toSdkChecks(checks = {}) {
const { holder } = checks;
return {
notExpired: checks.not_expired,
minAgeYears: checks.min_age_years,
maxAgeDays: checks.max_age_days,
expectedAmount: checks.expected_amount,
expectedReference: checks.expected_reference,
holder: holder
? { fullName: holder.full_name, documentNumber: holder.document_number, birthDate: holder.birth_date }
: undefined,
};
}
export async function runValidateDocument(input) {
const fileUrl = input?.file_url;
const expect = [].concat(input?.expect ?? []);
if (typeof fileUrl !== "string" || !ALLOWED_URL_PREFIXES.some((p) => fileUrl.startsWith(p))) {
return { error: "file_url not allowed: only documents uploaded to the application are accepted." };
}
if (expect.length === 0 || !expect.every((t) => ALLOWED_TYPES.includes(t))) {
return { error: `expect may only contain: ${ALLOWED_TYPES.join(", ")}` };
}
try {
const analysis = await constaia.analyze(
{ fileUrl },
{ expect, checks: toSdkChecks(input.checks ?? {}), language: "en" },
);
return {
analysis_id: analysis.id,
status: analysis.status, // "queued" or "processing" if it didn't finish within 30 s
document_type: analysis.document?.type ?? null,
verdict: analysis.verdict?.status ?? null,
reasons: (analysis.verdict?.reasons ?? []).map(({ code, severity, message }) => ({ code, severity, message })),
fields: Object.fromEntries(
KEY_FIELDS.filter((name) => analysis.fields?.[name]).map((name) => [name, analysis.fields[name].value]),
),
warnings: analysis.warnings ?? [],
};
} catch (error) {
if (error instanceof ConstaiaError) {
return { error: `${error.code ?? error.type}: ${error.message}`, request_id: error.requestId };
}
throw error;
}
}The available options and checks are in POST /v1/analyze and
Checks. The reasons codes are explained in Verdicts.
2. OpenAI (Chat Completions)
The tool is declared with type: "function". When the model asks for it, the response carries tool_calls; you
run each one and reply with a role: "tool" message with the same tool_call_id.
import OpenAI from "openai";
import { TOOL_DESCRIPTION, TOOL_NAME, TOOL_SCHEMA, runValidateDocument } from "./constaia-tool.js";
const openai = new OpenAI(); // reads OPENAI_API_KEY
const model = process.env.OPENAI_MODEL ?? "gpt-4.1";
const tools = [
{ type: "function", function: { name: TOOL_NAME, description: TOOL_DESCRIPTION, parameters: TOOL_SCHEMA } },
];
const messages = [
{
role: "system",
content:
"You help review registrations. Use validate_document to check documents. " +
"If verdict is review or status is not completed, say the document is pending review.",
},
{
role: "user",
content: "Check that https://files.example.com/uploads/dni_valid.jpg is a valid DNI of an adult.",
},
];
for (let turn = 0; turn < 5; turn++) {
const completion = await openai.chat.completions.create({ model, messages, tools });
const message = completion.choices[0].message;
messages.push(message);
if (!message.tool_calls?.length) {
console.log(message.content);
break;
}
for (const call of message.tool_calls) {
let result;
if (call.function.name !== TOOL_NAME) {
result = { error: `Unknown tool: ${call.function.name}` };
} else {
try {
result = await runValidateDocument(JSON.parse(call.function.arguments));
} catch {
result = { error: "The document could not be validated." };
}
}
messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(result) });
}
}3. Anthropic (Messages API)
In the Anthropic API the tool is declared with input_schema. When the model uses it, stop_reason is tool_use
and the content includes tool_use blocks; you reply with a user message containing tool_result blocks with the
same tool_use_id.
import Anthropic from "@anthropic-ai/sdk";
import { TOOL_DESCRIPTION, TOOL_NAME, TOOL_SCHEMA, runValidateDocument } from "./constaia-tool.js";
const anthropic = new Anthropic(); // reads ANTHROPIC_API_KEY
const tools = [{ name: TOOL_NAME, description: TOOL_DESCRIPTION, input_schema: TOOL_SCHEMA }];
const system =
"You help review registrations. Use validate_document to check documents. " +
"If verdict is review or status is not completed, say the document is pending review.";
const messages = [
{
role: "user",
content: "Check that https://files.example.com/uploads/dni_valid.jpg is a valid DNI of an adult.",
},
];
for (let turn = 0; turn < 5; turn++) {
const response = await anthropic.messages.create({
model: "claude-sonnet-4-5",
max_tokens: 1024,
system,
tools,
messages,
});
messages.push({ role: "assistant", content: response.content });
if (response.stop_reason !== "tool_use") {
console.log(response.content.filter((b) => b.type === "text").map((b) => b.text).join("\n"));
break;
}
const results = [];
for (const block of response.content) {
if (block.type !== "tool_use") continue;
let result;
try {
result =
block.name === TOOL_NAME
? await runValidateDocument(block.input)
: { error: `Unknown tool: ${block.name}` };
} catch {
result = { error: "The document could not be validated." };
}
results.push({
type: "tool_result",
tool_use_id: block.id,
content: JSON.stringify(result),
is_error: Boolean(result.error),
});
}
messages.push({ role: "user", content: results });
}What the model does with the result
The tool returns a small object like this one (test mode, dni_valid.jpg, no checks):
{
"analysis_id": "an_01J…",
"status": "completed",
"document_type": "es_dni",
"verdict": "valid",
"reasons": [
{ "code": "type_match", "severity": "info", "message": "The document is Spanish ID card (DNI)." },
{ "code": "not_expired", "severity": "info", "message": "Valid until 12/03/2031." }
],
"fields": {
"document_number": "12345678Z",
"first_name": "MARÍA",
"last_name_1": "GARCÍA",
"last_name_2": "LÓPEZ",
"birth_date": "1990-05-14",
"expiry_date": "2031-03-12",
"issue_date": "2021-03-12"
},
"warnings": []
}Let the model explain the result, but make decisions with side effects (approving a registration, paying,
onboarding) in your code based on verdict, not on the text the model generates:
verdict | Recommended decision |
|---|---|
| Válido | Your code can continue the process |
| No válido | Reject and explain the reasons with severity error |
| Revisar | Hand it to a person (human review) |
null with status queued/processing | The analysis is still running: call GET /v1/analyses/{id} or wait for the webhook |
Test in test mode
With a ck_test_… key no credits are used and the response depends on the file name. With file_url,
Constaia takes the name from the last segment of the URL, so upload real images named dni_valid.jpg,
dni_expired.jpg or blurry.jpg to your storage and ask the model to check them:
| URL | Verdict |
|---|---|
…/uploads/dni_valid.jpg | Válido |
…/uploads/dni_expired.jpg | No válido not_expired with severity error |
…/uploads/blurry.jpg | Revisar low_quality |
With a checks.holder.full_name other than "María García López" you get invalid with the holder reason. All
scenarios in Test mode.
Security
- The key never reaches the model. It lives in the environment of the server that runs the tool; the model only sees the schema and the result.
- Allowlist types on the server. Even if the schema has an
enum, checkexpectagain in your code: the model can produce arguments that don't match the schema. - Don't let the model pick arbitrary URLs. A hidden instruction in an email, a web page or the document itself could make the model ask to analyze third-party files and spend your credits. Accept only URLs from your storage (or, better, an internal identifier your code maps to a URL) and cap the number of calls per conversation.
- Return only what's needed. The trimmed result reduces the personal data you send to the model provider. Keep
the
analysis_idif you need the full detail later. - Treat extracted data as untrusted text. A document can contain text written to manipulate the model; don't
take actions just because they appear in
fields. - Use test keys during development and a separate live key per environment. More in Security.
Limits
- 20 MB per file; PDFs up to 30 pages synchronously. If the analysis takes longer than 30 s, the API returns 202
and
status: "queued"or"processing". - 2 requests per second per key on the free plan (10 on paid); the SDK retries 429s honouring
Retry-After. See Rate limits.
Next steps
Retool
Build an internal Retool panel to validate documents with Constaia: a REST resource with the key in configuration variables, file upload and results.
LangChain and LlamaIndex
Wrap Constaia as a LangChain (JS and Python) and LlamaIndex tool so your agents validate documents and get a clear valid, invalid or review verdict.