How to Use the Fluent API for Complex Signatures in Ax: A Complete Guide

Ax provides a type-safe fluent API (the f helper) that enables programmatic construction of complex input-output signatures with chainable validation modifiers and nested object support.

The ax-llm/ax repository implements a sophisticated declarative signature system for LLM interactions. When you need to define intricate request-response contracts beyond simple string templates, the fluent API for complex signatures in Ax offers compile-time validation, immutable field builders, and structured output enforcement.

Understanding the Fluent API Architecture

The fluent API centers on three core components implemented in src/ax/dsp/sig.ts. Understanding their interaction is essential for building maintainable, type-safe signatures.

AxSignatureBuilder

The AxSignatureBuilder class acts as a mutable accumulator for field definitions. Located at src/ax/dsp/sig.ts#L45-L165, this builder maintains internal arrays for input and output fields through the input() and output() methods (lines 60-70 and 116-126). Each method accepts a field name, an AxFluentFieldType instance, and an optional prepend boolean to control field ordering.

The f Factory Function

The f export serves dual purposes as implemented at src/ax/dsp/sig.ts#L442-L493. When invoked as a function (f()), it instantiates a fresh AxSignatureBuilder. As a namespace object, it exposes field-type factories including f.string(), f.number(), f.object(), f.boolean(), f.datetime(), and f.class().

AxFluentFieldType and Modifiers

Each field factory returns an AxFluentFieldType instance defined at src/ax/dsp/sig.ts#L335-L417. This object encapsulates type metadata and provides chainable modifier methods that return new immutable instances:

  • optional() – Marks the field as non-required (lines 300-313)
  • array() – Converts the type to an array variant (lines 316-333)
  • min() / max() – Applies numeric or length constraints (lines 378-394)
  • regex() – Adds pattern validation (line 395)
  • email() / url() – Applies format validators (lines 440-452)
  • internal() – Hides the field from external prompts (lines 334-345)
  • cache() – Enables response caching for the field

Building Complex Signatures with the Fluent API

The following patterns demonstrate how to construct signatures ranging from simple constrained fields to deeply nested schemas.

Basic Signatures with Constraints

Define input validation directly in the signature using chained constraint methods. This example creates a user validation schema with email format checking and age boundaries:

import { f } from '@ax-llm/ax';

const sig = f()
  .input('email', f.string('User email').email())
  .input('age', f.number('User age').min(18).max(120))
  .input('tags', f.string('Tag').array('List of tags'))
  .output('welcomeMessage', f.string('Greeting text'))
  .output('isAdult', f.boolean('Age-check result'))
  .description('Validates a user profile and returns a greeting')
  .build();

console.log(sig.toString());
// → "email:string.email() age:number.min(18).max(120) tags:string[]. -> welcomeMessage:string isAdult:boolean"

Key source references: f.string (line 462), .email() (lines 440-452), .min() / .max() (lines 378-394), .array() (lines 316-333), builder.build() (lines 170-186).

Nested Objects and Optional Fields

Complex payloads require nested object definitions. The f.object() factory accepts a map of field names to fluent field definitions, with ValidateNoMediaTypes<T> (lines 37-45) preventing media types (image, audio, file) from being nested inside objects at compile time:

import { f } from '@ax-llm/ax';

const sig = f()
  .input('request', f.object({
    query: f.string('Search query'),
    context: f.string('Optional context').optional(),
    metadata: f.object({
      source: f.string('Data source').optional(),
      timestamp: f.datetime('When the data was fetched')
    })
  }, 'User request payload'))
  .output('answer', f.string('Answer text'))
  .output('reasoning', f.string('Step-by-step reasoning').internal())
  .build();

console.log(sig.getInputFields());
/* 
[
  { name: 'request', type: { name: 'object', fields: { query: …, context: …, metadata: … } } }
]
*/

Key source references: f.object (lines 620-682), .optional() (lines 300-313), .internal() (lines 334-345).

Arrays and Classification Types

Use f.class() to constrain values to specific string literals, and chain .array() to create collections of complex objects:

import { f } from '@ax-llm/ax';

const sig = f()
  .input('text', f.string('Text to classify'))
  .output('sentiment', f.class(['positive', 'negative', 'neutral'] as const, 'Sentiment'))
  .output('entities', f.object({
    type: f.class(['PERSON', 'ORG', 'LOCATION'] as const, 'Entity type'),
    value: f.string('Entity string')
  }).array('Detected entities'))
  .build();

console.log(sig.toString());
// → "text:string -> sentiment:class['positive','negative','neutral'] entities:object[]."

Key source references: f.class (lines 758-788), .array() on objects (lines 618-630).

Enforcing Structured Output

By default, single-output signatures may return raw strings. Call .useStructured() before .build() to force JSON object wrapping, ensuring downstream parsers always receive valid objects:

import { f } from '@ax-llm/ax';

const sig = f()
  .input('prompt', f.string())
  .output('summary', f.string())
  .useStructured()   // guarantees `{ "summary": "…" }`
  .build();

console.log(sig.getOutputFields());
// Output field is still `summary:string`, but the runtime enforces structured JSON.

Key source reference: AxSignatureBuilder.useStructured (lines 202-212).

Validation and Type Safety

When invoking .build() (lines 170-186), AxSignatureBuilder performs several validation steps defined in the validateField utility:

  1. Uniqueness checks – Field names must be non-empty and unique across inputs and outputs
  2. Type validation – Media types are rejected when nested inside objects via ValidateNoMediaTypes<T> compile-time checks
  3. Constraint storage – Validation rules (min, max, regex, email, url) populate the AxField.type metadata for LLM prompting

All validation failures throw AxSignatureValidationError with contextual suggestions, preventing runtime signature mismatches.

Summary

  • The fluent API (f) in src/ax/dsp/sig.ts provides a type-safe, chainable interface for building AxSignature instances programmatically.
  • Field modifiers like .optional(), .array(), and .email() return immutable AxFluentFieldType instances, enabling complex constraint composition.
  • Nested objects support arbitrary depth through f.object(), with compile-time guards preventing invalid media type placement.
  • Structured output enforcement via .useStructured() guarantees JSON responses even for single-field outputs.
  • The builder validates field uniqueness, type compatibility, and constraint consistency during .build(), throwing descriptive errors for invalid configurations.

Frequently Asked Questions

How do I mark a field as optional in the Ax fluent API?

Call the .optional() method on any field type factory result before passing it to .input() or .output(). For example: f.string('Description').optional(). This modifies the field metadata to indicate the value is not required during validation.

Can I nest media types like images inside objects using the fluent API?

No. The ValidateNoMediaTypes<T> generic constraint at lines 37-45 of src/ax/dsp/sig.ts prevents media types (image, audio, file) from being defined inside nested f.object() schemas. Media types must be defined as top-level input fields only.

What is the difference between .internal() and .optional() field modifiers?

.optional() indicates that a field may be omitted from the LLM's response or input payload, while .internal() marks a field as hidden from the external prompt entirely (used for internal reasoning or caching). Internal fields are processed by Ax but not exposed to the language model during generation.

How do I force JSON output when my signature has only one output field?

Chain .useStructured() immediately before calling .build() on the AxSignatureBuilder. This method, implemented at lines 202-212, sets a flag that forces the runtime to wrap single values in a JSON object, ensuring consistent parsing regardless of the LLM's output format.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →