LayBuild does not connect to your Postgres or Supabase database, and an earlier version of this post implied it could. What LayBuild can do is call an HTTP endpoint you own, an "API tool", and show the result next to the AI's reply. So the safe way to get database data into a support chat is to put a small read-only service in front of the database and give LayBuild a token for that service, never a connection string. This post shows how to build that service and, just as important, what the tool mechanism can't do today.

How API tools work in LayBuild

An admin configures a tool with a base URL, a path, an HTTP method, custom headers, optional parameters with default values, a timeout (1 to 60 seconds, 10 by default) and one of four auth types: none, bearer token, basic auth, or an API key in a header you name. Then they attach the tool to an AI agent.

At runtime the flow is fixed:

text
customer message
      |
      v
retrieve from knowledge base --> generate answer (LLM)
                                        |
                                        v
                 call every attached tool in parallel, with default parameters only
                                        |
                                        v
                 first successful (2xx) response is attached to the reply
                                        |
                                        v
       chat window shows the answer, with the tool's JSON rendered underneath

That design has consequences you need to plan around:

  • This is not LLM function calling. The model doesn't decide whether to call a tool and can't fill in arguments. Every attached tool is called on every AI reply, with the default values you saved.
  • No customer context is sent. The request carries no question text, no customer ID and no conversation ID, and LayBuild sends no request body. A tool can't look up "my order" or "my account".
  • The model never sees the result. Tool data is attached to the message after the answer is generated, so the AI's text won't mention or reason about it.
  • The customer sees what you return. The response JSON is rendered under the reply. Treat the endpoint's output as public.
  • In production, tool URLs must point at a public host. Private IP ranges and localhost are rejected.
  • LayBuild logs each call with the request URL and the first 2,000 characters of the response.

Plans include 10 (Starter), 100 (Pro) or 250 (Premium) API tools.

What this is good for

Given those rules, the right data is current, non-personal and the same for every customer: open incidents and service status, today's delivery cut-off, maintenance windows, stock levels for a fixed product list, a price list your database is the source of truth for.

Customer-specific lookups (order status, balances, subscription state) aren't possible through LayBuild tools today. Route those to a human with handoff, or show them in your own signed-in UI.

Step one: a view and a read-only role

Don't point the endpoint at your tables with your application's credentials. Give it a view that contains only the columns you'd show a customer, and a role that can read that view and nothing else:

sql
create schema if not exists support_api;

-- Only the columns a customer may see. The view runs with its owner's
-- privileges, so the reader role needs no grant on public.incidents.
create view support_api.open_incidents as
  select title, status, started_at
  from public.incidents
  where resolved_at is null;

create role support_reader login password 'use-a-long-random-password';
grant usage on schema support_api to support_reader;
grant select on support_api.open_incidents to support_reader;

-- Belt and braces: even a bug in the endpoint can't write or run long.
alter role support_reader set default_transaction_read_only = on;
alter role support_reader set statement_timeout = '2s';

This works the same in Supabase's SQL editor. Two Supabase-specific notes: don't add support_api to the schemas exposed through Supabase's auto-generated API, since the endpoint below should be the only way in; and when you build the connection string for support_reader, check Supabase's connection docs for the username format your connection pooler expects.

Step two: the endpoint

A Bun handler with a bearer token, a fixed parameterized query, a row limit and a short cache. The cache matters because LayBuild calls the tool on every AI reply.

ts
import { SQL } from 'bun';
import { timingSafeEqual } from 'node:crypto';

const databaseUrl = process.env.SUPPORT_READER_URL;
const token = process.env.SUPPORT_TOOL_TOKEN;
if (!databaseUrl || !token) throw new Error('SUPPORT_READER_URL and SUPPORT_TOOL_TOKEN are required');

const db = new SQL(databaseUrl);
const MAX_ROWS = 10;
const CACHE_MS = 30_000;
let cached: { body: unknown; expiresAt: number } | null = null;

function isAuthorized(req: Request): boolean {
  const given = Buffer.from(req.headers.get('authorization') ?? '');
  const expected = Buffer.from(`Bearer ${token}`);
  // Constant-time compare so the token can't be guessed byte by byte from timing.
  return given.length === expected.length && timingSafeEqual(given, expected);
}

async function loadIncidents(): Promise<unknown> {
  const rows: Array<{ title: string; status: string; started_at: Date }> = await db`
    select title, status, started_at
    from support_api.open_incidents
    order by started_at desc
    limit ${MAX_ROWS}`;
  if (rows.length === 0) {
    return { type: 'text', title: 'All systems normal', description: 'No open incidents.' };
  }
  return {
    type: 'key_value',
    title: 'Open incidents',
    fields: rows.map((r) => ({
      label: r.title,
      value: `${r.status} since ${new Date(r.started_at).toUTCString()}`,
    })),
  };
}

Bun.serve({
  port: 8787,
  routes: {
    '/support/incidents': {
      GET: async (req) => {
        if (!isAuthorized(req)) return new Response('Unauthorized', { status: 401 });
        if (!cached || cached.expiresAt < Date.now()) {
          cached = { body: await loadIncidents(), expiresAt: Date.now() + CACHE_MS };
        }
        return Response.json(cached.body);
      },
    },
  },
});

The response uses one of the shapes LayBuild's chat window knows how to render: key_value (a titled list of label and value pairs) and text here; table, list, link_card and product_card also exist. Anything else is shown as raw JSON, which works but looks like a debug panel.

Put it behind TLS on a public hostname. If you'd rather not run a server, a Supabase Edge Function can do the same job; Supabase's docs note that because Edge Functions run server-side, "it's safe to connect directly to your database" (Supabase docs, checked September 2026). Keep the same rules: read-only role, fixed query, row limit, bearer token.

Step three: attach it in LayBuild

In the dashboard, an admin creates the tool with the endpoint's base URL, path /support/incidents, method GET and auth type Bearer with your token, then attaches it to the AI agent. Use the tool's test action to check the response renders before customers see it.

The token is stored as tool configuration in LayBuild's database. Make it worthless anywhere else: generate it for this endpoint only, never reuse your database password or an application API key, and rotate it if an admin with tool access leaves.

Why we don't let a model near SQL

A common alternative is to give the model a database tool and let it write queries. We don't do that and don't recommend it. A support chat takes untrusted text from anyone on the internet, and a model that turns that text into SQL is one prompt-injection away from reading tables you never meant to expose. Even with function calling, the pattern that holds up is the one above: fixed, parameterized endpoints with the narrowest possible credentials. LayBuild's tools are stricter than that, since they don't take arguments at all, which is limiting but leaves little for an attacker to steer.

Related reading