This tutorial builds an agent with function calling where the tool exposed to the model never executes arbitrary SQL. It combines two mechanisms already verified in the Aurabase code: the NL2SQL validator and a read-only Postgres transaction, two bricks of thenative AI integrated into thebackend. Prerequisites: an Aurabase project, its service_rolekey, and an account with one of the three native LLM providers (OpenAI, Anthropic, Gemini).
The essentials
- The real risk is not the function calling itself, but the tool exposed to the model: a raw
execute_sql(query)gives it full SQL access. - The safe architecture exposes a
query_database(question)tool that delegates to a syntax tree validator (SELECT only, bounded LIMIT, isolated schema) rather than direct execution. - Aurabase exposes this validator natively (
/nl2sql): reusing it as an implementation of the tool avoids having to recode the SQL validation yourself. - The committed SQL then executes through
aura.db.sql()inreadOnly: truemode, an actual read-only Postgres transaction, not a simple textual filter. - Aurabase's native
/chatendpoint does not yet accept antoolrole nor antoolsparameter (verified in the code): the agent loop currently runs via the LLM provider's SDK, not via the Aurabase proxy. - The
service_rolekey bypasses RLS by design: it must never leave your backend, and the agent inherits broader access than a typical authenticated user.
What you will build
You'll build an agent that answers natural language questions about data in a Postgres project, without ever letting the model write SQL that executes as is. The model calls a tool named query_database, this tool translates the question into SQL validated via NL2SQL, then executes this read-only SQL and returns the lines to the model so that it formulates its answer.
This tutorial uses the @aurabase/aurabase-js JavaScript SDK on the server side (never on the browser side, the service_role key must not be exposed to the client) and the OpenAI function calling API for the agent loop. The same principle applies with the Anthropic or Gemini SDK.
Why a “run this SQL” tool is dangerous
Most Postgres agent tutorials, including some official guides, define a single tool: a execute_sql function that takes an SQL string as an argument and executes it as is. The model writes this string itself, based on the user's question and the schema given to it in context.
This choice transfers a responsibility to the model that it cannot reliably fulfill. A prompt injection slipped into the question can produce destructive SQL that the tool executes indiscriminately, since it has no concept of what a "legitimate" query should look like. Our dedicated article details this attack vector: securing NL2SQL against SQL injection.
The alternative built in this tutorial exposes a narrower tool, query_database(question). The model can no longer write SQL directly: it can only ask a question in its own tool call. It is the Aurabase NL2SQL engine which translates this question into SQL, before passing it through a syntactic tree validator (SELECT alone, no subqueries, ten functions authorized, limited LIMIT).
A execute_sql(query: string) tool gives the model full SQL access, regardless of how good your system prompt is. An instruction ("only executes SELECTs") remains an instruction that the model can follow, misinterpret, or see circumvented by an injection slipped into the user's question.
Define the schema of the tool exposed to the model
Aurabase's three native LLM providers (OpenAI, Anthropic, Gemini) accept a table of tool definitions in JSON Schema format. A single tool is enough for this agent: query_database, which takes a question in natural language and nothing else. The model sees neither the SQL schema nor a query field that it could fill in itself.
Implement the tool: NL2SQL then read-only
The tool handler runs on your backend, never in the browser. It carries the project key service_role, which bypasses RLS by design and therefore should never be exposed to a client. It makes two calls to the Aurabase SDK.
The first call translates the question into SQL validated via aura.ai.nl2sql(): SELECT only, LIMIT limited, no access to the system catalog. The second executes this SQL already validated via aura.db.sql(), with the readOnly: true option: Postgres itself then refuses any writing in this transaction, independently of the textual validation already applied by NL2SQL upstream.
readOnly: true triggers a real read-only Postgres transaction: the engine refuses the write, it is not a filter applied to the request text. Combined with NL2SQL's SELECT-only validation, the agent has two independent layers: if one has a flaw, the other still holds.
The agent loop: function calling on the supplier's SDK side
Aurabase exposes three native LLM providers, but its /chat endpoint does not yet relay an tools parameter or toolrole. ChatOptions only carries temperature, max_tokens and model, and accepted roles are limited to system, user and assistant (verified in llm/mod.rs and handlers/chat.rs). The function calling loop therefore runs today directly via the provider's SDK, not via the Aurabase proxy.
As long as Aurabase does not natively orchestrate tool calls, your backend must manage the loop itself with the OpenAI, Anthropic or Gemini SDK. NL2SQL and SQL execution remain classic Aurabase calls inside this loop.
The principle remains the same if you orchestrate the agent with LangChain or a service like Azure AI Agent: the tool declared in the framework must remain the same query_database, never a raw SQL executor. Our comparison details where LangChain and LlamaIndex provide real value on Postgres, and where they especially add complexity: Postgres agents with LangChain or LlamaIndex.
Test with a real question
Question sent to agent: “How many premium customers placed an order this month?” ". The template calls query_database with this question as is, without ever seeing or writing any SQL. Here is the result of the two internal calls triggered by the tool.
The model's final answer is based on these actual lines, not a guess. If the tool returns zero rows, a number hallucination becomes significantly less likely than with a model that would respond without verified data.
Secure the agent before going into production
- The
service_rolekey never leaves your backend: neither in the prompt sent to the model, nor in a log, nor in a client-side environment variable. readOnly: trueremains active onaura.db.sql()for this specific tool, even if your project needs writing elsewhere in the application.service_rolebypasses RLS by design. If the agent should respond differently depending on the user asking the question, explicitly filter in the SQL or fall back to classic PostgREST endpoints, which respect RLS. See multi-tenant RLS isolation.- Log each tool call (question asked, SQL validated, number of lines): this is the only usable trace if a question produces an unexpected result.
- Aurabase's rate limiting and monthly quota already apply per project on
/nl2sql: a talkative agent cannot silently exceed your AI budget.
Current limits to be aware of
The query_database tool inherits all the limitations of the NL2SQL validator: no subqueries, no CTE/WITH, no UNION, and a closed list of ten SQL functions. A question which naturally calls for a sub-query (“customers who have never ordered”) must be reformulated, or processed by a second dedicated tool rather than forced into NL2SQL.
No orchestration of tool calls exists today inside the Aurabase /chat proxy: the agent loop described here lives in your application code, not in a managed service. If the agent must chain several tools (database and documentary RAG, for example), it is your backend which orchestrates the two calls.
RAG and function calling combined
This tutorial covers structured questions on relational data. For questions about unstructured content (documents, tickets, notes), the same agent can expose a second tool connected to Aurabase's native RAG (pgvector, HNSW search). The two capabilities and their articulation are detailed on the page Native AI on Postgres.