Your agents.Your data.Your rules.
Give your AI agent a URL. curral provides the skill, schema and business context to query your DuckDB and Iceberg data. You decide what it can read, which rows it sees and which columns stay masked.
docker pull lucasapassos/curral:v0.8.0Curral is Portuguese for corral: where you keep the herd in and decide who gets through the gate.
“What was our revenue last month?”
- 01Discover permitted tables
schema - 02Read business context
docs - 03Validate before execution
dry_run - 04Query within your rules
query
✓ Query complete · row filters and masks applied
Agent-ready by design
A URL is all it takes to get started.
No SDK required. One skill gives your agent a path from a question to a permissioned SQL result.
- 01
Give it a URL
The server describes its API and provides the skill install command. Your agent can discover how to connect.
- 02
Connect your agent
Install the skill and sign in from your terminal. Works with Codex, Claude Code and other agents that read SKILL.md.
- 03
Ask your data
Your agent discovers the schema, searches business context, checks its SQL and queries within your permissions.
Install for Codex · v0.8.0
curl -fsSL https://raw.githubusercontent.com/lucasapassos/curral/v0.8.0/skills/curral/scripts/install.sh -o /tmp/curral-install.sh
CURRAL_SKILL_REF=v0.8.0 sh /tmp/curral-install.sh ~/.agents/skills/curral
# In your own terminal: credentials stay out of the chat.
~/.agents/skills/curral/scripts/curral.sh loginContext to reason. Boundaries to act.
The skill teaches your agent to discover only what it may query, read semantic documents and validate SQL with a dry run. Your policies govern every query.
Codex · Claude Code · Hermes · OpenClaw · omp
Choose your agent and connect →DuckDB has no users, no permissions and no network protocol.
So sharing a DuckDB file or an Iceberg lake ends one of two ways: you hand out storage credentials and everyone can read everything, or you build and babysit a custom API for every team that asks.
curral is the layer in between. One binary with DuckDB embedded, speaking HTTP and Flight SQL on one side, deciding per statement who may run it, which rows they see and which columns arrive masked.
Six checks between a request and your data
Each stage can stop a request, and every outcome lands in the audit log.
- 1
Authenticate
Basic with bcrypt, service API keys, JWTs from any OIDC provider, or a trusted service naming the user it acts for. Repeated failures lock the IP or user out.
- 2
Queue
A global concurrency limit plus per-user quotas, so one heavy user cannot starve everyone else.
- 3
Inspect
The statement is prepared, not run. DuckDB’s own planner says which base tables it really reads, through views, CTEs and joins.
- 4
Authorize
An embedded OPA evaluates your Rego policy and returns allow or deny, plus limits and column masks.
- 5
Protect
Each protected table is replaced by a subquery that filters rows and masks columns before your query sees it.
- 6
Execute
A fresh connection and one transaction per request. Results stream as JSON, CSV, NDJSON or Arrow, over HTTP or Flight SQL.
Try the gate: see how your rules change a query
Send a query through the gate
Simulated in your browser with an example policy.
- Authenticate
- Queue
- Inspect
- Authorize
- Protect
- Execute
Pick a user and a statement, then send it.
Access rules you can review in a pull request
Policies are Rego, evaluated by an embedded Open Policy Agent. No sidecar, no extra service. The policy sees the base tables a statement really reads, taken from DuckDB’s planner rather than a regex, so a view or a CTE cannot smuggle a read past it.
- Row-level security per role or user, driven by config or by an ACL table.
- Column masks computed before your query runs, so a
WHERE ssn = …cannot guess the hidden value. - Dry runs that explain a decision without executing anything.
- Per-role limits for timeout, row count and concurrency.
package curral
import rego.v1
default allow := false
allow if "admin" in input.roles
allow if {
"analyst" in input.roles
input.resolved
input.statement_type == "SELECT"
every t in input.tables { startswith(t, "lake.") }
}
masks := {"lake.analytics.customers": {
"ssn": "last:4", "email": "redact",
}} if not "pii_reader" in input.rolestables:
lake.analytics.customers:
rules:
- roles: [analyst]
where: "region = 'north'"
- users: ["*@partner.example.com"]
where: "region IN (SELECT region FROM ctl.main.acl
WHERE usr = getvariable('curral_user'))"$ curl -u analyst localhost:8080/v1/query \
-d '{"sql": "DELETE FROM orders", "dry_run": true}'
{
"dry_run": true,
"decision": "deny",
"decided_by": "policy",
"statement_type": "DELETE",
"tables": ["lake.analytics.orders"],
"targets": ["lake.analytics.orders"],
"resolved": true,
"policy_sha256": "9f2c…"
}Anything DuckDB can attach
Declare databases in a YAML file and curral attaches them at startup, then locks the engine. Credentials come from the environment and are scrubbed from the process once loaded.
- DuckDB files
- Iceberg REST catalogs
- Cloudflare R2 Data Catalog
- Postgres
- S3 and Parquet
273 ms to 8 ms
Median query time against Cloudflare R2 once the built-in catalog metadata cache is on. Throughput with 8 clients went from 3 to 338 requests per second.
catalog.yaml
extensions: [httpfs, iceberg]
secrets:
- name: r2
type: iceberg
params: { TOKEN: "${R2_TOKEN}" }
databases:
- name: lake
path: ${R2_WAREHOUSE}
schema: analytics
cache_ttl: 30s
options: { TYPE: iceberg, SECRET: r2, ENDPOINT: "${R2_CATALOG_URI}" }
- name: sales
path: /data/sales.duckdbQuery it from anywhere
One statement per request, parameters bound server-side. GET /v1/schema lists only what the caller may query, which makes curral a safe front door for notebooks, BI tools, database IDEs and AI agents.
Streaming one million rows over the full HTTP path
curl -u analyst https://curral.example.com/v1/query \
-d '{"sql": "SELECT * FROM orders WHERE id = $1", "params": [42]}'export CURRAL_URL=https://curral.example.com CURRAL_TOKEN=curral_...
curral query "SELECT count(*) FROM orders" # CSV on stdout
curral query -f arrow -o orders.arrow "SELECT * FROM orders"from curral import Client
c = Client("https://curral.example.com", token="curral_...")
df = c.query("SELECT * FROM orders WHERE amount > $1", [100]).to_pandas()
print(c.dry_run("DELETE FROM orders")) # explains the decision, runs nothing# Arrow Flight SQL: the port DataGrip and DBeaver use
import adbc_driver_flightsql.dbapi as flightsql
conn = flightsql.connect("grpc+tls://curral.example.com:31337",
db_kwargs={"username": "analyst", "password": "..."})
cur = conn.cursor()
cur.execute("SELECT * FROM orders WHERE amount > ?", (100,))
table = cur.fetch_arrow_table() # pyarrow.Table# Install the skill first: see the AI agents guide
scripts/curral.sh capabilities # features and document scope
scripts/curral.sh schema # only what this agent may read
scripts/curral.sh search "revenue per customer" # what the docs say first
scripts/curral.sh dry-run "SELECT ..." # ask the policy first
scripts/curral.sh query -m 20 "SELECT ..." # then sample, within limitsDatabase IDEs
Open it in DataGrip or DBeaver
Start curral with --flight-listen and it also speaks Arrow Flight SQL. Database IDEs connect through the standard JDBC driver, ADBC clients in Python, Go and R connect directly. Same users, same policy, same audit log: nothing to configure twice.
- A tree with only what you may query, from the same listing as
/v1/schema. Hidden tables never show up. - Masks and row filters on every result, the data preview included.
- Refused before it runs: statements are authorized when the IDE prepares them.
- Passwords, API keys or SSO tokens, with the same lockouts and TLS certificate.
- lake
- analytics
- customers
- orders
- analytics
- sales empty
SELECT name, email, ssn, region
FROM customers;| name | ssn | region | |
|---|---|---|---|
| Ana Souza | *** | ***6789 | north |
| Carla Dias | *** | ***3456 | north |
| Elisa Nunes | *** | ***9999 | north |
3 rows
jdbc:arrow-flight-sql://localhost:31337?useEncryption=falseExecution delegation
Query as your app’s users, not as its key
A portal or internal app has its own users. Give it a delegating key and it names, on every request, the user it is acting for. curral runs the query as that identity: your policy, row filters, masks, limits and audit apply to them, and the key’s own roles are never used.
- The policy decides what a service may assert, with the identity in
input.identityand the key ininput.actor. - Read-only, and no fallback: a missing or refused identity is a 403, never the key’s access.
- Row filters from the policy, ANDed with the file, reading the identity through
curral_identity. - Over HTTP and Flight SQL, with the effective subject confirmed on every response and recorded in the audit log.
$ ctx=$(printf '%s' '{"subject":"portal:tenant-a:user:42","kind":"user","tenant":"tenant-a"}' \
| base64 | tr '+/' '-_' | tr -d '=\n')
$ curl -si localhost:8080/v1/query \
-H "Authorization: Bearer $PORTAL_KEY" \
-H "X-Curral-Execution-Context: $ctx" \
-d '{"sql": "SELECT count(*) AS n FROM orders"}'
HTTP/1.1 200 OK
X-Curral-Effective-Subject: portal:tenant-a:user:42
{"columns":[{"name":"n","type":"BIGINT"}],"data":[{"n":118}],"row_count":1}# The portal key may only speak for users of its own tenant.
allow if {
input.actor.name == "portal"
input.identity.tenant == input.actor.attributes.tenant
input.identity.kind == "user"
input.statement_type == "SELECT"
every t in input.tables { t == "sales.main.orders" }
}
row_filters := {"sales.main.orders": "tenant = 'tenant-a'"} if {
input.actor.name == "portal"
}Semantic layer
Context for the agents writing your SQL
The schema says a column is called amount. It does not say it is in cents, or that cancelled orders never count. Semantic documents do: short notes from the people who know the data, with sample SQL, that an agent searches with the user’s own question before it writes a query.
Filter
table and schema narrow the candidates to the documents about them.
BM25
Words of the question, title weighted most; accents, identifiers and plurals folded. Runs in memory, offline.
Jev
TypeSafe’s model judges whether the SQL would really use each document. Only those that pass come back, ranked by probability.
With --docs-scope schema, documents follow query permissions. The agent can see which tables in a document it may query. Writes go through your policy and carry an ETag. Without a Jev key, search stays local.
$ curl -u analyst localhost:8080/v1/docs/search \
-d '{"q": "revenue per customer last month"}'
{
"ranker": "jev",
"results": [{
"score": 0.93,
"judged": true,
"doc": {
"header": {"title": "Monthly revenue"},
"context": {
"tables": ["lake.analytics.orders"],
"body": "amount is in cents. Exclude cancelled orders.",
"sqls": [{"sql": "SELECT customer, sum(amount) / 100 …"}]
}
}
}]
}{
"header": {
"title": "Monthly revenue",
"description": "Gross revenue per customer and month",
"used_for": ["finance reports", "churn analysis"]
},
"context": {
"tables": ["lake.analytics.orders"],
"body": "amount is in cents. Cancelled orders (status = 'cancelled') never count as revenue. Join customers on customer_id, not email.",
"sqls": [{
"description": "revenue per customer and month",
"sql": "SELECT customer_id, date_trunc('month', created_at) AS m, sum(amount) / 100 AS revenue FROM lake.analytics.orders WHERE status <> 'cancelled' GROUP BY ALL"
}]
}
}Built to run unattended
- Fail-closed audit log
- One JSON line per request: who, what, which tables, which policy version decided. If the log cannot be written, queries stop.
- TLS and lockouts
- Native TLS 1.2+ or Caddy in front. Failed logins are counted per IP and per user, checked before bcrypt.
- Hot reload
- Send SIGHUP to swap users, keys, policies, row filters and semantic documents. Invalid files are rejected; in-flight queries keep their version.
- Prometheus metrics
- Stage latencies, decisions by role, queue depth, audit health and cache hits, on a separate port.
- Hardened image
- Distroless, non-root, no shell, about 190 MB. Extensions are baked in, so it boots without internet.
- Fuzzed inspection
- A differential fuzzer runs every generated statement against DuckDB and fails if a write escapes the inspection.
Open the gate in two minutes.
Clone the repository and start curral with the example users and policy.
git clone --branch v0.8.0 https://github.com/lucasapassos/curral && cd curral
docker run --rm -p 127.0.0.1:8080:8080 \
-v "$PWD/examples:/etc/curral:ro" -e CURRAL_DATA=/var/lib/curral \
lucasapassos/curral:v0.8.0 serve \
--catalog /etc/curral/catalog.yaml --users /etc/curral/users.yaml \
--policy /etc/curral/policy.rego --policy /etc/curral/roles.json