Skip to main content
Complete API reference for @forestadmin/agent (Node.js/TypeScript).

Agent Class

createAgent(options)

Create and configure a Forest agent.
import { createAgent } from '@forestadmin/agent';

const agent = createAgent(options: AgentOptions): Agent;
Parameters:
OptionTypeRequiredDescription
authSecretstringYesYour FOREST_AUTH_SECRET
envSecretstringYesYour FOREST_ENV_SECRET
isProductionbooleanNoEnable production mode
loggerfunctionNoCustom logger function
loggerLevelstringNoLog level: ‘Debug’, ‘Info’, ‘Warn’, ‘Error’
prefixstringNoAPI prefix (default: ‘/forest’)
schemaPathstringNoPath to .forestadmin-schema.json
typingsPathstringNoPath to the generated TypeScript typings file (written when set)
useUnsafeActionEndpointbooleanNoDrop the positional index from Smart Action routes.
Default false.
Example:
const agent = createAgent({
  authSecret: process.env.FOREST_AUTH_SECRET,
  envSecret: process.env.FOREST_ENV_SECRET,
  isProduction: process.env.NODE_ENV === 'production',
});
Only enable useUnsafeActionEndpoint if you specifically need index-free Smart Action routes.
It removes the positional index (/_actions/{collection}/{slug} instead of /_actions/{collection}/{index}/{slug}), which can make two action names collide on the same route.
When enabled, the agent refuses to start if two actions on the same collection resolve to the same slug.

agent.addDataSource(factory, options?)

Add a datasource to the agent.
agent.addDataSource(
  factory: DataSourceFactory,
  options?: DataSourceOptions
): Agent;
Parameters:
OptionTypeDescription
factoryDataSourceFactoryDatasource factory function
options.includestring[]Collections to include
options.excludestring[]Collections to exclude
options.renameRecord<string, string>Rename collections
Example:
import { createSqlDataSource } from '@forestadmin/datasource-sql';

agent.addDataSource(
  createSqlDataSource(process.env.DATABASE_URL),
  { exclude: ['internal_logs'] }
);

agent.customizeCollection(name, callback)

Customize a specific collection with the provided callback.
agent.customizeCollection(
  name: string,
  callback: (collection: CollectionCustomizer) => void
): Agent;
Example:
agent.customizeCollection('users', collection => {
  collection.addAction('Send email', {
    scope: 'Single',
    execute: async (context, resultBuilder) => {
      // Action logic
      return resultBuilder.success('Email sent!');
    },
  });
});

agent.addChart(name, definition)

Create a datasource-level API chart.
agent.addChart(
  name: string,
  definition: DataSourceChartDefinition
): Agent;
Example:
agent.addChart('overview', (context, resultBuilder) => {
  return resultBuilder.distribution({
    'Active': 150,
    'Inactive': 50,
  });
});

agent.removeCollection(…names)

Remove collections from the exported schema (they remain usable within the agent).
agent.removeCollection(...names: string[]): Agent;
Example:
agent.removeCollection('internalLogs', 'debugData');

agent.use(plugin, options?)

Load a plugin across all collections.
agent.use<Options>(
  plugin: Plugin<Options>,
  options?: Options
): Agent;
Example:
import advancedExportPlugin from '@forestadmin/plugin-export-advanced';

agent.use(advancedExportPlugin, { format: 'xlsx' });

agent.start()

Start the agent and connect to Forest servers.
await agent.start(): Promise<void>;
Example:
await agent.start();
console.log('Agent started successfully');

agent.stop()

Stop the agent and close all connections.
await agent.stop(): Promise<void>;

agent.restart()

Reconstruct routing and remount routes at runtime. Called when customizations are refreshed externally (e.g. in cloud environments).
await agent.restart(): Promise<void>;

agent.addAi(provider)

Route Forest’s AI features (natural language search, actions, etc.) through your own agent so your data never leaves your infrastructure. Pass a provider built with createAiProvider (from @forestadmin/ai-proxy); openai and anthropic are supported. See Self-hosted AI for the full guide.
agent.addAi(provider: AiProviderDefinition): Agent;
Can only be called once, calling it a second time throws an error. Example:
import { createAgent } from '@forestadmin/agent';
import { createAiProvider } from '@forestadmin/ai-proxy';

const agent = createAgent({
  authSecret: process.env.FOREST_AUTH_SECRET,
  envSecret: process.env.FOREST_ENV_SECRET,
})
  .addAi(
    createAiProvider({
      name: 'my-assistant',
      provider: 'anthropic',
      apiKey: process.env.ANTHROPIC_API_KEY,
      model: 'claude-sonnet-4-5',
    }),
  );

