@formio/uag-sdk
Form.io UAG › Engines › this page
Build engine packages for the Form.io Universal Agent Gateway (UAG). This package contains:
- the engine contract's types, and
ENGINE_API_VERSION; defineEngine, to declare an engine type and its settings;EngineError, for messages that are safe to show to API callers;- the loader, which the UAG,
uag-evaland the engine server use to find, configure and create engines; - shared helpers for model APIs with structured outputs;
- a conformance check that runs from any test runner.
An engine package depends on this package only. It never needs @formio/uag.
npm install @formio/uag-sdk
The SDK requires Node.js 22 or later and has no dependencies. The full reference, with two complete example engines and their tests, is Engine SDK.
An engine package
Name the package @formio/uag-<type> (official engines) or uag-engine-<type> (anyone's). Export one engine type definition. It is a plugin of type "engine": name is the type name used in UAG_ENGINES, title (default: name) is shown to people, description is an optional summary for operators, and version defaults to your package's own version:
const { defineEngine, EngineError, ENGINE_API_VERSION, requiredSchema } = require('@formio/uag-sdk');
module.exports = defineEngine({
type: 'engine',
name: 'acme',
title: 'Acme',
kinds: ['agent'],
apiVersion: ENGINE_API_VERSION,
description: 'Acme models: structured extraction.',
settings: {
apiKey: { env: 'ACME_API_KEY', required: true, secret: true },
model: { env: 'ACME_MODEL', default: 'acme-large-2026-09-01' },
maxTokens: { env: 'ACME_MAX_TOKENS', type: 'number', default: 4000 },
},
create({ name, settings, options }) {
const client = options.client || new AcmeClient({ apiKey: settings.apiKey });
return {
name,
kind: 'agent',
apiVersion: ENGINE_API_VERSION,
model: settings.model,
async extract(request) {
request.signal?.throwIfAborted();
const { schema, decode } = requiredSchema(request.schema);
const answer = await client.complete({ schema, signal: request.signal /* ... */ });
if (answer.quotaExceeded) throw new EngineError('Quota exceeded for this API key.');
return { model: answer.model, values: decode(answer.json) };
},
};
},
});
The engine must use the instance name it is given, and one of the kinds it declares. A decision engine has decide and maxOptions; an agent engine has extract, and sets toolUse: true when extract accepts read-only research tools. See contract.d.ts for every request and response.
A definition in the shape of the 2.0 release candidates, with the engine's name in type ({ type: 'acme', kinds, ... }), is refused with a message showing the shape above.
Generating text over documents
An agent engine may also implement generate(request), which document plugins call to write free text over uploaded documents, and declare accepts, the document kinds it reads natively (default ['text']; list 'pdf' only when your model reads PDFs as documents):
accepts: ['text', 'pdf'],
async generate({ instructions, system, attachments, format, maxTokens, signal, onProgress }) {
// attachments: { kind: 'pdf', data: Buffer, label, pages? } or { kind: 'text', text, label, textFormat? }
// Put the documents before the instructions; report progress as the answer streams.
return { text, model, stopReason: 'end' /* or 'max_tokens' when cut off */, usage };
},
generate must honour signal, never log the documents, and report a truncated answer as stopReason: 'max_tokens' rather than failing.
create receives { name, settings, options, config, requireSetting }:
settings: the instance's value for each declared setting that has one.options: the instance's options as given in configuration, including ones that are not settings. Accept test doubles here (options.client, as in the example above), so tests, and the conformance check below, never call the real API.config: the host's configuration, read-only, for examplerouting.timeouts[name].requireSetting(key): a setting's value, or the standard "needs its setting" error, for a setting that is required only sometimes.
create may be async.
Settings
Each setting is resolved from the first of these that is set:
- the instance's options in configuration;
UAG_ENGINE_<NAME>_<SETTING>, from the instance name and the setting name (fastandapiKeygiveUAG_ENGINE_FAST_API_KEY);- the variables the setting lists in
env, in order; - its
default.
| Key | Meaning |
|---|---|
env | The type's own variables, as a name or a list. |
required | Startup fails without a value. The error names the instance, the type, the setting and every variable that sets it. |
secret | The value is kept out of startup errors and capability discovery. Errors an engine throws while it runs are logged as they are, so never put a setting's value in one. |
type | string (the default), number or boolean. Values from the environment are converted. |
default | Used when nothing else is set. |
description | Documentation for operators. |
Instances
Hosts enable engines as named instances of a type:
UAG_ENGINES=jev,fast:claude,deep:claude,x:@acme/uag-engine-x
- A bare entry (
jev) names an instance after its type. <name>:<type>names it yourself, so one type can run as several instances with different settings.- A type starting with
@oruag-engine-, or containing/, is a package name. A bare package entry takes the package's type name. - Names use lowercase letters, digits and
-, start with a letter, and are at most 63 characters. Anything else, including any path, is refused before anything is loaded.
In configuration, an entry is such a string, or { name, type, options }, where type may also be an engine type definition.
Loading
const { engineInstances } = require('@formio/uag-sdk');
const { enginePaths, loadEngines } = require('@formio/uag-sdk/loader');
const engines = await loadEngines(engineInstances(config, process.env), {
types: { remote: RemoteEngine }, // built-in and embedded types, tried first
paths: enginePaths(__dirname, process.env),
env: process.env,
config,
});
A type name is looked up in types, then as @formio/uag-<type>, then as uag-engine-<type>. Packages are looked up in the node_modules of the host's installation and of the directories above it (where Node finds the host's own dependencies), then in <dir>/node_modules of each absolute directory in UAG_ENGINE_PATH. Never in the working directory, NODE_PATH or Node's global folders, and a package whose entry point resolves outside its own directory is refused. Nothing is ever installed. Engine packages are loaded with require(): CommonJS, or ES modules on Node 22.12 or later. Each result is { engine, type, title, kinds, typeVersion, package, version } (typeVersion when the definition sets version; package and version when the type came from a package), which is what Formflow.engineRegistry takes.
planEngines(instances, options) takes the same arguments and does the same resolving and checking, but creates nothing: for each instance it returns the result's fields without engine, plus name, definition, args (what to pass to the definition's create) and failure(err), which words a failure to start as the loader does, with secret values left out. It is for a host that creates the engines itself. createEngine(definition, { name, options, env, config }) resolves the settings and creates one instance, as the UAG does.
Every definition is checked structurally, so a package built with another copy of this SDK, or with none, loads too. A package built for another engine contract version is refused, and the error names both versions.
Errors
Throw EngineError for a failure whose message the caller may see, such as "Quota exceeded for this API key." Any other error is reported to API callers only as "the engine failed (see the logs for request X)" (on an engine server, "The engine failed (see the engine server logs)."), because provider SDK errors can carry URLs and account details.
Helpers
requiredSchema(schema): the extraction schema with every field required, for strict structured outputs, anddecode(answer)to map the answer back. An empty value stands for "not stated" and is left out. Numbers and yes/no fields travel as strings, keeping the field's title and description; a number written as an amount ($4,250) decodes to 4250, and anything else that is not a number is left out. A typed enum keeps its values, and an answer outside them is dropped.parseJsonObject(text): the first JSON object in a text answer, or anEngineError.toolResultText(result): a tool call's result as text for a model.
Conformance
const { checkEngine } = require('@formio/uag-sdk/conformance');
it('meets the engine contract', async () => {
const report = await checkEngine(require('..'), { options: { client: fakeClient } });
assert.deepStrictEqual(report.failures, []);
});
The check creates an instance and calls it. Pass options, such as a fake client, or the environment variables that let it answer. It checks that:
- the definition and the engine have the contract's shape;
- the engine uses the instance name and the
modeloption; extractreturns only the fields it was asked about, ordecideonly the questions it was asked;accepts, when set, lists onlytextandpdf;generate, when present, returns text, its model and a validstopReason;- research stops at
maxIterations, and reports every tool call it made; - an aborted signal stops the engine, including during research and
generate.
A research round is the calls a model makes together; calls made one after another count as separate rounds.
License: MIT.