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.

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

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

Nesta página