agent.mountAiMcpServer(options?)

Enable a Model Context Protocol (MCP) server on the agent, allowing AI assistants to interact with your data through standardized tools.
agent.mountAiMcpServer(options?: {
  enabledTools?: ToolName[];
}): Agent;
Parameters:
OptionTypeDescription
enabledToolsToolName[]Restrict which MCP tools are exposed. Defaults to all tools.
Available tool names: 'describeCollection', 'list', 'listRelated', 'create', 'update', 'delete', 'associate', 'dissociate', 'getActionForm', 'executeAction' Example:
agent.mountAiMcpServer({
  enabledTools: ['describeCollection', 'list', 'listRelated'],
});
The MCP server exposes HTTP endpoints for OAuth and protocol communication:
EndpointPurpose
POST /mcpMain MCP protocol endpoint (Bearer token required)
POST /oauth/authorizeOAuth authorization
POST /oauth/tokenToken exchange
GET /.well-known/oauth-protected-resource/mcpOAuth discovery
You can also configure enabled tools via the FOREST_MCP_ENABLED_TOOLS environment variable (comma-separated tool names), or set the server port with MCP_SERVER_PORT (default: 3931).

agent.updateTypesOnFileSystem(typingsPath, typingsMaxDepth)

Update the TypeScript typings file generated from your datasources.
await agent.updateTypesOnFileSystem(
  typingsPath: string,
  typingsMaxDepth: number
): Promise<void>;
Example:
await agent.updateTypesOnFileSystem('./typings.ts', 5);

agent.generateSchemaOnly()

Build the schema (.forestadmin-schema.json) and the TypeScript typings and write them to disk without starting the agent or sending the schema to Forest. Useful for generating the schema at build time in a CI/CD pipeline, see Generate the schema at build time. Available since @forestadmin/agent 1.83.0.
await agent.generateSchemaOnly(): Promise<void>;
It writes to the schemaPath (and typingsPath, when set) you configured in createAgent. Unlike agent.start(), it always rebuilds the schema, even when isProduction is true, and it never contacts Forest, with one exception: if you enable experimental no-code customizations, it still fetches their configuration from the Forest API, so connectivity is required in that case. Example:
import { createAgent } from '@forestadmin/agent';
import { createSqlDataSource } from '@forestadmin/datasource-sql';

const agent = createAgent({
  authSecret: process.env.FOREST_AUTH_SECRET,
  envSecret: process.env.FOREST_ENV_SECRET,
  isProduction: true,
  schemaPath: '.forestadmin-schema.json',
  typingsPath: './typings.ts', // optional
}).addDataSource(createSqlDataSource(process.env.DATABASE_URL));

await agent.generateSchemaOnly();
This does not close your data source connections. In a one-shot script, close your data source (the SQL/Sequelize/Mongo client you passed in) or call process.exit() once it resolves, otherwise an open connection pool can keep the process alive.

Collection Customizer

Methods available when customizing a collection through agent.customizeCollection().

Actions

collection.addAction(name, definition)

Add an action to the collection.
collection.addAction(
  name: string,
  definition: ActionDefinition
): CollectionCustomizer;
Definition Properties:
PropertyTypeDescription
scope’Single’ | ‘Bulk’ | ‘Global’Action scope
executefunctionAction execution handler
formFormElement[]Dynamic form configuration
descriptionstringAction description
generateFilebooleanWhether action returns a file
submitButtonLabelstringCustom button text
Execute Function:
execute: (
  context: ActionContext,
  resultBuilder: ResultBuilder
) => Promise<ActionResult>
ActionContext Properties:
  • context.collection - Collection instance
  • context.filter - Filter for selected records
  • context.caller - User who triggered the action
  • context.formValues - Form values submitted
ActionContext Methods:
  • context.getRecords(fields) - Get multiple records (Bulk/Single scope)
  • context.getRecordIds() - Get IDs of selected records
  • context.getCompositeRecordIds() - Get composite IDs of selected records
  • context.hasFieldChanged(fieldName) - Check if form field changed
  • context.getRecord(fields) - Get single record (Single scope only)
  • context.getRecordId() - Get single record ID (Single scope only)
  • context.getCompositeRecordId() - Get composite ID (Single scope only)
  • context.getField(fieldName) - Get single field value (Single scope only)
