Build an AI Compliance Agent in 30 Minutes: KYC with the handelsregister.ai API

8 min di lettura

Step-by-step: AI agent for KYC and supplier screening using handelsregister.ai as a tool — setup, system prompt, agent loop, audit trail.

Questo articolo è disponibile in inglese.

Instead of designing a workflow, you give the agent a goal. “Check this GmbH for compliance risks” — and the agent decides on its own which data to fetch, in what order, and when it knows enough. This exact pattern is currently replacing rigid n8n pipelines in the KYC space. The good news: you don’t need a specialized stack, no MCP, no vector DB. An LLM with tool calling, three endpoints from the handelsregister.ai API, and about 100 lines of Python are enough for a working first compliance agent. In this article I’ll show you the complete setup — including the system prompt, agent loop, demo run, and the hardening steps you need before the thing goes live.

Workflow vs. AI Agent: the decisive difference

A classic workflow (for example in n8n) consists of fixed steps: Trigger → API call → mapping → decision → save. You as the developer define the order and handle all branches explicitly.

An AI agent flips that around. You describe a goal and provide the LLM with a set of tools (functions). The LLM decides at each step: do I call tool A or tool B now, or do I already have enough information to answer? Instead of hard-wired logic you get an iterative reasoning loop.

For compliance, that’s brilliant — and dangerous. Brilliant, because many cases don’t fit into a rigid pipeline: sometimes a UBO lookup is enough, sometimes you need person profiles for multiple managing directors, sometimes you have to continue into a second ownership layer. Dangerous, because an agent without guardrails can hallucinate or burn too many calls. The trick lies in clean tool design and a precise system prompt.

Architecture: LLM + three tools + REST API

You need three building blocks:

  • An LLM with tool calling. OpenAI (gpt-4.1, gpt-5), Anthropic (Claude Sonnet, Claude Opus), or local via Ollama — all modern models support the scheme. Examples below in Python with the OpenAI SDK; Anthropic works analogously.
  • Three tools that wrap handelsregister.ai endpoints.
    • search_organization → GET /api/v1/search-organizations (company search, filters)
    • fetch_organization → GET /api/v1/fetch-organization with feature=ubos and feature=related_persons
    • fetch_person → GET /api/v1/fetch-person (AI-enriched person profile)
  • A simple loop that calls the LLM, executes tool calls, feeds results back — until the LLM no longer calls tools and instead returns a final answer.

So the agent runs like an intern with three clearly defined permissions: search companies, retrieve a company profile with owners, check a person. No more, no less.

Setup in five minutes

pip install openai httpx pydantic
export OPENAI_API_KEY="sk-..."
export HANDELSREGISTER_API_KEY="hr_..."

You’ll get the handelsregister.ai key in the dashboard. In the header it’s called x-api-key. You don’t need any more configuration for the first run.

Tool definitions: three functions, three JSON schemas

Tools are described to the LLM as a JSON schema. The LLM receives the name, purpose, and parameters — nothing more. You write the actual HTTP logic alongside in Python:

import os, httpx

HR_KEY = os.environ["HANDELSREGISTER_API_KEY"]
BASE = "https://handelsregister.ai/api/v1"
HEAD = {"x-api-key": HR_KEY}

def search_organization(q: str, postal_code: str | None = None) -> dict:
    params = {"q": q, "limit": 5}
    if postal_code:
        params["filters"] = f'{{"postal_code":"{postal_code}"}}'
    return httpx.get(f"{BASE}/search-organizations", params=params, headers=HEAD).json()

def fetch_organization(q: str) -> dict:
    params = {"q": q, "feature": ["ubos", "related_persons"], "ai_search": "on-default"}
    return httpx.get(f"{BASE}/fetch-organization", params=params, headers=HEAD).json()

def fetch_person(person_q: str, organization_q: str) -> dict:
    params = {"person_q": person_q, "organization_q": organization_q}
    return httpx.get(f"{BASE}/fetch-person", params=params, headers=HEAD).json()

The tool schemas for the LLM look like this (shortened — only fetch_organization):

TOOLS = [{
  "type": "function",
  "function": {
    "name": "fetch_organization",
    "description": "Complete company profile incl. UBOs and current management.",
    "parameters": {
      "type": "object",
      "properties": {"q": {"type": "string", "description": "Company name or HR number."}},
      "required": ["q"]
    }
  }
}]  # plus search_organization and fetch_person analogously

Important: In description, describe when the tool should be used, not only what it does. The LLM makes its choice primarily based on these texts. The UBO aspect belongs in there — see also our UBO guide for the threshold logic.

System prompt + agent loop

