> Markdown version of [Controllers](https://vaadin.com/docs/next/flow/ai-support/controllers). Section index: [llms.txt](https://vaadin.com/docs/next/flow/llms.txt)

# Controllers (since V25.2)

Controllers expose your application’s capabilities to the LLM as callable tools — a customer database, an inventory lookup, a weather API, a form-filling routine — and give you lifecycle hooks that fire before and after each LLM turn. Use a controller when you want reusable tools that don’t depend on a specific AI framework’s annotations, when you need to defer UI updates until after a multi-step AI response, or when you want to package an AI capability once and share it across applications.

If you’ve registered tool objects via `withTools()` elsewhere, controllers are the framework-agnostic equivalent: the same tool calling, but defined through the `AIController` interface instead of LangChain4j’s or Spring AI’s `@Tool` annotations.

Vaadin provides three built-in controllers:

- [`GridAIController`](https://vaadin.com/docs/next/flow/ai-support/ai-powered-grid.md) — populates a `Grid` from a database using natural-language requests.

- [`ChartAIController`](https://vaadin.com/docs/next/flow/ai-support/ai-powered-chart.md) — creates and updates `Chart` visualizations from a database using natural-language requests.

- [`FormAIController`](https://vaadin.com/docs/next/flow/ai-support/ai-powered-form.md) — fills any layout containing input fields, from natural-language input or attached files.

The grid and chart controllers rely on a [`DatabaseProvider`](#database-provider) to expose schema information and execute queries on behalf of the LLM. The form controller works directly with the form’s field components.

> **Note: Built-In Controllers Are a Commercial Feature**
>
> The built-in controllers are part of the commercial `vaadin-ai-extensions-flow` module and require a commercial Vaadin subscription. The `AIController` interface and `DatabaseProvider` belong to the open-source `vaadin-ai-core-flow` module, so building and using [custom controllers](#custom-controller) is free.

## <a id="attaching-a-controller"></a>Attaching a Controller

Pass a controller to the orchestrator’s builder with `withController()`:

```java
var orchestrator = AIOrchestrator
        .builder(provider, systemPrompt)
        .withMessageList(messageList)
        .withInput(messageInput)
        .withController(controller)
        .build();
```

Only one controller can be attached per orchestrator. If you need tools from several sources, compose them into one controller that delegates, or combine a controller with tool objects registered via `withTools()` — the orchestrator collects the controller’s tools before each request and passes both to the provider, which offers them to the LLM as one tool list.

## <a id="database-provider"></a>Database Provider

`DatabaseProvider` is the bridge between the LLM and an application database. The built-in grid and chart controllers use it to let the LLM discover the schema and run SQL queries on demand. Critically, the data flow is asymmetric: the LLM sees the schema so it can write valid queries, but the query results are rendered in the `Grid` or `Chart` component — the rows themselves are never sent back to the LLM.

The interface defines two methods:

- `getSchema()` — returns a plain-text description of the tables, columns, and SQL dialect. The LLM uses this to write valid queries.

- `executeQuery(String sql)` — executes a SQL query and returns the rows as a list of column-name-to-value maps. These rows are handed to the grid or chart for rendering; they do not appear in any prompt as such.

The following example is a straightforward JDBC implementation:

```java
public class JdbcDatabaseProvider implements DatabaseProvider {

    private final DataSource readOnlyDataSource;

    public JdbcDatabaseProvider(DataSource readOnlyDataSource) {
        this.readOnlyDataSource = readOnlyDataSource;
    }

    @Override
    public String getSchema() {
        return """
                Tables:
                employees(id INT, name VARCHAR, department VARCHAR, salary NUMERIC, hired_on DATE)
                departments(id INT, name VARCHAR)
                Dialect: PostgreSQL.
                """;
    }

    @Override
    public List<Map<String, Object>> executeQuery(String sql) {
        try (var conn = readOnlyDataSource.getConnection();
                var stmt = conn.prepareStatement(sql);
                var rs = stmt.executeQuery()) {
            var meta = rs.getMetaData();
            var rows = new ArrayList<Map<String, Object>>();
            while (rs.next()) {
                var row = new LinkedHashMap<String, Object>();
                for (int i = 1; i <= meta.getColumnCount(); i++) {
                    row.put(meta.getColumnLabel(i), rs.getObject(i));
                }
                rows.add(row);
            }
            return rows;
        } catch (SQLException e) {
            // ToolException relays the message to the LLM so it can
            // correct its query. Check what your database includes in
            // its error messages before relaying them like this.
            throw new ToolException("Query failed: " + e.getMessage(), e);
        }
    }
}
```

The example returns a hardcoded string for clarity, but `getSchema()` can return whatever helps the LLM. Build the description at runtime from `java.sql.DatabaseMetaData` if your schema changes often, and add free-form context — column meanings, business rules, common joins, units, sample values — in plain English. The LLM treats the entire string as guidance, so anything that improves its queries is fair game.

When a query fails — for example, because it references an unknown column — the exception type determines what the LLM learns about the failure. The example above throws a `ToolException` (since V25.3), whose message is relayed to the LLM so it can correct the query on its next attempt; any other exception is replaced with a generic error message. See [Tool Error Handling](#tool-error-handling).

> **Important: Read-Only Database Access**
>
> The LLM writes the SQL that gets executed. Always back a `DatabaseProvider` implementation with a database account that has read-only access to the tables and views you intend to expose. This prevents the LLM from modifying or deleting data and limits the impact of a prompt-injection attempt that tries to trick the LLM into running destructive statements.

> **Important: Query Results Stay in the Application**
>
> The LLM receives the schema, never the query results as rows. Every row returned by `executeQuery()` is rendered in the grid or chart component and discarded from the request cycle, so row values don’t enter a follow-up prompt or the conversation history. The grid’s state tool returns only the current SQL query to the LLM. The chart’s state tool returns the SQL queries and only the configuration the LLM has set itself, not the series, x-axis categories, or other values the chart builds from the rows. The schema description that `getSchema()` returns and the message of a `ToolException` thrown from `executeQuery()` reach the LLM as is, so keep row values out of both.

> **Tip: Schema Scope**
>
> Return only the tables, columns, and relationships the LLM needs. A smaller, well-described schema produces better queries, uses fewer tokens, and reduces the chance of leaking sensitive columns.

## <a id="custom-controller"></a>Building a Custom Controller

The built-in controllers cover grid and chart data exploration. To expose your own capabilities to the LLM, implement `AIController` directly.

`AIController` defines three methods:

- `getTools()` — returns the list of `LLMProvider.ToolSpec` instances the controller contributes to each LLM request. Tools are collected before every request, so a controller can vary its tool set based on current state.

- `onRequest(RequestListener.RequestEvent)` — runs on the UI thread right before the LLM stream opens. Use it to lock UI surfaces, snapshot state the tool definitions depend on, or otherwise prepare for the turn. Since tools may execute on a background thread, this is also the place to capture anything that depends on Vaadin thread locals, such as `UI.getCurrent()`. The event (since undefined) is the same one the [request listener](https://vaadin.com/docs/next/flow/ai-support/listeners.md#request-listener) receives: the user message, the id assigned to it, and its attachments. Use it, for example, to key per-turn state by message id so `onResponse()` can find it, or to pick which tools apply based on the attachments.

- `onResponse(ResponseListener.ResponseEvent)` — called through `ui.access()` when the turn ends: once the LLM stream has completed, either successfully (`event.getError()` is empty) or with an error, but also when the turn fails before the stream opens, possibly without a preceding `onRequest()` call. It doesn’t fire for a prompt the [request interceptor](https://vaadin.com/docs/next/flow/ai-support/request-interception.md) rejected. Since it runs through `ui.access()`, it can safely update components. Controllers use this hook to commit deferred state changes on success and release any per-turn state captured in `onRequest()` on failure. The event also carries the provider’s response metadata (since undefined), which tells a completed turn from one truncated at the model’s output limit — a truncated turn ends without an error, so a controller that commits staged state should check it. See [Response Metadata](https://vaadin.com/docs/next/flow/ai-support/listeners.md#response-metadata).

Each tool is an implementation of `LLMProvider.ToolSpec`, which has four methods:

- `getName()` — the unique name the LLM uses to invoke the tool.

- `getDescription()` — a human-readable description shown to the LLM.

- `getParametersSchema()` — a JSON Schema string describing the tool’s parameters, or `null` for tools that take no parameters.

- `execute(JsonNode arguments)` — receives the arguments passed by the LLM as a `JsonNode` (from `tools.jackson.databind`) and returns the tool’s result as a string. To report a failure in terms the LLM is allowed to see, throw a `ToolException` — see [Tool Error Handling](#tool-error-handling).

A minimal custom controller looks like this:

```java
public class WeatherController implements AIController {

    @Override
    public List<LLMProvider.ToolSpec> getTools() {
        return List.of(new LLMProvider.ToolSpec() {
            @Override
            public String getName() {
                return "get_weather";
            }

            @Override
            public String getDescription() {
                return "Returns the current weather for a city.";
            }

            @Override
            public String getParametersSchema() {
                return """
                        {
                          "type": "object",
                          "properties": {
                            "city": { "type": "string" }
                          },
                          "required": ["city"]
                        }""";
            }

            @Override
            public String execute(JsonNode arguments) {
                String city = arguments.get("city").asString();
                return weatherService.lookup(city);
            }
        });
    }

    @Override
    public void onResponse(ResponseListener.ResponseEvent event) {
        if (event.getError().isPresent()) {
            return;
        }
        // Apply any deferred state changes here.
    }
}
```

Tool names must match the pattern `^[a-zA-Z0-9_-]{1,64}$`, as required by popular LLM APIs. Names and parameter schemas are validated when the controller is registered with `withController()`, on the builder and on the reconnector alike. An invalid name, or a schema that isn’t a JSON object, causes an `IllegalArgumentException` that names the tool. Use a prefixed name such as `"MyController_getWeather"` to avoid collisions with tools from other controllers.

> **Tip: Generating JSON Schemas**
>
> Hand-writing JSON schemas is fine for small tools but becomes error-prone as they grow. For larger tools, use a schema generator to catch typos and structural mistakes at compile time. When using `SpringAILLMProvider`, Spring AI’s `JsonSchemaGenerator` is already on the classpath — pair it with a record annotated with `@JsonPropertyDescription` and parse the arguments via Jackson. Whichever approach you pick, verify the output stays inside the portable JSON Schema subset (string, integer, number, boolean, array, object, plus `anyOf` and `enum`); some generators emit keywords such as `format`, `pattern`, or `$ref` that not every LLM provider accepts.

> **Note: Controller Serialization**
>
> Controllers are not serialized with the orchestrator. After session restore, pass the controller to `reconnect(provider).withController(controller).apply()` — see [Conversation History & Session Persistence](https://vaadin.com/docs/next/flow/ai-support/conversation-history.md).

## <a id="tool-error-handling"></a>Tool Error Handling (since V25.3)

When a tool call fails, the message the LLM receives determines whether it can recover. Given the actual reason, it can correct its next attempt; given a generic error, it tends to retry the same call unchanged.

By default, any exception thrown from tool code is caught, logged, and replaced with a generic error message before reaching the LLM, so internal details such as SQL fragments, schema names, or file paths don’t leak into the conversation. To let the LLM see why a call failed, throw a `ToolException` (from the `com.vaadin.flow.component.ai.provider` package) — its message is forwarded to the LLM unchanged, behind a short error prefix, as the tool’s error output:

```java
@Override
public String execute(JsonNode arguments) {
    String city = arguments.get("city").asString();
    if (!weatherService.isKnownCity(city)) {
        throw new ToolException("Unknown city: '" + city
                + "'. Pass the city name in English, e.g. 'Munich'.");
    }
    return weatherService.lookup(city);
}
```

`ToolException` works anywhere the framework executes tool code: a `ToolSpec.execute()` implementation or a [`DatabaseProvider`](#database-provider) alike. Because the message is passed to the LLM as-is, make sure it’s safe to expose: no personal data, no internal identifiers beyond what the LLM already sent, and no third-party error text you haven’t vetted. A database error that points out a mistake in an LLM-written query is usually reasonable to relay, but check first what your database actually puts in its error messages. A stack trace from your infrastructure is never safe to relay.

> **Note: Vendor-Annotated Tools Are Out of Scope**
>
> This contract applies only to framework-agnostic `ToolSpec` tools — those contributed by controllers and by `DatabaseProvider`-backed features. Tool objects registered via [`withTools()`](https://vaadin.com/docs/next/flow/ai-support/tool-calling.md) are executed by the vendor framework itself, whose own error handling decides what reaches the LLM: by default, both LangChain4j and Spring AI relay the raw message of any exception. Route error-sensitive tools through a controller to keep control over what the model sees.