ResultBuilder Methods:
  • resultBuilder.success(message?, options?) - Success response
    • options.html - Custom HTML to display
    • options.invalidated - Array of collection names to refresh
  • resultBuilder.error(message?, options?) - Error response
    • options.html - Custom HTML to display
  • resultBuilder.webhook(url, method, headers, body) - Trigger webhook
  • resultBuilder.file(stream, filename, mimeType) - Return file download
  • resultBuilder.redirectTo(path) - Redirect to URL
  • resultBuilder.setHeader(name, value) - Add HTTP header to response
Example - Simple Action:
collection.addAction('Mark as verified', {
  scope: 'Single',
  execute: async (context, resultBuilder) => {
    const user = await context.getRecord(['id']);
    await updateUser(user.id, { verified: true });
    return resultBuilder.success('User marked as verified');
  },
});
Example - Action with Form:
collection.addAction('Send notification', {
  scope: 'Bulk',
  form: [
    {
      label: 'Message',
      type: 'String',
      isRequired: true,
    },
    {
      label: 'Channel',
      type: 'Enum',
      enumValues: ['email', 'sms', 'push'],
      isRequired: true,
    },
  ],
  execute: async (context, resultBuilder) => {
    const { message, channel } = context.formValues;
    const users = await context.getRecords(['email']);

    for (const user of users) {
      await sendNotification(user.email, message, channel);
    }

    return resultBuilder.success(`Sent ${channel} to ${users.length} users`);
  },
});
Example - File Generation:
collection.addAction('Export to PDF', {
  scope: 'Bulk',
  generateFile: true,
  execute: async (context, resultBuilder) => {
    const records = await context.getRecords(['name', 'email']);
    const pdfStream = await generatePDF(records);

    return resultBuilder.file(pdfStream, 'export.pdf', 'application/pdf');
  },
});

Fields

collection.addField(name, definition)

Add a computed field to the collection.
collection.addField(
  name: string,
  definition: ComputedDefinition
): CollectionCustomizer;
Definition Properties:
PropertyTypeRequiredDescription
columnTypeColumnTypeYesField data type
dependenciesstring[]YesFields needed for computation
getValuesfunctionYesValue computation function
defaultValueanyNoDefault value
enumValuesstring[]NoEnum options (if type is Enum)
Column Types:
  • 'String' - Text
  • 'Number' - Numeric value
  • 'Boolean' - True/false
  • 'Date' - Date with time
  • 'Dateonly' - Date without time
  • 'Time' - Time only
  • 'Enum' - Enumeration
  • 'Json' - JSON object
  • 'Uuid' - UUID
  • 'Point' - Geographic point
  • 'File' - File reference
Example - Simple Computed Field:
collection.addField('fullName', {
  columnType: 'String',
  dependencies: ['firstName', 'lastName'],
  getValues: (records) =>
    records.map(r => `${r.firstName} ${r.lastName}`),
});
Example - Async Computed Field:
collection.addField('revenueThisYear', {
  columnType: 'Number',
  dependencies: ['id'],
  getValues: async (records) => {
    const ids = records.map(r => r.id);
    const revenues = await fetchRevenues(ids);
    return revenues;
  },
});

collection.importField(name, options)

Import a field from a related collection.
collection.importField(
  name: string,
  options: { path: string; readonly?: boolean }
): CollectionCustomizer;
Example:
// Import author's name into books collection
collection.importField('authorName', {
  path: 'author:fullName',
  readonly: true,
});

collection.renameField(currentName, newName)

Rename a field in the exported schema.
collection.renameField(
  currentName: string,
  newName: string
): CollectionCustomizer;
Example:
collection.renameField('created_at', 'createdAt');

collection.removeField(…names)

Remove fields from the exported schema (they remain usable within the agent).
collection.removeField(...names: string[]): CollectionCustomizer;
Example:
collection.removeField('password', 'internalNotes', 'debugData');

collection.addFieldValidation(name, operator, value?)

Add a validation rule to a field.
collection.addFieldValidation(
  name: string,
  operator: Operator,
  value?: any
): CollectionCustomizer;
Operators: 'Present', 'LongerThan', 'ShorterThan', 'Contains', 'Like', 'Match', 'GreaterThan', 'LessThan', 'Before', 'After' Example:
collection
  .addFieldValidation('email', 'Present')
  .addFieldValidation('email', 'Match', /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/)
  .addFieldValidation('age', 'GreaterThan', 18)
  .addFieldValidation('username', 'LongerThan', 3);

collection.setFieldNullable(name)

