Skip to content
Browse documentation
Execution engines

Custom engines

Implement an engine in your application and register it with the SDK.

Implement an engine in your own application or package, register it with the SDK, then select it by name. Custom engine registration is available in the SDK only; the CLI accepts built-in engine names.

Register an engine

typescript
import { AVA, registerEngine } from '@antv/ava';
import { SalesEngine } from './sales-engine';
 
registerEngine('sales', SalesEngine);
 
const ava = new AVA({
  llm: { model },
  engine: { type: 'sales' },
});

Here model is a configured AI SDK model; the complete example below shows its setup. The implementation stays in your application. Register the engine before calling source(); no changes to AVA's source are needed.

InputTypeRequiredBehavior
registerEngine(type, engineClass) → typestringYesName used to select the engine. Registering the same name again replaces its constructor for future loads.
registerEngine(type, engineClass) → engineClassEngineClassYesClass implementing AnalysisEngine; constructor receives llmConfig and optional engine options.
engine.typestringYesA built-in or registered engine name, such as 'sales'.
Other engine fieldsEngine-specificDepends on enginePassed as options to the constructor; the engine validates them.

Registration returns no value and applies to the loaded SDK module in the current process. It does not register an engine in a separate CLI process. An unregistered name fails when source() tries to create the engine.

AVA creates a new instance on each source() call. It disposes the previous engine before replacement, disposes an engine whose load() fails, and releases the active engine on ava.dispose(). Open connections in load() so failed loads can be cleaned up.

The browser SDK exports the same registerEngine API from @antv/ava/browser; the supplied engine and its dependencies must support the browser runtime. Use a build that exports this API; older package versions with an internal-only registry need to be upgraded.

Engine contract

Implement the exported AnalysisEngine interface. AVA's lifecycle coordinator calls these members:

MemberInput parametersReturns / behavior
ConstructorllmConfig: LLMConfig, options?: Record<string, unknown>AVA passes its model configuration and all engine fields except type.
languageNone; readonly propertyQueryLanguage, used by analysis prompts.
load(config)Required DataSourceConfig; see source parameters.Promise<Schema>; load data and expose tables and metadata.
profile(options?)Optional ProfileOptions; see profiling parameters.Optional method returning Promise<Profile>.
getDSL(query, context?)Required natural-language query: string; optional DataContext.Promise<string> in the engine's language.
execute(dsl, options?)Required dsl: string; optional ExecutionOptions.Promise<ExecutionResult<T>>; enforce validation and result bounds.
dispose()No parameters.Promise<void>; release connections, files, and other resources.
QueryLanguage fieldTypeRequiredMeaning
namestringYesLanguage or dialect name included in prompts.
fencestringYesMarkdown code-fence language, such as sql or python.
instructionsstringNoAvailable bindings and result conventions.
DataContext fieldTypeRequiredMeaning
schemaSchemaYesTables, fields, indexes, and relationships.
profileProfileNoCached statistics, when available.

Example: a sales metrics engine

Suppose your application exposes two sales reports: total revenue and revenue by region. This engine translates a question into a small JSON command and calculates the report in memory. It does not execute model-generated JavaScript or SQL.

The example reuses the existing inline JSON source shape. For a remote engine, pass connection settings as engine options and let load() interpret a supported DataSourceConfig shape.

InputTypeRequiredSupported values / behavior
SalesEngine(llmConfig)LLMConfig with a model objectYesConfigured model used to generate metric commands.
source.type'json'YesOnly inline arrays are supported in this example.
source.options.dataSale[]YesRows with the two fields below; an empty array is allowed.
data[].regionstringYesRegion to group by.
data[].revenuenumberYesFinite revenue value.
Command metric'total_revenue' or 'revenue_by_region'YesThe only allowed command key; other operations are rejected.

Implement the engine

Create sales-engine.ts in your application. This example uses ai for model calls and only public exports from @antv/ava.

typescript
import { generateText, type LanguageModel } from 'ai';
import { executionResult } from '@antv/ava';
import type { AnalysisEngine, DataSourceConfig, ExecutionOptions, ExecutionResult, LLMConfig, Schema } from '@antv/ava';
 
type Sale = { region: string; revenue: number };
 
export class SalesEngine implements AnalysisEngine {
  readonly language = {
    name: 'Sales metrics JSON',
    fence: 'json',
    instructions:
      'Return exactly {"metric":"total_revenue"} or {"metric":"revenue_by_region"}. No other keys or operations are supported.',
  };
  private sales: Sale[] | null = null;
 
  private readonly model: LanguageModel;
 
  constructor(private readonly llm: LLMConfig) {
    if (typeof llm.model === 'string') {
      throw new Error('SalesEngine expects a configured AI SDK model object.');
    }
    this.model = llm.model;
  }
 
