Docs

Controllers

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

Controllers extend the orchestrator with domain-specific tools and lifecycle hooks. Objects registered via withTools() rely on vendor-specific annotations such as LangChain4j’s or Spring AI’s @Tool. A controller, by contrast, contributes tools through the framework-agnostic AIController interface. The orchestrator collects the controller’s tools before every LLM request and notifies the controller once the request cycle completes.

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, GridAIController.getSystemPrompt())
        .withMessageList(messageList)
        .withInput(messageInput)
        .withController(controller)
        .build();

Only one controller can be attached per orchestrator. Controllers and tool objects registered via withTools() can be combined — the orchestrator merges their tool lists before each request.

The AIController Interface

AIController defines two methods, both with default implementations:

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

  • onRequestCompleted() — 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.

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(String arguments) {
                // Parse arguments as JSON and call your service
                return weatherService.lookup(arguments);
            }
        });
    }
}
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.

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 execute read-only SQL queries on demand. Any controller that needs database access can reuse DatabaseProviderAITools to register the shared get_database_schema tool.

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.

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);
        }
    }
}
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.
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.

Updated