Mark a field as optional (nullable).
collection.setFieldNullable(name: string): CollectionCustomizer;
Your database might still refuse empty values if it requires one.
Example:
collection.setFieldNullable('middleName');

collection.replaceFieldWriting(name, definition)

Replace the write behavior of a field.
collection.replaceFieldWriting(
  name: string,
  definition: WriteDefinition
): CollectionCustomizer;
Example:
// Write fullName as firstName + lastName
collection.replaceFieldWriting('fullName', fullName => {
  const [firstName, lastName] = fullName.split(' ');
  return { firstName, lastName };
});

collection.replaceFieldBinaryMode(name, mode)

Choose how binary data should be transported to the GUI.
collection.replaceFieldBinaryMode(
  name: string,
  mode: 'datauri' | 'hex'
): CollectionCustomizer;
Modes:
  • 'datauri' - Best for file uploads, uses FilePicker widget
  • 'hex' - Best for short binary data like UUIDs
Example:
collection.replaceFieldBinaryMode('avatar', 'datauri');
collection.replaceFieldBinaryMode('uuid', 'hex');

Relationships

collection.addManyToOneRelation(name, foreignCollection, options)

Add a many-to-one relationship.
collection.addManyToOneRelation(
  name: string,
  foreignCollection: string,
  options: {
    foreignKey: string;
    foreignKeyTarget?: string;
  }
): CollectionCustomizer;
Example:
// books.authorId → persons.id
books.addManyToOneRelation('author', 'persons', {
  foreignKey: 'authorId',
});

collection.addOneToManyRelation(name, foreignCollection, options)

Add a one-to-many relationship.
collection.addOneToManyRelation(
  name: string,
  foreignCollection: string,
  options: {
    originKey: string;
    originKeyTarget?: string;
  }
): CollectionCustomizer;
Example:
// persons.id ← books.authorId
persons.addOneToManyRelation('writtenBooks', 'books', {
  originKey: 'authorId',
});

collection.addOneToOneRelation(name, foreignCollection, options)

Add a one-to-one relationship.
collection.addOneToOneRelation(
  name: string,
  foreignCollection: string,
  options: {
    originKey: string;
    originKeyTarget?: string;
  }
): CollectionCustomizer;
Example:
// persons.id ← profiles.personId (unique)
persons.addOneToOneRelation('profile', 'profiles', {
  originKey: 'personId',
});

collection.addManyToManyRelation(name, foreignCollection, throughCollection, options)

Add a many-to-many relationship.
collection.addManyToManyRelation(
  name: string,
  foreignCollection: string,
  throughCollection: string,
  options: {
    originKey: string;
    foreignKey: string;
    originKeyTarget?: string;
    foreignKeyTarget?: string;
  }
): CollectionCustomizer;
Example:
// students ↔ student_courses ↔ courses
students.addManyToManyRelation('enrolledCourses', 'courses', 'student_courses', {
  originKey: 'studentId',
  foreignKey: 'courseId',
});

collection.addExternalRelation(name, definition)

Add a virtual collection into the related data of a record.
collection.addExternalRelation(
  name: string,
  definition: ExternalRelationDefinition
): CollectionCustomizer;
Example:
collection.addExternalRelation('relatedProducts', {
  schema: { id: 'Number', name: 'String', price: 'Number' },
  listRecords: async ({ id }) => {
    return await fetchRelatedProducts(id);
  },
});

Segments

collection.addSegment(name, definition)

Add a segment (saved filter) to the collection.
collection.addSegment(
  name: string,
  definition: SegmentDefinition
): CollectionCustomizer;
Example - Static Segment:
collection.addSegment('Premium users', {
  field: 'plan',
  operator: 'Equal',
  value: 'premium',
});
Example - Dynamic Segment:
collection.addSegment('Active this month', async (context) => {
  const startOfMonth = new Date();
  startOfMonth.setDate(1);

  return {
    field: 'lastActiveAt',
    operator: 'After',
    value: startOfMonth,
  };
});

Hooks

collection.addHook(position, type, handler)

Add a hook to execute code before or after operations.
collection.addHook(
  position: 'Before' | 'After',
  type: HookType,
  handler: HookHandler
): CollectionCustomizer;
Hook Types: 'List', 'Create', 'Update', 'Delete', 'Aggregate' Example - Before Hook:
collection.addHook('Before', 'Create', async (context) => {
  // Validate data before creation
  if (!context.data.email) {
    throw new Error('Email is required');
  }
});
Example - After Hook:
collection.addHook('After', 'Update', async (context) => {
  // Send notification after update
  const records = await context.collection.list(context.filter, ['email']);
  for (const record of records) {
    await sendUpdateNotification(record.email);
  }
});