  async load(config: DataSourceConfig): Promise<Schema> {
    await this.dispose();
    if (config.type !== 'json' || !Array.isArray(config.options.data)) {
      throw new Error('SalesEngine requires an inline JSON array.');
    }
    this.sales = config.options.data.map((row) => {
      if (
        !row ||
        typeof row !== 'object' ||
        typeof row.region !== 'string' ||
        typeof row.revenue !== 'number' ||
        !Number.isFinite(row.revenue)
      ) {
        throw new Error('Each sale requires a region string and finite revenue number.');
      }
      return { region: row.region, revenue: row.revenue };
    });
    return {
      tables: [
        {
          name: 'sales',
          columnCount: 2,
          indexes: [],
          fields: [
            { name: 'region', type: 'string' },
            { name: 'revenue', type: 'number' },
          ],
        },
      ],
    };
  }
 
  async getDSL(query: string): Promise<string> {
    if (!this.sales) throw new Error('Load a source first.');
    const { text, usage } = await generateText({
      model: this.model,
      maxRetries: this.llm.maxRetries ?? 3,
      system: `Convert a question into a sales metric. ${this.language.instructions}
Return raw JSON without Markdown. If neither metric answers the question, return {"metric":"unsupported"}.`,
      prompt: query,
    });
    this.llm.onQueryUsage?.(usage);
    return text.trim();
  }
 
  async execute<T = Record<string, unknown>>(dsl: string, options?: ExecutionOptions): Promise<ExecutionResult<T>> {
    if (!this.sales) throw new Error('Load a source first.');
    const command = JSON.parse(dsl);
    if (
      !command ||
      Array.isArray(command) ||
      Object.keys(command).length !== 1 ||
      !['total_revenue', 'revenue_by_region'].includes(command.metric)
    ) {
      throw new Error('Expected one supported metric and no additional keys.');
    }
 
    let rows: Record<string, unknown>[];
    if (command.metric === 'total_revenue') {
      rows = [{ total_revenue: this.sales.reduce((sum, row) => sum + row.revenue, 0) }];
    } else {
      const totals = new Map<string, number>();
      for (const row of this.sales) {
        totals.set(row.region, (totals.get(row.region) ?? 0) + row.revenue);
      }
      rows = Array.from(totals, ([region, total_revenue]) => ({ region, total_revenue }));
    }
    if (rows.some((row) => !Number.isFinite(row.total_revenue))) {
      throw new Error('Revenue total exceeds the numeric range.');
    }
    const schema =
      command.metric === 'total_revenue'
        ? [{ name: 'total_revenue', type: 'number' }]
        : [
            { name: 'region', type: 'string' },
            { name: 'total_revenue', type: 'number' },
          ];
    return executionResult(rows as T[], schema, options);
  }
 
  async dispose(): Promise<void> {
    this.sales = null;
  }
}

execute() validates commands even when called directly through query(). The exported executionResult(rows, schema, options?) helper applies AVA's row and byte limits; its parameters are also listed in the API reference.

This engine has a fixed schema, so getDSL() does not need the optional context argument. It omits profile(); analyze(), suggest(), and query() still work, but ava.profile() reports that profiling is unsupported. The sample holds all input rows in memory; result limits bound the returned data, not the input size or aggregation work.

Register and use it

The application needs @antv/ava, ai, and @ai-sdk/openai. Configure OPENAI_API_KEY and OPENAI_MODEL, then run this with your project's TypeScript runner. Register the class once during application startup, then select it by name. AVA passes the configured model to each new engine instance:

typescript
import { createOpenAI } from '@ai-sdk/openai';
import { AVA, registerEngine } from '@antv/ava';
import { SalesEngine } from './sales-engine';
 
registerEngine('sales', SalesEngine);
 
const model = createOpenAI({
  apiKey: process.env.OPENAI_API_KEY,
  baseURL: process.env.OPENAI_BASE_URL,
}).chat(process.env.OPENAI_MODEL || 'gpt-4o-mini');
 
const ava = new AVA({
  llm: { model },
  engine: { type: 'sales' },
});
 
try {
  await ava.source({
    type: 'json',
    options: {
      data: [
        { region: 'East', revenue: 120 },
        { region: 'West', revenue: 90 },
        { region: 'East', revenue: 80 },
      ],
    },
  });
 
  const answer = await ava.analyze('What is total revenue by region?', {
    strategy: { type: 'direct', maxRetries: 0 },
    includeSummary: false,
  });
  console.log(answer.sql); // Expected command: {"metric":"revenue_by_region"}
  console.table(answer.data);
 
  // Execute a known command directly, without calling the model.
  const total = await ava.query('{"metric":"total_revenue"}');
  console.table(total.data); // [{ total_revenue: 290 }]
} finally {
  await ava.dispose();
}

When the model selects revenue_by_region, answer.data is:

json
[
  { "region": "East", "total_revenue": 200 },
  { "region": "West", "total_revenue": 90 }
]

answer.sql contains the JSON command despite the field name. answer.text is empty because the example disables summarization. Running revenue_by_region with maxRows: 1 through analyze() or query() returns only the first region and marks the result with truncated: true and truncatedBy: 'maxRows'.

For an unsupported question, the model is instructed to return an unsupported metric, which execute() rejects. This example disables correction retries so the error surfaces immediately. Model interpretation can still be wrong; use query() with a known command when your interface already determines the required report.