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.

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>Receives model configuration and engine fields except type.
languageReadonly propertyQueryLanguage, used by analysis prompts.
load(config)DataSourceConfigPromise<void>; prepare data and connections for execution.
schema()NonePromise<Schema>; discover the loaded source structure.
profile(schema, options?)Required Schema, optional ProfileOptionsOptional method returning Promise<Profile>; compute against the supplied structure.
getDSL(query, dataModel)Required query string and DataModelPromise<TranslationResult> with dsl and optional usage.
execute(dsl, options?)Required DSL string; optional ExecutionOptionsPromise<ExecutionResult<T>>; enforce validation and result bounds.
dispose()NonePromise<void>; release connections, files, and other resources.

AVA owns schema/profile caching. load() prepares resources; schema() discovers structure on demand. execute() must work without prior discovery. Profiling uses the supplied schema; query generation uses the supplied data model. Discovery failure leaves the engine available for retry or queries.

To migrate an older engine, move discovery out of load() into schema(), change load() to return Promise<void>, add the required schema/data-model parameters, and return { dsl, usage? } from getDSL(). Keep dispose() safe after partial loading.

QueryLanguage fieldTypeRequiredMeaning
namestringYesLanguage or dialect name included in prompts.
fencestringYesMarkdown code-fence language, such as sql or python.
instructionsstringNoAvailable bindings and result conventions.

DataModel combines structural metadata, optional statistics, and semantic annotations. AVA assembles it from cached Schema, Profile, and Semantic metadata when analysis, translation, or suggestions need it. Engines receive the model directly, without a context wrapper.

DataModel fieldTypeRequiredMeaning
modelsModel[]NoModels and columns, including optional metrics and properties.
relationshipsRelationship[]NoJoins between models.
viewsView[]NoNamed queries.
propertiesPropertiesNoDataset annotations.

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.

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,
  TranslationResult,
  DataModel,
  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<void> {
    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 };
    });
  }
 
  async schema(): Promise<Schema> {
    if (!this.sales) throw new Error('Load a source first.');
    return {
      models: [
        {
          name: 'sales',
          tableReference: { table: 'sales' },
          columns: [
            { name: 'region', type: 'string' },
            { name: 'revenue', type: 'number' },
          ],
        },
      ],
    };
  }
 
  async getDSL(query: string, _dataModel: DataModel): Promise<TranslationResult> {
    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,
    });
    return { dsl: text.trim(), usage };
  }
 
  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; see its parameters below.

This engine has a fixed command vocabulary, so its getDSL() accepts but does not use the supplied data model. 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();
}

The example omits profiling and disables analysis retries and summaries. answer.sql contains the JSON command, answer.data contains the computed rows, and answer.text is empty. Use query() when the required command is already known.

Extension reference

registerEngine

registerEngine(type: string, engineClass: EngineClass): void

Exported by both SDK entry points. Register an engine implemented in your own application before loading a source.

ParameterTypeRequiredMeaning
typestringYesEngine name. Re-registering replaces the constructor used for future loads.
engineClassnew (llmConfig: LLMConfig, options?: Record<string, unknown>) => AnalysisEngineYesEngine class. AVA passes its model configuration and remaining engine options to each new instance.

Registration affects the loaded SDK module only.

executionResult helper

executionResult<T>(rows: T[], schema: QueryColumn[], options?: ExecutionOptions, upstreamTruncatedBy?: 'maxRows' | 'maxResultBytes'): ExecutionResult<T>

A synchronous helper exported by the Node.js SDK for custom engine implementations. Import it from @antv/ava.

ParameterTypeRequiredMeaning / default
rowsT[]YesRows produced by the engine.
schemaQueryColumn[]YesOutput columns; each has name: string and optional type: string.
optionsExecutionOptionsNo{}; uses the execution limits.
upstreamTruncatedBy'maxRows' or 'maxResultBytes'NoPreserve truncation already reported by an upstream executor unless a local limit truncates further.

Returns bounded data, schema, and either rowCount for a complete result or truncation metadata for a partial result. Throws if an individual result field exceeds 1 MiB. The helper bounds already-produced rows; it does not limit query execution or input memory use.