collection.replaceSearch(definition)

Replace the default search behavior.
collection.replaceSearch(
  definition: SearchDefinition
): CollectionCustomizer;
Example:
collection.replaceSearch(async (searchString) => {
  // Search in multiple fields
  return {
    aggregator: 'Or',
    conditions: [
      { field: 'firstName', operator: 'Contains', value: searchString },
      { field: 'lastName', operator: 'Contains', value: searchString },
      { field: 'email', operator: 'Contains', value: searchString },
    ],
  };
});

collection.disableSearch()

Disable search functionality on the collection.
collection.disableSearch(): CollectionCustomizer;

Sorting

collection.emulateFieldSorting(name)

Enable in-memory sorting on a field.
collection.emulateFieldSorting(name: string): CollectionCustomizer;
Example:
collection.emulateFieldSorting('fullName');

collection.replaceFieldSorting(name, equivalentSort)

Replace sorting implementation for a field.
collection.replaceFieldSorting(
  name: string,
  equivalentSort: SortClause[]
): CollectionCustomizer;
Example:
collection.replaceFieldSorting('fullName', [
  { field: 'lastName', ascending: true },
  { field: 'firstName', ascending: true },
]);

collection.disableFieldSorting(name)

Disable sorting on a specific field.
collection.disableFieldSorting(name: string): CollectionCustomizer;

Filtering

collection.emulateFieldFiltering(name)

Enable in-memory filtering on all operators for a field.
collection.emulateFieldFiltering(name: string): CollectionCustomizer;

collection.emulateFieldOperator(name, operator)

Enable in-memory filtering for a specific operator on a field.
collection.emulateFieldOperator(
  name: string,
  operator: Operator
): CollectionCustomizer;
Example:
collection.emulateFieldOperator('fullName', 'Contains');

collection.replaceFieldOperator(name, operator, replacer)

Replace the implementation of a filter operator.
collection.replaceFieldOperator(
  name: string,
  operator: Operator,
  replacer: OperatorDefinition
): CollectionCustomizer;
Example:
collection.replaceFieldOperator('fullName', 'Contains', (value) => {
  return {
    aggregator: 'Or',
    conditions: [
      { field: 'firstName', operator: 'Contains', value },
      { field: 'lastName', operator: 'Contains', value },
    ],
  };
});

Charts

collection.addChart(name, definition)

Add a chart to the collection.
collection.addChart(
  name: string,
  definition: ChartDefinition
): CollectionCustomizer;
Example - Value Chart:
collection.addChart('totalRevenue', async (context, resultBuilder) => {
  const total = await calculateTotalRevenue();
  return resultBuilder.value(total);
});
Example - Distribution Chart:
collection.addChart('usersByPlan', async (context, resultBuilder) => {
  const distribution = await getUserDistributionByPlan();
  return resultBuilder.distribution(distribution);
});
Example - Time-based Chart:
collection.addChart('signupsOverTime', async (context, resultBuilder) => {
  const data = await getSignupsOverTime(context.timezone);
  return resultBuilder.timeBased(data);
});

Collection Overrides

collection.overrideCreate(handler)

Replace the default create operation.
collection.overrideCreate(
  handler: CreateOverrideHandler
): CollectionCustomizer;
Example:
collection.overrideCreate(async (context) => {
  const { data } = context;

  // Custom creation logic
  const record = await customCreateAPI(data);

  return [record];
});

collection.overrideUpdate(handler)

Replace the default update operation.
collection.overrideUpdate(
  handler: UpdateOverrideHandler
): CollectionCustomizer;
Example:
collection.overrideUpdate(async (context) => {
  const { filter, patch } = context;

  // Custom update logic
  await customUpdateAPI(filter, patch);
});

collection.overrideDelete(handler)

Replace the default delete operation.
collection.overrideDelete(
  handler: DeleteOverrideHandler
): CollectionCustomizer;
Example:
collection.overrideDelete(async (context) => {
  const { filter } = context;

  // Custom deletion logic (e.g., soft delete)
  await customSoftDeleteAPI(filter);
});

Other Methods

collection.disableCount()

Disable count in list view pagination for improved performance.
collection.disableCount(): CollectionCustomizer;

collection.use(plugin, options?)

Load a plugin on a specific collection.
collection.use(
  plugin: Plugin,
  options?: any
): CollectionCustomizer;
Example:
import { createFileField } from '@forestadmin/plugin-s3';