The system prompt is your agent’s character. Keep it short, precise, and with a clear output schema:

SYSTEM = """You are a KYC compliance agent for German companies.
Task: Use the tools to check a GmbH and, at the end, return a JSON
with the fields: company, ubos (>=25%), management, risk_flags, coverage, verdict.
Rules:
- Always start with fetch_organization. Use search_organization only
  if the company name is ambiguous.
- If coverage < 80%, set risk_flags += ["low_coverage"].
- Check exactly one UBO with fetch_person if there are suspicious indications
  (e.g. multiple affiliations, foreign context).
- Respond exclusively with the final JSON, no prose."""

The agent loop is 30 lines of Python. Pseudocode:

from openai import OpenAI
client = OpenAI()
messages = [{"role":"system","content":SYSTEM},
            {"role":"user","content":"Check: Example Holding GmbH"}]

while True:
    resp = client.chat.completions.create(
        model="gpt-5.5", messages=messages, tools=TOOLS)
    msg = resp.choices[0].message
    messages.append(msg)
    if not msg.tool_calls:
        print(msg.content); break
    for call in msg.tool_calls:
        result = dispatch(call.function.name, call.function.arguments)
        messages.append({"role":"tool","tool_call_id":call.id,"content":str(result)})

dispatch is a tiny routing function that maps the tool name to the corresponding Python function. That’s it structurally. Anthropic users replace the client and the message structure — the logic remains identical.

Demo run: what the agent actually does

Input: “Check: Examplewerk Analytics GmbH from Munich”. A typical trace looks like this:

  1. Tool: fetch_organization with q="Examplewerk Analytics GmbH Munich". Response: company found, two resolved UBOs (one person 60%, one holding with an unresolved share 40%), two managing directors, coverage 60%.
  2. LLM reasoning: “Coverage below 80%, so low_coverage flag. The 60% person is a clear UBO — no additional person lookup necessary. The unresolved holding is an escalation trigger, no automatic profiling.”
  3. Tool call omitted, the agent goes straight to output.
  4. Final output (JSON):
{
  "company": {"name": "Examplewerk Analytics GmbH", "register": "HRB 254891 Munich"},
  "ubos": [{"name": "Max Mustermann", "share": 60, "source": "shareholders_list"}],
  "management": ["Max Mustermann", "Erika Example"],
  "risk_flags": ["low_coverage", "unresolved_holding_40_percent"],
  "coverage": 0.6,
  "verdict": "needs_enhanced_due_diligence"
}

That’s the value: not just raw data, but a structured assessment with reasoning — and a complete trace for every step that you can archive.

From prototype to production: five hardening topics

A working agent is not automatically production-ready. Before you unleash it on real onboarding cases, you need:

  • Audit trail. Every tool call (input + result), every LLM message, and the final verdict belong in a table with timestamp and run ID. That’s not “nice to have” — that’s a GwG requirement.
  • Idempotency and caching. An agent that fires all tools again on a second request for the same company burns credits unnecessarily. A hash cache on the input (company name + date) and a TTL of e.g. 24h are enough for most workflows.
  • Rate limits + retries. The handelsregister.ai API allows 60 calls/min (and 5/min on document endpoints). Add exponential backoff on 429 responses and limit maximum tool calls per run (e.g. 8) — otherwise the agent ends up in loops.
  • Cost control. Set a hard limit on tokens and tool calls per run, otherwise the bill explodes on an edge case. Recommendation: max 8 tool calls and 4,000 output tokens. More is almost always a bug.
  • Hallucination protection. An LLM can “invent” fields that aren’t in the API response. Validate the final JSON against a strict Pydantic schema. If a field is missing or a made-up value appears, reject the run and log it as a training case.

If you check off these five items cleanly, you’re no longer just prototyping — you have a compliance tool that holds up in audits.

Bonus: multi-agent instead of mono-agent

As soon as tasks get more complex (e.g. “check these 50 companies per day”), a researcher–reviewer pattern is worth it: one agent researches (may use all tools), a second checks the first output against the JSON schema and either gives the green light or sends it back. This doubles token costs, but in practice halves hallucination rates — and is still significantly cheaper than a human.

Recap

With three API tools, a precise system prompt, and about 100 lines of Python, you can build a functioning AI compliance agent in an afternoon. The handelsregister.ai API is well-suited for this because its responses are already structured — the LLM doesn’t need to parse, only decide. The next level (caching, multi-agent, RAG pipeline) is built on top without overturning the core.

If you want to go deeper into KYC mechanics: we explain the UBO threshold and ownership chains in detail elsewhere, and the comparison of visual workflows (n8n + handelsregister.ai) helps you decide which approach is right when.