Constaia
Integrations

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.

Cette page n'est pas encore traduite dans votre langue. Voici la version anglaise.

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 → answer

If 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 the CONSTAIA_API_KEY variable.
  • 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/sdk

1. 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.

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", "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.

openai-agent.js
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.

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

validate_document result
{
  "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:

verdictRecommended decision
VálidoYour code can continue the process
No válidoReject and explain the reasons with severity error
RevisarHand it to a person (human review)
null with status queued/processingThe 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:

URLVerdict
…/uploads/dni_valid.jpgVálido
…/uploads/dni_expired.jpgNo válido not_expired with severity error
…/uploads/blurry.jpgRevisar 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, check expect again 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_id if 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

Sur cette page