collection.use(createFileField, {
  fieldname: 'avatar',
  bucket: 'my-bucket',
});

Chart Result Builders

When creating charts with collection.addChart() or agent.addChart(), the result builder provides methods to format chart data.

resultBuilder.value(value, previousValue?)

Create a Value chart (single number).
collection.addChart('totalRevenue', async (context, resultBuilder) => {
  const total = await calculateRevenue();
  const previous = await calculateRevenue(lastMonth);
  return resultBuilder.value(total, previous);
});

resultBuilder.distribution(obj)

Create a Distribution/Pie chart.
collection.addChart('usersByPlan', async (context, resultBuilder) => {
  return resultBuilder.distribution({
    'Free': 1000,
    'Pro': 500,
    'Enterprise': 50,
  });
});

resultBuilder.timeBased(timeRange, values)

Create a Time-based/Line chart.
collection.addChart('signupsOverTime', async (context, resultBuilder) => {
  return resultBuilder.timeBased('Day', [
    { date: new Date('2024-01-01'), value: 10 },
    { date: new Date('2024-01-02'), value: 15 },
    { date: new Date('2024-01-03'), value: null }, // Missing data
  ]);
});
Time Ranges: 'Day', 'Week', 'Month', 'Quarter', 'Year'

resultBuilder.multipleTimeBased(timeRange, dates, lines)

Create a Multi-line Time-based chart.
collection.addChart('comparison', async (context, resultBuilder) => {
  const dates = [new Date('2024-01-01'), new Date('2024-01-02'), new Date('2024-01-03')];

  return resultBuilder.multipleTimeBased('Day', dates, [
    { label: 'Sales', values: [100, 150, 200] },
    { label: 'Returns', values: [10, 15, null] },
  ]);
});

resultBuilder.percentage(value)

Create a Percentage chart.
collection.addChart('completionRate', async (context, resultBuilder) => {
  const rate = (completed / total) * 100;
  return resultBuilder.percentage(rate);
});

resultBuilder.objective(value, objective)

Create an Objective chart (progress toward goal).
collection.addChart('salesGoal', async (context, resultBuilder) => {
  const current = await getCurrentSales();
  const target = 100000;
  return resultBuilder.objective(current, target);
});

resultBuilder.leaderboard(obj)

Create a Leaderboard chart (sorted distribution).
collection.addChart('topSellers', async (context, resultBuilder) => {
  return resultBuilder.leaderboard({
    'John': 5000,
    'Jane': 7500,
    'Bob': 3000,
  });
  // Automatically sorted: Jane (7500), John (5000), Bob (3000)
});

resultBuilder.smart(data)

Create a Smart chart (custom format).
collection.addChart('custom', async (context, resultBuilder) => {
  return resultBuilder.smart({
    // Custom chart data structure
    type: 'custom',
    data: [/* your data */],
  });
});

Form Field Types

Action forms support various field types with different widgets.

Basic Field Types

collection.addAction('Example', {
  scope: 'Single',
  form: [
    {
      label: 'User Name',
      type: 'String',
      isRequired: true,
      description: 'Enter the user name',
    },
    {
      label: 'Age',
      type: 'Number',
      isRequired: false,
      defaultValue: 18,
    },
    {
      label: 'Is Active',
      type: 'Boolean',
      defaultValue: true,
    },
    {
      label: 'Birth Date',
      type: 'Date',
    },
    {
      label: 'Metadata',
      type: 'Json',
    },
  ],
  execute: async (context, resultBuilder) => {
    const { userName, age, isActive, birthDate, metadata } = context.formValues;
    // ... action logic
    return resultBuilder.success();
  },
});
Available Types:
  • 'String' - Text input
  • 'Number' - Numeric input
  • 'Boolean' - Checkbox
  • 'Date' - Date picker
  • 'Dateonly' - Date without time
  • 'Time' - Time picker
  • 'Enum' - Single selection
  • 'EnumList' - Multiple selection
  • 'File' - File upload
  • 'FileList' - Multiple file upload
  • 'Json' - JSON editor
  • 'Collection' - Record picker
  • 'NumberList' - Array of numbers
  • 'StringList' - Array of strings

Enum Fields

{
  label: 'Status',
  type: 'Enum',
  enumValues: ['pending', 'approved', 'rejected'],
  isRequired: true,
}

Collection Fields

Pick a record from another collection:
{
  label: 'Assign to User',
  type: 'Collection',
  collectionName: 'users',
}

