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.
This guide turns Constaia document validation into a validate_document tool for LangChain (JavaScript and
Python) and LlamaIndex (Python). The logic is the same as in function calling:
the agent picks the arguments, your code validates them, calls Constaia with your key and returns a trimmed result.
If you only want to use Constaia from a desktop client (Claude Desktop, Cursor, VS Code), use the MCP server.
Prerequisites
- Node.js ≥ 18 or Python ≥ 3.9.
- A test key
ck_test_…from the dashboard inCONSTAIA_API_KEY. - A key for the model provider. The examples use Anthropic (
ANTHROPIC_API_KEY); with OpenAI only the model class changes (ChatOpenAIin LangChain). - The documents at an
https://URL on your own storage.
The function that calls Constaia
All three integrations use this function. It checks the URL and types on the server, calls POST /v1/analyze and returns only the verdict, the reasons and the key fields.
npm i @constaia/sdk @langchain/core @langchain/anthropic zodimport { 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", "amount", "currency",
];
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 async function runValidateDocument({ file_url, expect, checks = {} }) {
if (!ALLOWED_URL_PREFIXES.some((p) => file_url.startsWith(p))) {
return { error: "file_url not allowed: only documents uploaded to the application are accepted." };
}
if (!expect.length || !expect.every((t) => ALLOWED_TYPES.includes(t))) {
return { error: `expect may only contain: ${ALLOWED_TYPES.join(", ")}` };
}
try {
const analysis = await constaia.analyze(
{ fileUrl: file_url },
{
expect,
language: "en",
checks: {
notExpired: checks.not_expired,
minAgeYears: checks.min_age_years,
holder: checks.holder_full_name ? { fullName: checks.holder_full_name } : undefined,
},
},
);
return {
analysis_id: analysis.id,
status: analysis.status,
verdict: analysis.verdict?.status ?? null,
reasons: (analysis.verdict?.reasons ?? []).map(({ code, severity, message }) => ({ code, severity, message })),
fields: Object.fromEntries(
KEY_FIELDS.filter((n) => analysis.fields?.[n]).map((n) => [n, analysis.fields[n].value]),
),
warnings: analysis.warnings ?? [],
};
} catch (error) {
if (error instanceof ConstaiaError) {
return { error: `${error.code ?? error.type}: ${error.message}`, request_id: error.requestId };
}
throw error;
}
}LangChain
The tool() function from @langchain/core/tools takes the implementation and a zod schema, which LangChain
converts to the model's tool format.
import { tool } from "@langchain/core/tools";
import { HumanMessage, SystemMessage } from "@langchain/core/messages";
import { ChatAnthropic } from "@langchain/anthropic";
import { z } from "zod";
import { TOOL_DESCRIPTION, runValidateDocument } from "./constaia-tool.js";
const validateDocument = tool(async (input) => JSON.stringify(await runValidateDocument(input)), {
name: "validate_document",
description: TOOL_DESCRIPTION,
schema: z.object({
file_url: z.string().describe("https URL of the document, exactly as the application provides it."),
expect: z
.array(z.enum(["es_dni", "es_nie", "passport", "medical_certificate_sport", "payment_receipt"]))
.min(1)
.describe("Acceptable document types."),
checks: z
.object({
not_expired: z.boolean().optional(),
min_age_years: z.number().int().optional(),
holder_full_name: z.string().optional().describe("Expected full name of the holder."),
})
.optional(),
}),
});
const model = new ChatAnthropic({ model: "claude-sonnet-4-5", maxTokens: 1024 }).bindTools([validateDocument]);
const messages = [
new SystemMessage(
"You help review registrations. Use validate_document to check documents. " +
"If verdict is review or status is not completed, say it is pending review.",
),
new HumanMessage(
"Is https://files.example.com/uploads/dni_valid.jpg a valid DNI of María García López, who must be an adult?",
),
];
for (let turn = 0; turn < 5; turn++) {
const ai = await model.invoke(messages);
messages.push(ai);
if (!ai.tool_calls?.length) {
console.log(ai.content);
break;
}
for (const call of ai.tool_calls) {
messages.push(await validateDocument.invoke(call)); // returns a ToolMessage
}
}The tool works the same with any LangChain chat model that supports tools (bindTools / bind_tools) and inside
LangChain or LangGraph prebuilt agents: pass it in the tools list.
LlamaIndex
FunctionTool.from_defaults infers the schema from the function's signature and type annotations; the description
comes from the description parameter (or the docstring).
import asyncio
import json
from typing import List, Optional
from llama_index.core.agent.workflow import FunctionAgent
from llama_index.core.tools import FunctionTool
from llama_index.llms.anthropic import Anthropic
from constaia_tool import TOOL_DESCRIPTION, run_validate_document
def validate_document(
file_url: str,
expect: List[str],
not_expired: Optional[bool] = None,
min_age_years: Optional[int] = None,
holder_full_name: Optional[str] = None,
) -> str:
"""Validates a document. expect accepts: es_dni, es_nie, passport, medical_certificate_sport, payment_receipt."""
return json.dumps(
run_validate_document(file_url, expect, not_expired, min_age_years, holder_full_name),
ensure_ascii=False,
)
tool = FunctionTool.from_defaults(fn=validate_document, name="validate_document", description=TOOL_DESCRIPTION)
agent = FunctionAgent(
tools=[tool],
llm=Anthropic(model="claude-sonnet-4-5", max_tokens=1024),
system_prompt=(
"You help review registrations. Use validate_document to check documents. "
"If verdict is review or status is not completed, say it is pending review."
),
)
async def main() -> None:
response = await agent.run(
"Is https://files.example.com/uploads/dni_valid.jpg a valid DNI of María García López, who must be an adult?"
)
print(str(response))
asyncio.run(main())Since FunctionTool doesn't enforce an enum on expect, the allowed-types check in run_validate_document is
what protects your account.
What to return and what to decide
The tool returns JSON with verdict, reasons, fields and warnings. Let the agent explain it, but decide
anything with side effects in your code:
verdict | Decision |
|---|---|
| Válido | Continue the process |
| No válido | Reject and explain the reasons with severity error |
| Revisar | Hand it to a person (human review) |
null (status queued/processing) | The analysis is still running: call GET /v1/analyses/{id} or use webhooks |
Test in test mode
With ck_test_… no credits are used and the result depends on the file name, which with file_url is the last
segment of the URL. Upload real images with these names to your storage:
| URL | Result |
|---|---|
…/uploads/dni_valid.jpg | Válido MARÍA GARCÍA LÓPEZ, 12345678Z |
…/uploads/dni_expired.jpg | No válido not_expired with severity error |
…/uploads/blurry.jpg | Revisar low_quality |
With a holder_full_name other than "María García López" and dni_valid.jpg, the verdict is invalid with the
holder reason. More in Test mode.
Security
- The Constaia key lives in the server's environment; the agent never sees it.
- Check allowed types and URL origin on the server, even if the schema already restricts them.
- Don't let the agent analyze URLs coming from untrusted content (emails, web pages, the document itself): a hidden instruction could make it spend your credits on third-party files. Also cap the number of calls per conversation.
- Return only the fields the model needs and treat extracted text as untrusted.
- Develop with test keys. More in Security.
Limits
- 20 MB per file; PDFs up to 30 pages synchronously (if it takes longer than 30 s, the API responds 202).
- 2 requests per second per key on the free plan (10 on paid). The SDKs retry 429s honouring
Retry-Afterand limit concurrency on the client (maxConcurrencyin JavaScript,max_concurrencyin Python; 4 by default). See Rate limits.
Next steps
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.
Authentication
Authenticate Constaia API calls with ck_live_ and ck_test_ Bearer keys, where to create and revoke them, and why they never go in the browser.