Constaia
Integrations

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.

Esta página ainda não está traduzida para o seu idioma. Mostramos a versão em inglês.

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 in CONSTAIA_API_KEY.
  • A key for the model provider. The examples use Anthropic (ANTHROPIC_API_KEY); with OpenAI only the model class changes (ChatOpenAI in 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 zod
constaia-tool.js
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", "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.

langchain-agent.js
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).

llamaindex_agent.py
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:

verdictDecision
VálidoContinue the process
No válidoReject and explain the reasons with severity error
RevisarHand 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:

URLResult
…/uploads/dni_valid.jpgVálido MARÍA GARCÍA LÓPEZ, 12345678Z
…/uploads/dni_expired.jpgNo válido not_expired with severity error
…/uploads/blurry.jpgRevisar 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-After and limit concurrency on the client (maxConcurrency in JavaScript, max_concurrency in Python; 4 by default). See Rate limits.

Next steps

Nesta página