{
  label: 'Country',
  type: 'String',
  widget: 'Dropdown',
  options: [
    { label: 'United States', value: 'US' },
    { label: 'United Kingdom', value: 'UK' },
    { label: 'France', value: 'FR' },
  ],
  placeholder: 'Select a country',
  search: 'static', // or 'disabled'
}

Dynamic Dropdown

{
  label: 'Product',
  type: 'String',
  widget: 'Dropdown',
  search: 'dynamic',
  options: async (context, searchValue) => {
    const products = await searchProducts(searchValue);
    return products.map(p => ({
      label: p.name,
      value: p.id,
    }));
  },
}

Conditional Fields

Show/hide fields based on other field values:
form: [
  {
    label: 'Notification Type',
    type: 'Enum',
    enumValues: ['email', 'sms', 'push'],
  },
  {
    label: 'Email Address',
    type: 'String',
    if: (context) => context.formValues.notificationType === 'email',
  },
  {
    label: 'Phone Number',
    type: 'String',
    if: (context) => context.formValues.notificationType === 'sms',
  },
]

Dynamic Field Values

{
  label: 'Department',
  type: 'Enum',
  enumValues: async (context) => {
    // Fetch departments based on selected company
    const companyId = context.formValues.companyId;
    return await getDepartments(companyId);
  },
}

Read-Only Fields

{
  label: 'Created At',
  type: 'Date',
  isReadOnly: true,
  value: async (context) => {
    const record = await context.getRecord(['createdAt']);
    return record.createdAt;
  },
}

ConditionTree Utilities

Build complex filter conditions programmatically.

ConditionTreeLeaf

Simple condition on a single field:
import { ConditionTreeLeaf } from '@forestadmin/agent';

const condition = new ConditionTreeLeaf('status', 'Equal', 'active');
// Equivalent to: { field: 'status', operator: 'Equal', value: 'active' }

ConditionTreeBranch

Combine multiple conditions with AND/OR:
import { ConditionTreeBranch, ConditionTreeLeaf } from '@forestadmin/agent';

const condition = new ConditionTreeBranch('And', [
  new ConditionTreeLeaf('status', 'Equal', 'active'),
  new ConditionTreeLeaf('age', 'GreaterThan', 18),
]);

ConditionTree Factory

import { ConditionTreeFactory } from '@forestadmin/agent';

// From plain object
const condition = ConditionTreeFactory.fromPlainObject({
  field: 'email',
  operator: 'Contains',
  value: '@example.com',
});

// Combine conditions
const combined = ConditionTreeFactory.intersect([
  condition1,
  condition2,
]);

// Union (OR)
const union = ConditionTreeFactory.union([
  condition1,
  condition2,
]);

Available Operators

Comparison:
  • 'Equal', 'NotEqual'
  • 'GreaterThan', 'LessThan'
  • 'In', 'NotIn'
  • 'Present', 'Blank'
String:
  • 'Contains', 'NotContains'
  • 'StartsWith', 'EndsWith'
  • 'Like', 'ILike' (case-insensitive)
Date:
  • 'Before', 'After'
  • 'Today', 'Yesterday', 'PreviousWeek', 'PreviousMonth', 'PreviousQuarter', 'PreviousYear'
  • 'Past', 'Future'
Array:
  • 'IncludesAll', 'IncludesNone'

Caller

User information available in all contexts:
interface Caller {
  id: number;
  email: string;
  firstName: string;
  lastName: string;
  team: string;
  role: string;
  tags: Record<string, string>;
  timezone: string;
}

Filter

import { Filter, ConditionTreeLeaf } from '@forestadmin/agent';

const filter = new Filter({
  conditionTree: new ConditionTreeLeaf('status', 'Equal', 'active'),
  search: 'john',
  searchExtended: false,
  segment: 'premium-users',
});

Projection

Specify which fields to retrieve:
import { Projection } from '@forestadmin/agent';

const projection = new Projection('id', 'name', 'email', 'company:name');

Plugin: Flattener

@forestadmin/plugin-flattener flattens nested data structures (composite columns, relations, JSON columns) into individual top-level fields.

flattenColumn(dataSource, collection, options)

Decompose a column with a composite type into individual fields.
import { flattenColumn } from '@forestadmin/plugin-flattener';

agent.customizeCollection('orders', async (collection) => {
  await collection.use(flattenColumn, {
    columnName: 'address',
    include: ['street', 'city', 'zipCode'],
    readonly: false,
  });
});
Options:
OptionTypeRequiredDescription
columnNamestringYesColumn to flatten
includestring[]NoFields to import (defaults to all)
excludestring[]NoFields to skip
levelnumberNoMaximum nesting depth
readonlybooleanNoWhether imported fields are read-only
columnTypeobjectNoCustom type mapping for nested fields

