SQL Agent
The SQL Agent helper is an SDK-side tool loop that orchestrates an LLM to generate and execute SQL queries safely. It combines schema inspection tools, query validation, and execution into a single client-side loop (no server-side agent node).
How it works
The SQL tool loop provides the LLM with four tools:
| Tool | Description |
|---|---|
list_tables |
List available tables in the database |
describe_table |
Get column names, types, and nullability for a table |
sample_rows |
Return a small sample of rows (optional) |
execute_sql |
Execute a validated SQL query |
The workflow enforces safe SQL generation:
- Schema inspection — The LLM must call
list_tablesanddescribe_tablebefore executing queries - Query validation — All queries pass through
/sql/validateto ensure they match your policy - Read-only enforcement — Only SELECT queries are allowed; mutations are rejected
- Attempt limits — Configurable max attempts prevent runaway retries
sequenceDiagram
participant App as Your App
participant SDK as SDK Tool Loop
participant LLM as LLM
participant API as /sql/validate
participant DB as Your Database
App->>SDK: prompt + handlers
SDK->>LLM: prompt + tool definitions
Note over LLM: Schema inspection
LLM->>SDK: list_tables
SDK->>DB: handler.listTables
DB-->>SDK: table names
SDK-->>LLM: tables
LLM->>SDK: describe_table
SDK->>DB: handler.describeTable
DB-->>SDK: columns
SDK-->>LLM: schema
Note over LLM: Query generation
LLM->>SDK: execute_sql with query
SDK->>API: validate query
API-->>SDK: normalized SQL + tables
alt validation passes
SDK->>DB: handler.executeSQL
DB-->>SDK: rows
SDK-->>LLM: results
LLM-->>SDK: summary
SDK-->>App: result with summary, SQL, rows
else validation fails
SDK-->>LLM: error message
Note over LLM: Retry with corrected query
end
Quickstart
import { ModelRelay } from "@modelrelay/sdk";
const mr = new ModelRelay({
apiKey: process.env.MODELRELAY_API_KEY,
});
const result = await mr.sqlAgentQuickstart("claude-sonnet-4-5", {
prompt: "What are the top 10 customers by total order value?",
profileId: "your-sql-profile-id",
handlers: {
listTables: async () => {
const { rows } = await db.query(`
SELECT table_name as name FROM information_schema.tables
WHERE table_schema = 'public'
`);
return rows;
},
describeTable: async ({ table }) => {
const { rows } = await db.query(`
SELECT column_name as name, data_type as type
FROM information_schema.columns WHERE table_name = $1
`, [table]);
return { table, columns: rows };
},
executeSQL: async ({ query, limit }) => {
const { rows } = await db.query(query, { limit });
return { columns: Object.keys(rows[0] ?? {}), rows };
},
},
});
console.log(result.summary); // LLM's summary of the results
console.log(result.sql); // The executed SQL query
console.log(result.rows); // Query results
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/google/uuid"
sdk "github.com/modelrelay/sdk-go"
)
func main() {
client, err := sdk.NewClientWithKey(
sdk.MustParseAPIKey(os.Getenv("MODELRELAY_API_KEY")),
)
if err != nil {
log.Fatal(err)
}
profileID := uuid.MustParse("your-sql-profile-id")
handlers := sdk.SQLToolLoopHandlers{
ListTables: func(ctx context.Context) ([]sdk.SQLTableInfo, error) {
rows, err := db.QueryContext(ctx, `
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public'`)
if err != nil {
return nil, err
}
defer rows.Close()
var tables []sdk.SQLTableInfo
for rows.Next() {
var name string
rows.Scan(&name)
tables = append(tables, sdk.SQLTableInfo{Name: name})
}
return tables, nil
},
DescribeTable: func(ctx context.Context, args sdk.SQLDescribeTableArgs) (*sdk.SQLTableDescription, error) {
rows, err := db.QueryContext(ctx, `
SELECT column_name, data_type FROM information_schema.columns
WHERE table_name = $1`, args.Table)
if err != nil {
return nil, err
}
defer rows.Close()
var cols []sdk.SQLColumnInfo
for rows.Next() {
var name, typ string
rows.Scan(&name, &typ)
cols = append(cols, sdk.SQLColumnInfo{Name: name, Type: typ})
}
return &sdk.SQLTableDescription{Table: args.Table, Columns: cols}, nil
},
ExecuteSQL: func(ctx context.Context, args sdk.SQLExecuteArgs) (*sdk.SQLExecuteResult, error) {
rows, err := db.Query(ctx, args.Query, args.Limit)
if err != nil {
return nil, err
}
return &sdk.SQLExecuteResult{Columns: rows.Columns(), Rows: rows.ToMaps()}, nil
},
}
result, err := client.SQLToolLoopQuickstart(
context.Background(),
"claude-sonnet-4-5",
"What are the top 10 customers by total order value?",
handlers,
nil, // policy (nil when using profileID)
&profileID,
)
if err != nil {
log.Fatal(err)
}
fmt.Println("Summary:", result.Summary)
fmt.Println("SQL:", result.SQL)
fmt.Printf("Rows: %d\n", len(result.Rows))
}
use modelrelay::{ApiKey, Client, Config, SqlToolLoopHandlers};
use std::env;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let client = Client::new(Config {
api_key: Some(ApiKey::parse(&env::var("MODELRELAY_API_KEY")?)?),
..Default::default()
})?;
let db = db.clone(); // Your database connection pool
let handlers = SqlToolLoopHandlers::new(
// list_tables - query information_schema
|db| async move {
let rows = db.query(
"SELECT table_name as name FROM information_schema.tables
WHERE table_schema = 'public'", &[]).await?;
Ok(rows.into_iter().map(|r| r.into()).collect())
},
// describe_table - query column metadata
|db, args| async move {
let rows = db.query(
"SELECT column_name as name, data_type as column_type
FROM information_schema.columns WHERE table_name = $1",
&[&args.table]).await?;
Ok(SqlTableDescription { table: args.table, columns: rows.into_iter().map(|r| r.into()).collect() })
},
// execute_sql
|db, args| async move {
let rows = db.query(&args.query, &[]).await?;
Ok(SqlExecuteResult { columns: rows.columns(), rows: rows.to_maps() })
},
);
let profile_id = uuid::Uuid::parse_str("your-sql-profile-id")?;
let result = client
.sql_tool_loop_quickstart(
"claude-sonnet-4-5",
"What are the top 10 customers by total order value?",
handlers,
None, // policy
Some(profile_id),
)
.await?;
println!("Summary: {}", result.summary);
println!("SQL: {}", result.sql);
println!("Rows: {}", result.rows.len());
Ok(())
}
import ModelRelay
let client = try ModelRelayClient.fromAPIKey(ProcessInfo.processInfo.environment["MODELRELAY_API_KEY"] ?? "")
let handlers = SQLToolLoopHandlers(
listTables: {
// Query information_schema for table names
let rows = try await db.query("""
SELECT table_name as name FROM information_schema.tables
WHERE table_schema = 'public'
""")
return rows.map { SQLTableInfo(name: $0["name"] as! String) }
},
describeTable: { args in
// Query information_schema for column metadata
let rows = try await db.query("""
SELECT column_name as name, data_type as type
FROM information_schema.columns WHERE table_name = $1
""", [args.table])
return SQLTableDescription(
table: args.table,
columns: rows.map { SQLColumnInfo(name: $0["name"] as! String, type: $0["type"] as! String) }
)
},
executeSQL: { args in
let rows = try await db.query(args.query, limit: args.limit)
return SQLExecuteResult(columns: rows.columns, rows: rows.rows)
}
)
let result = try await client.sqlToolLoop(
model: "claude-sonnet-4-5",
prompt: "What are the top 10 customers by total order value?",
handlers: handlers,
profileId: "your-sql-profile-id"
)
print(result.summary)
Configuration options
For more control, use the full configuration APIs:
| Option | Type | Default | Description |
|---|---|---|---|
model |
string | required | LLM model to use for query generation |
prompt |
string | required | Natural language question about your data |
profileId |
string | — | SQL validation profile ID (required if no policy) |
policy |
object | — | Inline SQL policy (required if no profileId) |
maxAttempts |
number | 3 | Maximum SQL execution attempts |
resultLimit |
number | 100 | Maximum rows to return (capped at 1000) |
sampleRows |
boolean | auto | Enable sample_rows tool (auto-enabled only when a handler is provided) |
sampleRowsLimit |
number | 3 | Rows returned by sample_rows (capped at 10) |
requireSchemaInspection |
boolean | true | Require list_tables and describe_table before execute_sql |
system |
string | — | Additional system prompt instructions |
Using inline policies
Instead of a saved profile, you can provide an inline policy:
const result = await mr.sqlAgentQuickstart("claude-sonnet-4-5", {
prompt: "How many orders were placed last month?",
policy: {
dialect: "postgres",
read_only: true,
tables: { allowlist: ["orders", "customers"] },
limits: { default_limit: 100, max_limit: 1000 },
},
handlers: {
// ... handler implementations
},
});
policy := &sdk.SQLPolicy{
Dialect: "postgres",
ReadOnly: true,
Tables: &sdk.SQLPolicyTables{Allowlist: []string{"orders", "customers"}},
Limits: &sdk.SQLPolicyLimits{DefaultLimit: 100, MaxLimit: 1000},
}
result, err := client.SQLToolLoopQuickstart(
ctx,
"claude-sonnet-4-5",
"How many orders were placed last month?",
handlers,
policy,
nil, // profileID
)
use modelrelay::generated::{SqlPolicy, SqlPolicyLimits, SqlPolicyTables};
let policy = SqlPolicy {
dialect: "postgres".into(),
read_only: true,
tables: Some(SqlPolicyTables {
allowlist: Some(vec!["orders".into(), "customers".into()]),
denylist: None,
}),
limits: Some(SqlPolicyLimits {
default_limit: Some(100),
max_limit: Some(1000),
timeout_ms: None,
}),
..Default::default()
};
let result = client
.sql_tool_loop_quickstart(
"claude-sonnet-4-5",
"How many orders were placed last month?",
handlers,
Some(policy),
None, // profile_id
)
.await?;
Full configuration
For advanced use cases, use the full configuration API:
import { runSqlToolLoop } from "@modelrelay/sdk";
const result = await runSqlToolLoop({
responses: mr.responses,
sql: mr.sql,
model: "claude-sonnet-4-5",
prompt: "Analyze monthly revenue trends",
profileId: "your-profile-id",
maxAttempts: 5,
resultLimit: 500,
sampleRows: true,
sampleRowsLimit: 5,
requireSchemaInspection: true,
system: "Focus on year-over-year comparisons when analyzing trends.",
handlers: {
listTables: async () => [...],
describeTable: async ({ table }) => {...},
executeSQL: async ({ query, limit }) => {...},
sampleRows: async ({ table, limit }) => {...},
},
});
sampleRows := true
result, err := client.SQLToolLoop(ctx,
sdk.SQLToolLoopOptions{
Prompt: "Analyze monthly revenue trends",
Model: "claude-sonnet-4-5",
ProfileID: &profileID,
MaxAttempts: 5,
ResultLimit: 500,
SampleRows: &sampleRows,
SampleRowsLimit: 5,
RequireSchemaInspection: boolPtr(true),
System: "Focus on year-over-year comparisons.",
},
sdk.SQLToolLoopHandlers{
ListTables: listTablesHandler,
DescribeTable: describeTableHandler,
ExecuteSQL: executeSQLHandler,
SampleRows: sampleRowsHandler,
},
)
use modelrelay::sql_tool_loop::SqlToolLoopOptions;
let handlers = SqlToolLoopHandlers::new_sync(list_tables, describe_table, execute_sql)
.with_sample_rows_sync(sample_rows_handler);
let opts = SqlToolLoopOptions::new("claude-sonnet-4-5", "Analyze monthly revenue trends")
.with_profile_id(profile_id)
.with_max_attempts(5)
.with_result_limit(500)
.with_sample_rows(true)
.with_sample_rows_limit(5)
.with_require_schema_inspection(true)
.with_system("Focus on year-over-year comparisons.");
let result = client.sql_tool_loop(opts, handlers).await?;
Result structure
The tool loop returns a result object with:
| Field | Type | Description |
|---|---|---|
summary |
string | LLM’s natural language summary of the results |
sql |
string | The final SQL query that was executed |
columns |
string[] | Column names in the result set |
rows |
object[] | Query result rows as key-value objects |
usage |
object | Token usage statistics |
attempts |
number | Number of execute_sql calls made |
notes |
string | Additional notes (e.g., “query returned no rows”) |
Schema inspection requirement
By default, requireSchemaInspection is true, which means:
- The LLM must call
list_tablesbeforeexecute_sql - The LLM must call
describe_tablefor every table referenced in the query
This prevents the LLM from guessing table/column names and ensures queries are grounded in actual schema. Set requireSchemaInspection: false if your use case has a well-known schema that the LLM can assume.
SQL validation
All queries pass through the /sql/validate API before execution. This:
- Parses the SQL and extracts referenced tables
- Validates against your policy (allowed tables, operations, etc.)
- Rejects non-SELECT queries
- Returns a normalized version of the query
If validation fails, the error is returned to the LLM so it can retry with a corrected query.
Error handling
The tool loop surfaces errors to the LLM for self-correction:
- Validation errors — Query doesn’t match policy, LLM can retry
- Schema errors — Table doesn’t exist, LLM should re-inspect schema
- Execution errors — Database error, LLM can adjust query
After maxAttempts execution failures, the loop terminates and returns what it has.