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:

  1. Schema inspection — The LLM must call list_tables and describe_table before executing queries
  2. Query validation — All queries pass through /sql/validate to ensure they match your policy
  3. Read-only enforcement — Only SELECT queries are allowed; mutations are rejected
  4. 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:

  1. The LLM must call list_tables before execute_sql
  2. The LLM must call describe_table for 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.

Next steps

  • Workflows — Combine SQL agents with other workflow nodes
  • Agents — Build reusable agent resources