Docs

Controllers

Extend the AIOrchestrator with reusable, framework-agnostic tools and lifecycle hooks using the AIController interface.

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 a lifecycle hook that fires after the LLM finishes each 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 two built-in controllers:

  • GridAIController — populates a Grid from a database using natural-language requests.

  • ChartAIController — creates and updates Chart visualizations from a database using natural-language requests.

Both rely on a DatabaseProvider to expose schema information and execute queries on behalf of the LLM.

Attaching a Controller

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

Source code
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 merges their tool lists before each request.

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 only — they 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.

The following example is a straightforward JDBC implementation:

Source code
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) {
            throw new IllegalArgumentException("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.

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 only the schema, never the query results. Every row returned by executeQuery() is rendered in the grid or chart component and discarded from the request cycle. This boundary is unconditional: sensitive row values cannot leak into a follow-up prompt, into the conversation history, or to the LLM provider.
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.

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 two 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.

  • onResponseComplete() — runs after all tool calls for a user request have finished and the LLM has produced its final response. Controllers use this hook to apply deferred state changes, avoiding partial state and multiple redraws during a multi-tool turn.

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.

A minimal custom controller looks like this:

Source code
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 onResponseComplete() {
        // 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 are validated at build time; invalid names cause an IllegalArgumentException. 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.

Updated