flattenRelation(dataSource, collection, options)

Import fields from a relation directly into the collection.
import { flattenRelation } from '@forestadmin/plugin-flattener';

agent.customizeCollection('books', async (collection) => {
  await collection.use(flattenRelation, {
    relationName: 'author',
    include: ['firstName', 'lastName', 'email'],
    readonly: true,
  });
});
Options:
OptionTypeRequiredDescription
relationNamestringYesRelation to flatten
includestring[]NoFields to import
excludestring[]NoFields to skip
readonlybooleanNoWhether imported fields are read-only

flattenJsonColumn(dataSource, collection, options)

Expand a JSON column into individual typed fields.
import { flattenJsonColumn } from '@forestadmin/plugin-flattener';

agent.customizeCollection('products', (collection) => {
  collection.use(flattenJsonColumn, {
    columnName: 'metadata',
    columnType: {
      weight: 'Number',
      dimensions: { width: 'Number', height: 'Number' },
      tags: ['String'],
    },
    readonly: false,
    keepOriginalColumn: false,
  });
});
Options:
OptionTypeRequiredDescription
columnNamestringYesJSON column to flatten
columnTypeobjectYesType definition for nested fields
levelnumberNoMaximum nesting depth
readonlybooleanNoWhether flattened fields are read-only
keepOriginalColumnbooleanNoWhether to preserve the original JSON column

Package: Forest Cloud

@forestadmin/forest-cloud is a dev-only package that provides CLI tooling for cloud-hosted customization projects.

Installation

npm install @forestadmin/forest-cloud --save-dev

CLI Commands

bootstrap

Initialize a cloud customization project. Authenticates with Forest, creates a cloud-customizer directory, configures credentials, and generates type definitions.
npx forest-cloud bootstrap --env-secret YOUR_FOREST_ENV_SECRET

update-typings

Regenerate TypeScript type definitions based on your current database structure and customization code.
npx forest-cloud update-typings

login

Refresh your Forest authentication token.
npx forest-cloud login

Exported Types

import type { Agent, SqlConnectionParams, MongoConnectionParams } from '@forestadmin/forest-cloud';
TypeDescription
AgentRe-export of the Agent class from @forestadmin/agent
SqlConnectionParamsConnection options for SQL datasources
MongoConnectionParamsConnection parameters for MongoDB datasources

Package: Agent Testing

@forestadmin/agent-testing provides utilities to test agent customizations locally without connecting to Forest servers.

createForestServerSandbox(port)

Start a local sandbox that mimics Forest servers.
import { createForestServerSandbox } from '@forestadmin/agent-testing';

const sandbox = await createForestServerSandbox(3001);

// ... run your tests

await sandbox.close();

createAgentTestClient(options)

Connect a test client to a running agent to simulate frontend requests.
import { createAgentTestClient } from '@forestadmin/agent-testing';

const client = await createAgentTestClient({
  agentForestEnvSecret: process.env.FOREST_ENV_SECRET,
  agentForestAuthSecret: process.env.FOREST_AUTH_SECRET,
  agentUrl: 'http://localhost:3000',
  serverUrl: 'http://localhost:3001',
  agentSchemaPath: '.forestadmin-schema.json',
});
Parameters:
OptionTypeDescription
agentForestEnvSecretstringEnvironment secret
agentForestAuthSecretstringAuth secret
agentUrlstringURL of your running agent
serverUrlstringURL of the sandbox server
agentSchemaPathstringPath to .forestadmin-schema.json

Typical test workflow

import { createForestServerSandbox, createAgentTestClient } from '@forestadmin/agent-testing';

// 1. Start sandbox
const sandbox = await createForestServerSandbox(3001);

// 2. Start your agent (pointing at sandbox)
// FOREST_SERVER_URL=http://localhost:3001 node index.js

// 3. Connect test client
const client = await createAgentTestClient({
  agentForestEnvSecret: 'test-env-secret',
  agentForestAuthSecret: 'test-auth-secret',
  agentUrl: 'http://localhost:3000',
  serverUrl: 'http://localhost:3001',
  agentSchemaPath: '.forestadmin-schema.json',
});

// 4. Assert
const users = await client.collection('users').list();
expect(users).toHaveLength(3);

// 5. Cleanup
await sandbox.close();