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:
| Option | Type | Required | Description |
|---|
authSecret | string | Yes | Your FOREST_AUTH_SECRET |
envSecret | string | Yes | Your FOREST_ENV_SECRET |
isProduction | boolean | No | Enable production mode |
logger | function | No | Custom logger function |
loggerLevel | string | No | Log level: ‘Debug’, ‘Info’, ‘Warn’, ‘Error’ |
prefix | string | No | API prefix (default: ‘/forest’) |
schemaPath | string | No | Path to .forestadmin-schema.json |
typingsPath | string | No | Path to the generated TypeScript typings file (written when set) |
useUnsafeActionEndpoint | boolean | No | Drop 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:
| Option | Type | Description |
|---|
factory | DataSourceFactory | Datasource factory function |
options.include | string[] | Collections to include |
options.exclude | string[] | Collections to exclude |
options.rename | Record<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:
| Option | Type | Description |
|---|
enabledTools | ToolName[] | 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:
| Endpoint | Purpose |
|---|
POST /mcp | Main MCP protocol endpoint (Bearer token required) |
POST /oauth/authorize | OAuth authorization |
POST /oauth/token | Token exchange |
GET /.well-known/oauth-protected-resource/mcp | OAuth 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:
| Property | Type | Description |
|---|
scope | ’Single’ | ‘Bulk’ | ‘Global’ | Action scope |
execute | function | Action execution handler |
form | FormElement[] | Dynamic form configuration |
description | string | Action description |
generateFile | boolean | Whether action returns a file |
submitButtonLabel | string | Custom 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:
| Property | Type | Required | Description |
|---|
columnType | ColumnType | Yes | Field data type |
dependencies | string[] | Yes | Fields needed for computation |
getValues | function | Yes | Value computation function |
defaultValue | any | No | Default value |
enumValues | string[] | No | Enum 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);
}
});
Search
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 */],
});
});
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:
| Option | Type | Required | Description |
|---|
columnName | string | Yes | Column to flatten |
include | string[] | No | Fields to import (defaults to all) |
exclude | string[] | No | Fields to skip |
level | number | No | Maximum nesting depth |
readonly | boolean | No | Whether imported fields are read-only |
columnType | object | No | Custom 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:
| Option | Type | Required | Description |
|---|
relationName | string | Yes | Relation to flatten |
include | string[] | No | Fields to import |
exclude | string[] | No | Fields to skip |
readonly | boolean | No | Whether 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:
| Option | Type | Required | Description |
|---|
columnName | string | Yes | JSON column to flatten |
columnType | object | Yes | Type definition for nested fields |
level | number | No | Maximum nesting depth |
readonly | boolean | No | Whether flattened fields are read-only |
keepOriginalColumn | boolean | No | Whether 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.
Exported Types
import type { Agent, SqlConnectionParams, MongoConnectionParams } from '@forestadmin/forest-cloud';
| Type | Description |
|---|
Agent | Re-export of the Agent class from @forestadmin/agent |
SqlConnectionParams | Connection options for SQL datasources |
MongoConnectionParams | Connection 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:
| Option | Type | Description |
|---|
agentForestEnvSecret | string | Environment secret |
agentForestAuthSecret | string | Auth secret |
agentUrl | string | URL of your running agent |
serverUrl | string | URL of the sandbox server |
agentSchemaPath | string | Path 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();