Create bindu_agent.py with the code below, or save it directly from your editor.
"""Bindu wrapper for the Dutch-law research agent.Exposes the Agno + Dutch-Law-MCP agent (built in `agent.py`) as aBindu-native A2A endpoint: • DID-based identity (deterministic from author + name) • JSON-RPC 2.0 on http://localhost:3773 • `/.well-known/agent.json` agent card with the dutch-law-research skill • mTLS / OAuth2 / x402 extension points wired through bindufy()Run: uv run python bindu_agent.pyThen probe (in another shell): curl -s http://localhost:3773/.well-known/agent.json | jq . curl -s http://localhost:3773/health curl -s -X POST http://localhost:3773 \\ -H 'Content-Type: application/json' \\ -d '{"jsonrpc":"2.0","id":1,"method":"message/send", "params":{"message":{"role":"user", "parts":[{"kind":"text","text":"Wat zegt art. 6:162 BW?"}]}}}'"""from __future__ import annotationsimport asyncioimport osfrom dotenv import load_dotenvload_dotenv()from bindu.penguin.bindufy import bindufyfrom agent import build_agent, build_server_paramsfrom agno.tools.mcp import MCPTools# ---------------------------------------------------------------------------# Persistent MCP connection# ---------------------------------------------------------------------------# `bindufy` runs each request as an `await handler(messages)` coroutine on a# single event loop. Spawning the Dutch-Law-MCP server per request adds ~1-2s# of node-startup overhead and forces SQLite to re-open the 130 MB DB. We# instead lazy-init `MCPTools` once on the first request and keep the stdio# subprocess + SQLite handle alive for the agent's lifetime.## `MCPTools.__aenter__()` opens an anyio task group bound to the task that# called it. Subsequent agent.arun() calls run as child tasks and dispatch# tool calls through the same session, which is the supported pattern.# ---------------------------------------------------------------------------_mcp_tools: MCPTools | None = None_agent = None_init_lock = asyncio.Lock()async def _ensure_agent(): global _mcp_tools, _agent if _agent is not None: return _agent async with _init_lock: if _agent is None: _mcp_tools = MCPTools(server_params=build_server_params()) await _mcp_tools.__aenter__() _agent = build_agent(_mcp_tools) return _agent# ---------------------------------------------------------------------------# Handler# ---------------------------------------------------------------------------# bindufy validates the handler has a single param literally named# `messages`. The framework passes the latest user-message string (not a# list) — see bindu/penguin/manifest.py::_resolve_params.# ---------------------------------------------------------------------------async def handler(messages: str) -> str: agent = await _ensure_agent() result = await agent.arun(input=messages) # Agno >=1.7 returns a RunOutput with `.content`; fall back to str(). content = getattr(result, "content", None) or getattr(result, "response", None) return content if isinstance(content, str) else str(result)# ---------------------------------------------------------------------------# Agent manifest# ---------------------------------------------------------------------------config = { "author": "bindu.builder@getbindu.com", "name": "dutch_law_agent", "description": ( "Citation-grounded Dutch legal research. Answers questions in Dutch " "or English against 3,251 Dutch statutes (BWB) with verbatim " "provision text from wetten.overheid.nl. Backed by the Ansvar " "dutch-legal-citations MCP (18 tools). Research only — not legal " "advice." ), "version": "0.1.0", "deployment": { "url": f"http://localhost:{os.environ.get('BINDU_PORT', '3773')}", "expose": True, "cors_origins": ["http://localhost:5173", "http://localhost:3775"], }, "skills": ["skills/dutch-law-research"],}if __name__ == "__main__": bindufy(config, handler)
The Bindu wrapper imports build_agent and build_server_params from a sibling agent.py that wires the Agno Agent to the dutch-legal-citations MCP server over stdio. Create agent.py alongside bindu_agent.py:
"""Dutch-law research agent — Agno + MCPTools + Ansvar Dutch-Law-MCP.Spawns the `dutch-legal-citations` MCP server over stdio, attaches the 15tools to an Agno Agent, and starts an interactive CLI. Patterned after theAgno MCP demo (https://docs.agno.com/examples/agent-os/advanced-demo/mcp-demo)with the system-prompt structure adapted from `prompt.py`.Run: cp .env.example .env # set OPENAI_API_KEY uv sync uv run python agent.py # interactive uv run python agent.py "your question here" # one-shot"""from __future__ import annotationsimport asyncioimport osimport sysfrom pathlib import Pathfrom dotenv import load_dotenvfrom mcp import StdioServerParametersfrom agno.agent import Agentfrom agno.models.base import Modelfrom agno.models.openai import OpenAIChatfrom agno.models.openrouter import OpenRouterfrom agno.tools.mcp import MCPToolsfrom prompt import SYSTEM_PROMPTdef build_model() -> Model: """Pick the model backend from env. - LLM_PROVIDER=openrouter → agno.models.openrouter.OpenRouter (uses OPENROUTER_API_KEY, base https://openrouter.ai/api/v1). - LLM_PROVIDER=openai (default) → agno.models.openai.OpenAIChat (uses OPENAI_API_KEY). If LLM_PROVIDER is unset but OPENROUTER_API_KEY is and OPENAI_API_KEY is not, OpenRouter is selected automatically. """ explicit = os.environ.get("LLM_PROVIDER", "").strip().lower() has_or = bool(os.environ.get("OPENROUTER_API_KEY")) has_oai = bool(os.environ.get("OPENAI_API_KEY")) provider = explicit or ("openrouter" if has_or and not has_oai else "openai") if provider == "openrouter": model_id = os.environ.get("OPENROUTER_MODEL", "openai/gpt-4o") return OpenRouter(id=model_id) if provider == "openai": return OpenAIChat(id=os.environ.get("OPENAI_MODEL", "gpt-4o")) raise ValueError( f"unknown LLM_PROVIDER={provider!r}. Use 'openai' or 'openrouter'." )load_dotenv()def resolve_mcp_entry() -> Path: """Resolve the absolute path to the built MCP server entrypoint. Defaults to `../Dutch-law-mcp/dist/index.js` relative to this file, matching the layout used in this repo's `examples/` tree. """ raw = os.environ.get("DUTCH_LAW_MCP_ENTRY", "../Dutch-law-mcp/dist/index.js") entry = Path(raw) if not entry.is_absolute(): entry = (Path(__file__).parent / entry).resolve() if not entry.exists(): raise FileNotFoundError( f"Dutch-Law-MCP entry not found at {entry}. " f"Build it with: cd {entry.parent.parent} && " f"npm install --ignore-scripts && npm run build" ) return entrydef _clear_stale_lock(entry: Path) -> None: """Clear `@ansvar/mcp-sqlite`'s lock directory if a prior run crashed.""" import shutil candidates: list[Path] = [entry.parent.parent / "data" / "database.db"] if db_override := os.environ.get("DUTCH_LAW_DB_PATH"): candidates.append(Path(db_override)) for db in candidates: lock = db.with_suffix(db.suffix + ".lock") if lock.is_dir(): shutil.rmtree(lock, ignore_errors=True)def build_server_params() -> StdioServerParameters: entry = resolve_mcp_entry() _clear_stale_lock(entry) env = {"PATH": os.environ.get("PATH", "")} if db_path := os.environ.get("DUTCH_LAW_DB_PATH"): env["DUTCH_LAW_DB_PATH"] = db_path return StdioServerParameters(command="node", args=[str(entry)], env=env)def build_agent(mcp_tools: MCPTools) -> Agent: return Agent( name="Lex-NL", model=build_model(), tools=[mcp_tools], instructions=SYSTEM_PROMPT, markdown=True, )
And the Windsurf-style XML-tagged system prompt in prompt.py:
"""System prompt for the Dutch Law research agent."""SYSTEM_PROMPT = """\You are Lex-NL, a Dutch legal research assistant built on the Agno agent \framework. You operate over a verified, government-sourced corpus of Dutch \law via the `dutch-legal-citations` MCP server (Ansvar Systems). You are \NOT a lawyer; you are a research tool that returns citation-grounded \answers from primary sources only.<corpus>Source: wetten.overheid.nl (BWB — Basiswettenbestand), rechtspraak.nl \(case law), overheid.nl (Kamerstukken), eur-lex.europa.eu (EU metadata).Coverage: 3,251 Dutch statutes, 77,531 provisions.Provenance: every provision is returned verbatim from SQLite FTS5 — zero \LLM paraphrase. If a tool returns no result, say so; do not fabricate.</corpus><tool_calling>1. Only call a tool when necessary. NEVER make redundant calls.2. If you state you will call a tool, call it as your next action.3. Always follow the tool schema exactly. Never invent BWB-IDs.4. Pick the narrowest tool: search_legislation, get_provision, validate_citation, check_currency, build_legal_stance, format_citation, get_eu_basis, get_dutch_implementations, get_provision_at_date, list_sources.</tool_calling><legal_research_method>1. Ground every legal claim in a tool result. If a tool returns nothing, say so verbatim — do NOT fall back on general knowledge.2. Quote operative text VERBATIM from the tool output.3. Always cite as: `Statute (BWB-ID) artikel X`.4. For amendment/repeal questions, run `check_currency` first.</legal_research_method><safety_and_disclaimers>THIS IS A RESEARCH TOOL, NOT LEGAL ADVICE. Municipal and provincial laware out of scope. Recommend a qualified Dutch advocaat for any "whatshould I do" question.</safety_and_disclaimers>"""
The shipped prompt in prompt.py is longer (full Windsurf-style sections for <user_information>, <citation_format>, <communication_style>). Read the file in the source repo for the exact text.
# Dutch Law Research Skill# Citation-grounded research over Dutch federal law (rijkswetgeving)# Basic Metadataid: dutch-law-research-v1name: dutch-law-researchversion: 1.0.0author: bindu.builder@getbindu.com# Descriptiondescription: | Verified, citation-grounded research over Dutch federal law. Answers questions in Dutch or English using the Ansvar dutch-legal-citations MCP server (BWB corpus: 3,251 statutes, 77,531 provisions). Every legal claim is grounded in a verbatim quote from wetten.overheid.nl with a BWB-ID citation. The agent uses 18 MCP tools: search_legislation, get_provision, validate_citation, check_currency, build_legal_stance, get_eu_basis, and others. Out of scope: municipal (gemeentelijk) and provincial (provinciaal) legislation, full EU case-law text, and any matter that requires legal advice (the agent will redirect the user to a qualified Dutch advocaat).# Tags and Modestags: - legal - dutch-law - nederlands-recht - citation - compliance - bwb - wetten-overheid - eu-law - gdpr - avginput_modes: - text/plainoutput_modes: - text/plain - text/markdown# Example Queriesexamples: - "Wat zegt artikel 6:162 BW over onrechtmatige daad?" - "Is artikel 24 van de Mededingingswet nog van kracht?" - "Valideer de citatie 'art. 6:162 BW'." - "Welke EU-richtlijnen worden door de AVG geïmplementeerd?" - "Bouw een juridisch standpunt op over privacyrecht in Nederland." - "What does Burgerlijk Wetboek Boek 6 say about contractual liability?" - "Find Dutch provisions about trade secrets." - "Geef de Grondwet artikel 1." - "Wat is het EU-fundament van AVG art. 5?" - "Welke Nederlandse wet implementeert de ePrivacy-richtlijn?"# Detailed Capabilitiescapabilities_detail: legislation_search: supported: true backed_by_tool: search_legislation features: - fts5_full_text_search - bm25_ranking - statute_scoped_search - status_filter_in_force_amended_repealed - historical_as_of_date provision_retrieval: supported: true backed_by_tool: get_provision features: - exact_text_verbatim - bwb_id_lookup - article_and_lid_resolution citation_validation: supported: true backed_by_tool: validate_citation features: - statute_format_check - case_law_ecli_check - kamerstuk_check - eu_directive_regulation_check currency_check: supported: true backed_by_tool: check_currency features: - in_force_status - amendment_history - repeal_status eu_bridge: supported: true backed_by_tools: - get_eu_basis - get_dutch_implementations - search_eu_implementations - get_provision_eu_basis - validate_eu_compliance features: - dutch_to_eu_lookup - eu_to_dutch_lookup - celex_resolution multi_source_stance: supported: true backed_by_tool: build_legal_stance features: - aggregate_statutes_and_case_law - cross_reference_eu# Out-of-scope (refuse without tool call)out_of_scope: - Municipal (gemeentelijk) ordinances and APVs - Provincial (provinciaal) legislation - Full text of EU directives or CJEU case law - Legal advice (research only — agent will redirect to a qualified advocaat)# Data provenancedata_sources: - name: wetten.overheid.nl role: primary statute corpus (BWB API) authority: Ministerie van Justitie en Veiligheid - name: rechtspraak.nl role: case law (premium) - name: overheid.nl role: Kamerstukken and preparatory works (premium) - name: eur-lex.europa.eu role: EU metadata for cross-references# Disclaimerdisclaimer: | This is a research tool, NOT legal advice. Always verify critical citations against wetten.overheid.nl before relying on them in professional legal work.
Dutch-Law-MCP server is a Node process built from a sibling ../Dutch-law-mcp/ folder
Launched via mcp.StdioServerParameters(command="node", args=["dist/index.js"])
MCPTools opens the subprocess once on the first request and keeps the stdio session warm — avoids ~1-2s node startup per call and the 130 MB SQLite re-open
# Pick one: OPENAI_API_KEY or OPENROUTER_API_KEYOPENAI_API_KEY=sk-...OPENAI_MODEL=gpt-4o# Or use OpenRouter (auto-selected if only this is set)# LLM_PROVIDER=openrouter# OPENROUTER_API_KEY=sk-or-v1-...# OPENROUTER_MODEL=openai/gpt-4o# Path to the built MCP server entry pointDUTCH_LAW_MCP_ENTRY=../Dutch-law-mcp/dist/index.js# Optional: explicit DB path override# DUTCH_LAW_DB_PATH=../Dutch-law-mcp/data/database.db
# Clone the Bindu repositorygit clone https://github.com/GetBindu/Bindu# Navigate to frontend directorycd frontend# Install dependenciesnpm install# Start frontend development servernpm run dev