One governed door in front of your organization's data, your own intelligence layer, with every AI agent behind it: a capability registry that is also the routing table, three-level scope checks, an append-only audit trail, and an MCP server so Claude or Cursor can use it directly. Runs end to end with no third-party credentials.
Start building in Xano: Access the prompt at https://go.xano.co/start-xano-with-template-skill and run it here with governed-agent-data-gateway as your template.Copy and paste the prompt in your local coding agent of choice.
Template Details
One governed door in front of your data, with every AI agent behind it — a capability registry, scope checks, and an append-only audit trail, plus an MCP server so Claude or Cursor can use it directly.
You have data in Xano and a growing number of AI agents that want it. Handing each agent database access or vendor API keys does not scale, and it leaves you with no answer to the only three questions that matter in an incident: who asked, were they allowed, and what happened.
This template is that one door. An agent names a capability — orders.search, weather.forecast — and the layer identifies the caller, checks their scopes, forwards the call to whichever workspace or third-party API actually holds the data, and writes an append-only receipt. Agents never hold a downstream credential and never learn the topology. It runs end to end in about four minutes with no third-party credentials: the router uses Xano's free built-in model, the outside calls go to keyless public APIs, and the "downstream system" is a reference API group in the same workspace, reached over real HTTP. That also means a fresh install starts open — setting the two security switches is the first thing you do, and GET health tells you when you are done.
The code is prefixed
xil_throughout (for Xano intelligence layer), which is what the layer is called inside Xano. It is the same thing this page calls the gateway.
Once more than one agent needs company data, the usual options both fail. Give each agent a database connection or a vendor API key and you have copied your credentials across every consumer, with no way to revoke one of them and no record of what any of them did. Put a bespoke endpoint in front of each use case and the access rules end up spread across a dozen stacks, each slightly different, each a place governance can be wrong.
This template removes that by making the access decision a single piece of code and the routing a table. Every call — from the console, from curl, from Claude, from an agent tool — goes through one function, xil_proxy_request, which resolves the capability in the xil_capability registry, checks the caller's scopes, attaches the downstream credential the caller never sees, and appends a row to xil_audit_log whether the call was allowed, denied, mocked, or failed. Adding a capability is inserting a row, not shipping a deploy; anything not in the registry is not callable; and because the audit row is written on the same path as the call, the record cannot drift from what actually happened.
backend/
ai/
agent/
xil_router.xs
mcp_server/
xil_gateway.xs
tool/
xil_country_info.xs
xil_delivery_weather.xs
xil_fx_convert.xs
xil_get_client.xs
xil_list_branches.xs
xil_mcp_call_capability.xs
xil_mcp_list_capabilities.xs
xil_mcp_recent_audit.xs
xil_mcp_resolve_intent.xs
xil_open_ticket.xs
xil_rep_performance.xs
xil_search_orders.xs
xil_warehouse_search.xs
api/
xil_admin/
agents/
register_post.xs
api_group.xs
health_get.xs
seed_post.xs
xil_app/
api_group.xs
ask_post.xs
audit_get.xs
capabilities_get.xs
history_get.xs
steps_get.xs
xil_auth/
api_group.xs
login_post.xs
me_get.xs
signup_post.xs
xil_layer/
api_group.xs
ask_post.xs
audit_get.xs
capabilities_get.xs
proxy_post.xs
resolve_post.xs
steps_get.xs
xil_source_reference/
branches/
list_get.xs
clients/
get_post.xs
orders/
search_post.xs
reps/
performance_post.xs
api_group.xs
health_get.xs
function/
xil_agent_from_key.xs
xil_build_target_url.xs
xil_check_auth.xs
xil_check_scope.xs
xil_city_coords.xs
xil_current_turn.xs
xil_emit_step.xs
xil_extract_tool_calls.xs
xil_header.xs
xil_mock_response.xs
xil_proxy_request.xs
xil_ref_check_source_key.xs
xil_resolve_caller.xs
xil_resolve_capability.xs
xil_run_ask.xs
xil_seed_data.xs
xil_source_key.xs
xil_write_audit.xs
table/
ref_branch.xs
ref_client.xs
ref_paper_order.xs
ref_sales_rep.xs
xil_agent_key.xs
xil_agent.xs
xil_audit_log.xs
xil_capability.xs
xil_chat_step.xs
xil_request_log.xs
xil_user.xs
workflow_test/
agent_key_identifies_its_caller_and_can_be_revoked.xs
failed_call_errors_instead_of_faking_data.xs
governed_call_is_forwarded_and_audited.xs
health_reports_the_layer_is_seeded_and_wired.xs
intent_resolves_to_a_capability_without_a_model.xs
scope_denied_call_is_logged_not_forwarded.xs
seeded_source_data_answers_for_real.xs
unknown_capability_is_refused_and_audited.xs
workspace/
xil.xs
browser user ── username/password ──► /api:xil-auth ──► /api:xil-app ─┐
agent / MCP client / curl ── X-Agent-Key ──► /api:xil-layer ─────────┤
▼
POST /api:xil-{app,layer}/ask ──► xil_router (xano-free) ─► tool ┐
POST /api:xil-layer/proxy ────────────────────────────────────┤
MCP xil_mcp_call_capability ─────────────────────────────────┤
▼
xil_proxy_request
resolve capability → check scope → call → audit → step
│ │
transport "source" transport "external"
▼ ▼
<base>/api:xil-source-reference/… Open-Meteo · CountriesNow
(this workspace in the demo, open.er-api · DummyJSON
your source workspaces in production) JSONPlaceholder (the only write)
xil-auth; Xano hashes their password and issues a one-day token. Machine callers send an agent key (X-Agent-Key, or Authorization: Bearer xil_…) to xil-layer; xil_resolve_caller hashes it and looks it up. The raw key is never stored: what is kept is its SHA-256 hash plus a short, non-secret prefix so an operator can tell keys apart.POST ask registers a turn in xil_request_log, runs the router agent, and returns the answer with its tool calls, tool errors, and live steps. Browser turns carry user_id, and the app's history, step, and audit queries enforce that owner.xil_proxy_request, the single chokepoint. It looks the capability up in xil_capability, checks the caller's scopes (*:*, service:*, service:operation), builds the target URL, attaches the right downstream credential, calls it, and appends an audit row.xil_emit_step writes progress to xil_chat_step so a UI can show the turn as it runs (GET steps).Scopes use a three-level grammar: *:* (everything), service:* (a whole service), service:operation (one capability). xil_check_scope is a pure function — no logging, no side effects — so the decision is easy to read and easy to test.
Multi-workspace routing lives in three columns on xil_capability: base_url (which instance answers), target_canonical (which API group there), and credential_ref (the name of the environment variable holding that workspace's key). Callers see none of it — the capability name is the whole interface, and the audit row is identical no matter which workspace answered.
Intent resolution is layered on purpose. The LLM picks tools when there is an LLM; POST resolve and the MCP xil_mcp_resolve_intent tool map free text to a capability using keyword rules with no model and no credentials at all, which is what keeps the registry usable from scripts, tests, and offline demos.
MOCK_MODE=true (or force_mock inside a workflow test) makes every capability serve a canned response. That is an explicit request for fake data, and mode says mock. The variable is not shipped — add it in the dashboard when you want it, since unset already means off.
A live call that times out or returns a bad status is not silently replaced with canned data. It returns __error with data: null, and an audit row of outcome: "error", mode: "live"; the upstream status and the technical reason travel in the response_status and reason fields rather than in the caller-facing message. A caller can therefore never mistake invented data for a real answer — fake data only ever arrives when it was asked for. The workflow test failed_call_errors_instead_of_faking_data locks this in.
POST agents/register issues it a key scoped to orders:search and clients:get; anything else it tries is refused before a call is forwarded, and the refusal is logged.Start with your coding agent. Paste the prompt below into a local MCP-capable coding agent — Claude Code, Cursor, Windsurf, and others. It installs the Xano CLI, connects Xano, and imports this template.
Start building in Xano: Access the prompt at https://go.xano.co/start-xano-with-template-skill and run it here with governed-agent-data-gateway as your template.
The agent will walk you through the setup below.
backend/ to a workspace (or install with the Xano template skill, which merges it into your existing workspace).API_AUTH_SECRET when it is set, and answer anyone when it is not — so this call works either way (send the header if your install carries the sample value; omit it after a plain push). Seeding is idempotent; the first run returns two demo agent keys, shown once:curl -X POST https://<instance>/api:xil-admin/seed -H "X-API-Key: CHANGE_ME-operator-secret"
source_reachable: true means the loopback hop to the reference group works, and seeded: true means step 2 landed. Expect two entries in warnings at this stage — one per security switch, saying it is unset (after a plain push) or still on the public sample value:curl https://<instance>/api:xil-admin/health
curl -X POST https://<instance>/api:xil-layer/ask \
-H "X-Agent-Key: xil_…" -H "Content-Type: application/json" \
-d '{"message":"Will the Nashua delivery get rained on in the next 3 days?"}'
GET audit shows outcome: denied.frontend/index.html with your instance URL and API group canonicals, upload it to Xano Static Hosting, then create a username/password account in it:node scripts/build-static.mjs https://<instance>.xano.io
xano static_host build push <host-name> -d dist
[!IMPORTANT] A fresh install is wide open, and health will tell you so. The two security switches —
API_AUTH_SECRETandXIL_SOURCE_KEY— are declared inbackend/workspace/xil.xswith the public sample valuesCHANGE_ME-operator-secretandCHANGE_ME-source-key, so the wiring is demonstrated rather than merely described. Whether they reach your workspace depends on the install path:xano workspace pushdoes not send environment variables unless you pass--env, so after a plain push both are unset — which is the most open state there is, because an unsetAPI_AUTH_SECRETmakes every anonymous caller an operator with*:*. An install that does carry them lands on the sample values, which are printed in this repo and therefore not secrets either.
GET /api:xil-admin/healthis the source of truth for which state you are in: it returnsdemo_mode: trueand awarningsarray naming each switch that is unset or still on a sample value, and the console shows the same as an amber banner.warnings: []is the finish line — see Going to production.
Environment variables are listed under Environment variables; none are required to boot, and two are required to be safe. Regenerate the publication bundle after backend edits with node scripts/build-multidoc.mjs.
xil-layer — caller identified by X-Agent-Key, or X-API-Key, or open when API_AUTH_SECRET is unset
| Method | Path | Purpose |
|---|---|---|
| POST | ask |
Question in, answer out via the router agent; returns turn_id, mode, tool_calls, tool_errors, steps |
| POST | proxy |
Direct governed call: {capability, params, body?} |
| POST | resolve |
Keyword rules map intent to a capability and report has_scope (no LLM) |
| GET | capabilities |
Active registry rows with an allowed flag for the caller |
| GET | steps |
Live step feed for one of your turns (after_id for polling); operators may read any |
| GET | audit |
Paged audit log — your own rows, or all rows plus an agent_name filter for operators |
xil-auth — local browser accounts; passwords are hashed by the xil_user auth table
| Method | Path | Purpose |
|---|---|---|
| POST | signup |
Create a username/password account and return a one-day auth token |
| POST | login |
Verify local credentials and return a one-day auth token |
| GET | me |
Return the signed-in user's safe profile fields |
xil-app — requires Authorization: Bearer <user-token>
| Method | Path | Purpose |
|---|---|---|
| POST | ask |
Authenticated chat through the same governed router and proxy |
| GET | history |
The signed-in user's recent messages and answers |
| GET | capabilities |
Capability registry for the interactive user |
| GET | steps |
Live steps, after verifying the turn belongs to the user |
| GET | audit |
Only audit entries belonging to the signed-in user |
xil-admin — seed and agents/register require X-API-Key when API_AUTH_SECRET is set
| Method | Path | Purpose |
|---|---|---|
| GET | health |
Public. Mode, seeded state, counts, source reachability, the two security switches, uses_sample_secrets, per-source credential status, and a warnings array |
| POST | seed |
Idempotent demo loader; issues demo agent keys on first run |
| POST | agents/register |
Create or update an agent's scopes and mint a new key. Omitted fields keep their existing values |
xil-source-reference (COPY-ME) — requires X-Xil-Source-Key when XIL_SOURCE_KEY is set
| Method | Path | Purpose |
|---|---|---|
| GET | health |
Liveness, record counts, and key_enforced |
| POST | orders/search |
client_name?, product?, status?, branch?, limit? |
| POST | clients/get |
company_name (partial) |
| GET | branches/list |
All branches |
| POST | reps/performance |
rep_name?, branch?; adds attainment_pct |
Capabilities seeded (name → transport): orders.search, clients.get, branches.list, reps.performance → the source reference group; weather.forecast → Open-Meteo; countries.lookup → CountriesNow; fx.latest → open.er-api; catalog.search → DummyJSON; tickets.create → JSONPlaceholder (POST, the only write). All five outside services are keyless.
Header names are matched case-insensitively. Xano re-cases inbound header names (X-API-Key arrives as X-Api-Key), so every header read goes through xil_header, which compares on the lower-cased name. Send whatever casing you like.
| Table | Role |
|---|---|
xil_user |
Username/password accounts for the hosted console |
xil_agent |
Registered callers and their scope list |
xil_agent_key |
SHA-256 hashes of issued keys (plus a short non-secret prefix); revoke by status |
xil_capability |
The capability registry and routing table: service, operation, transport, target, base_url, credential_ref, path template, required scope, keywords |
xil_audit_log |
Append-only: one row per governed call; indexed by agent_id and user_id so reads can be scoped to an owner |
xil_request_log |
One row per inbound request; carries the turn's owner for private history and step access |
xil_chat_step |
Live progress steps per turn |
ref_branch, ref_client, ref_paper_order, ref_sales_rep |
COPY-ME demo data for Keystone Paper Supply Co. |
xil-gateway-mcp exposes four tools — xil_mcp_list_capabilities, xil_mcp_resolve_intent, xil_mcp_call_capability, xil_mcp_recent_audit — and nothing else. MCP clients never see the raw data tools; they get the governed generic surface, and every call lands in the same audit log as the console.
Pass agent_key as a tool argument. That is what decides the caller's scopes and whose audit rows come back. Per-tool Xano user auth can additionally be enabled in the dashboard (that setting is not expressible in XanoScript).
There are two ways to connect, and the console's Connect Claude page walks through both:
| Client | Endpoint | Setup |
|---|---|---|
| Claude connectors (claude.ai, Claude Desktop) | https://<your-worker>.workers.dev/mcp |
Claude connectors require OAuth and Xano MCP servers do not speak it, so you deploy a small Cloudflare Worker in front of the gateway: Add OAuth to a Xano MCP server. The URL shown in the console (xil-gateway-mcp-oauth-proxy.workers.dev) is an example of the shape yours will take — it is not a live endpoint. |
| Bearer-token clients (Cursor, MCP Inspector, custom) | https://<instance>/x2/mcp/xil-gateway-mcp/mcp/stream |
Direct streamable HTTP, no Worker needed: Connecting MCP clients. |
Both security switches are declared with public sample values so the mechanism is demonstrated and closing the door is an edit rather than a discovery — but depending on your install path they may not have reached your workspace at all (see the note in Quick start). GET /api:xil-admin/health tells you where you stand — warnings: [] means production-shaped. The console shows an amber banner and a health chip until then.
| Step | What it fixes |
|---|---|
1. Replace API_AUTH_SECRET in the layer workspace |
It ships as CHANGE_ME-operator-secret, which is printed in this repo — so until you replace it, treat the instance as open. Callers present X-Agent-Key (an agent) or X-API-Key (the operator), and MCP tools must be passed an agent_key. Deleting the variable entirely is worse, not better: anonymous callers then become operators with *:*. |
2. Replace XIL_SOURCE_KEY in the layer and in every source workspace |
It ships as CHANGE_ME-source-key. Both sides must hold the same value. Deleting it makes xil_ref_check_source_key skip its check, and the xil-source-reference endpoints answer anyone who finds the URL. |
3. Issue one narrow agent per consumer via POST agents/register |
Give each agent the smallest scope list that works. Do not hand out *:*. Keys are stored as SHA-256 hashes (plus a short non-secret prefix); rotate by re-registering, revoke by setting status: "revoked". |
4. Give each source workspace its own credential_ref |
See Connecting your own source workspaces. One key per source, not one key for all of them. |
5. Confirm health returns warnings: [], uses_sample_secrets: false, and every source_credentials entry is enforced: true |
This is the check that the previous four actually took effect. |
Who can read the audit log. Reading the record of calls is governed as tightly as making them:
/api:xil-layer/audit and the MCP xil_mcp_recent_audit tool return only the calling key's own rows. The response carries scope: "self".*:* — an admin agent, the X-API-Key operator, or anyone at all while API_AUTH_SECRET is unset — sees the whole log (scope: "all") and may filter by agent_name./api:xil-layer/steps returns a turn only to the caller that created it, or to an operator./api:xil-app/audit, filtered on $auth.id.This is also why step 1 matters: while API_AUTH_SECRET is unset, everyone is an operator, so everyone sees everything.
Not included, and worth adding for a real deployment: per-agent rate limiting, key expiry, and a retention/redaction policy for request_payload.
The repo contains two clearly separated concerns, and only the first belongs in your gateway workspace:
| Concern | Prefix | Stays where |
|---|---|---|
| The layer: users, agents, keys, capability registry, proxy, audit, live steps, router agent, MCP server | tables/functions/tools xil_*, API groups xil-auth, xil-app, xil-layer, xil-admin |
your gateway workspace |
| Downstream reference group: the contract a source workspace exposes to the layer, with demo data for a fictional paper distributor | tables ref_*, API group xil-source-reference, function xil_ref_check_source_key |
copied into each of your source workspaces. Every file is marked COPY-ME. |
To point the layer at a real source instead of the loopback demo:
ref_* queries with your own data:backend/api/xil_source_reference/**backend/table/ref_*.xsbackend/function/xil_ref_check_source_key.xsXIL_SOURCE_KEY to a shared secret. Its endpoints then require header X-Xil-Source-Key.XIL_SOURCE_KEY, and set XIL_SOURCE_BASE_URL to the source instance origin.GET health should report source_reachable: true against the new base URL. Nothing else changes: the proxy, scopes, and audit are identical in loopback and production.Step 3 wires up one source. The registry is built to address many, and each one gets its own credential — a leaked key from one workspace must not open the others. Three columns on xil_capability decide where a row lands:
| Column | Meaning |
|---|---|
base_url |
Instance origin of the workspace that answers this capability. null falls back to XIL_SOURCE_BASE_URL, then to this instance (loopback). |
target_canonical |
API group canonical in that workspace. Defaults to xil-source-reference. |
credential_ref |
Name of the environment variable holding that workspace's shared secret. null means XIL_SOURCE_KEY. |
So a two-source install looks like this:
| Capability | base_url |
credential_ref |
Env var set in the layer |
|---|---|---|---|
orders.search |
https://sales.xano.io |
XIL_KEY_SALES |
XIL_KEY_SALES=… |
tickets.search |
https://support.xano.io |
XIL_KEY_SUPPORT |
XIL_KEY_SUPPORT=… |
Each source workspace sets its own XIL_SOURCE_KEY to the value the layer holds for it. xil_source_key resolves the reference at call time and xil_proxy_request sends it as X-Xil-Source-Key.
Verify a reference actually resolves before you trust it. GET /api:xil-admin/health returns source_credentials, one entry per distinct credential_ref in the registry, with an enforced flag — names only, never values. An enforced: false on a named reference means that workspace is being called with no credential at all, and health raises a warning for it:
"source_credentials": [
{ "name": "XIL_KEY_SALES", "enforced": true },
{ "name": "XIL_KEY_SUPPORT", "enforced": false }
]
Eight workflow tests ship with the template. All of them are credential-free and LLM-free, so they run in a fresh workspace with nothing configured:
xano workflow_test run_all -w <workspace_id>
Xano plans cap how many workflow tests a workspace can store. If a push fails with
Your plan only supports N workflow tests, that is the cap, not the code — push a subset ofbackend/workflow_test/(each file is one self-contained test), run it, then swap in the rest. This is how the suite was verified during the build: two passes of four against byte-identical backend code.
Data-and-setup flows, against the seed data the repo ships:
seeded_source_data_answers_for_real — seeds the workspace, then reads the data back over real HTTP through the source contract with the shared source key, asserting the actual seeded counts. No mocks.health_reports_the_layer_is_seeded_and_wired — seeds, then asserts health reports the registry populated, the demo data loaded, the loopback hop answering, and a warning that the shipped sample secrets are still in place.Governance flows:
governed_call_is_forwarded_and_audited — an agent with orders:* calls orders.search with force_mock; expects mode: mock, data present, and an audit row of outcome: mock.scope_denied_call_is_logged_not_forwarded — an agent without tickets:create calls it; expects a plain refusal in __error, the missing scope in reason, mode: denied, and an audit row with outcome: denied and no upstream status.unknown_capability_is_refused_and_audited — a caller holding *:* asks for a capability that is not in the registry; expects a refusal and an audit row of outcome: error, proving the registry is the allow-list rather than the scope list.intent_resolves_to_a_capability_without_a_model — free text resolves to orders.search and weather.forecast by keyword rules, nonsense resolves to nothing rather than a guess, and a read-only agent is told it lacks the write scope before any call is made.agent_key_identifies_its_caller_and_can_be_revoked — issues a key, confirms it identifies its agent with exactly the scopes granted and nothing more, that the raw key is not recoverable from what was stored, then revokes it and confirms it stops working.failed_call_errors_instead_of_faking_data — a capability pointed at an unroutable host; expects __error, data: null, mode: live, and an audit row of outcome: error. This is the guard that a broken upstream is never quietly answered with canned data.What is not covered automatically. The router agent itself (ai.agent.run on xano-free) is exercised by hand against a live workspace, not in the suite — a model's wording is not a stable assertion. The five keyless public APIs are likewise reached by hand and in the console; the suite deliberately does not depend on the open internet. Everything the tests do assert runs unattended, with no credentials.
Declared in backend/workspace/xil.xs and editable in the dashboard under Workspace settings → Environment Variables.
Values in the table below are what backend/workspace/xil.xs declares. A plain xano workspace push does not send environment variables (that needs --env), so after one both switches are unset until you set them in the dashboard — which is also why a push can never overwrite values you already have.
| Variable | Declared as | Effect |
|---|---|---|
API_AUTH_SECRET |
CHANGE_ME-operator-secret |
Gates seed and agents/register, and is the operator identity for xil-layer, sent as X-API-Key. Replace it — the shipped value is public. Unset entirely = anonymous callers become operators with *:*. |
XIL_SOURCE_KEY |
CHANGE_ME-source-key |
Default shared secret the reference group checks in X-Xil-Source-Key. Replace it, in the layer and in every source workspace. Unset entirely = the source endpoints answer anyone. |
MOCK_MODE |
not declared | Add it yourself; an unset MOCK_MODE behaves exactly like "false". "true" makes every capability serve canned data instead of calling out. Explicit only — failures are never quietly mocked, and this is the only runtime path to canned data. |
XIL_SOURCE_BASE_URL |
not declared | Default instance origin of the source workspace; unset = loopback to this instance. |
XIL_KEY_<NAME> |
not declared | Any variable named by a capability's credential_ref, for per-source credentials. See Fanning out. |
The router agent runs on xano-free, so no model key is needed. To use another provider, edit backend/ai/agent/xil_router.xs: set type to openai, anthropic, or google-genai and add api_key: $env.<YOUR_KEY>.
target_canonical.credential_ref is resolved dynamically ($env[<name>]). The default XIL_SOURCE_KEY is read statically and is always correct; confirm any named reference resolves via source_credentials in GET health before relying on it.ai.agent.run lose the endpoint's input context, so the router passes turn_id into every tool call. The fallback in xil_current_turn is used only when exactly one request is running; it never guesses between concurrent users.Xano gives you everything you need to ship modern applications—fast, securely, and at scale.
Get started