Skip to main content
Actions can return different types of results to provide feedback to users. Use the result builder to control what happens after an action executes.

Default behavior

If you don’t return anything and no exception is thrown, Forest displays a generic success notification.
execute: async (context, resultBuilder) => {
  // Perform your logic
  // No return = generic success message
}
Default success notification

Success notification

Display a custom success message.
return resultBuilder.success('Company is now live!');
Custom success notification

Error notification

Display an error message when something goes wrong.
if (!isValid) {
  return resultBuilder.error('The company was already live!');
}
Custom error notification
Always handle errors gracefully and return meaningful error messages to help users understand what went wrong.

HTML result

Return rich formatted content displayed in a side panel. Perfect for showing detailed operation results.
HTML success result in a side panel
return resultBuilder.success('Charge successful', {
  html: `
    <p class="c-clr-1-4 l-mt l-mb">
      $${amount} USD has been successfully charged.
    </p>
    <strong class="c-form__label--read c-clr-1-2">Credit card</strong>
    <p class="c-clr-1-4 l-mb">**** **** **** ${last4}</p>
    <strong class="c-form__label--read c-clr-1-2">Transaction ID</strong>
    <p class="c-clr-1-4 l-mb">${transactionId}</p>
  `,
});

HTML with error

You can also return HTML content with an error:
return resultBuilder.error('Charge failed', {
  html: `
    <p class="c-clr-1-4 l-mt l-mb">
      $${amount} USD has not been charged.
    </p>
    <strong class="c-form__label--read c-clr-1-2">Reason</strong>
    <p class="c-clr-1-4 l-mb">
      The credit card is marked as blocked.
    </p>
  `,
});

File download

Generate and download files (PDFs, CSVs, Excel, etc.).
Actions that generate files must set generateFile: true in their configuration. This flag prevents using other result types (notifications, HTML) in the same action.
collection.addAction('Download report', {
  scope: 'Global',
  generateFile: true,  // Required for file downloads
  execute: async (context, resultBuilder) => {
    // From a string
    return resultBuilder.file(
      'Report content here',
      'report.txt',
      'text/plain'
    );

    // From a Buffer
    const buffer = Buffer.from('Report content');
    return resultBuilder.file(buffer, 'report.txt', 'text/plain');

    // From a stream
    const stream = fs.createReadStream('path/to/file.pdf');
    return resultBuilder.file(stream, 'report.pdf', 'application/pdf');
  },
});

Common MIME types

File typeMIME type
PDFapplication/pdf
CSVtext/csv
Excel (xlsx)application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
Excel (xls)application/vnd.ms-excel
JSONapplication/json
ZIPapplication/zip
Plain texttext/plain
HTMLtext/html

Example: Generate CSV

collection.addAction('Export to CSV', {
  scope: 'Bulk',
  generateFile: true,
  execute: async (context, resultBuilder) => {
    const records = await context.getRecords(['id', 'name', 'email']);

    // Generate CSV content
    const header = 'ID,Name,Email\n';
    const rows = records.map(r => `${r.id},"${r.name}","${r.email}"`).join('\n');
    const csv = header + rows;

    return resultBuilder.file(csv, 'export.csv', 'text/csv');
  },
});

Example: Generate PDF

collection.addAction('Generate invoice', {
  scope: 'Single',
  generateFile: true,
  execute: async (context, resultBuilder) => {
    const order = await context.getRecord(['id', 'total', 'customer:name']);

    // Generate PDF (using a library like pdfkit)
    const pdf = await generateInvoicePDF(order);

    return resultBuilder.file(
      pdf,
      `invoice-${order.id}.pdf`,
      'application/pdf'
    );
  },
});

Redirect

Redirect users to another page after the action executes. Works for both internal Forest pages and external URLs.

Internal redirect

Redirect to another page within Forest:
return resultBuilder.redirectTo(
  '/MyProject/MyEnvironment/MyTeam/data/20/index/record/20/108/activity'
);

External redirect

Redirect to an external URL:
return resultBuilder.redirectTo(
  'https://www.example.com/tracking?id=ZW924750388GB'
);

Example: Redirect to created record

collection.addAction('Create and view', {
  scope: 'Global',
  execute: async (context, resultBuilder) => {
    // Create a new record
    const newRecord = await createRecord(context.formValues);

    // Redirect to the new record's detail page
    return resultBuilder.redirectTo(
      `/MyProject/Production/data/companies/index/record/companies/${newRecord.id}/details`
    );
  },
});

Webhook

Trigger an HTTP callback from the user’s browser. Useful for logging into third-party applications or triggering operations on the user’s behalf.
Webhooks are triggered from the user’s browser and are subject to CORS restrictions. Make sure the target server accepts requests from Forest domains.
return resultBuilder.webhook(
  'https://api.example.com/callback',  // URL
  'POST',                                // Method
  { Authorization: 'Bearer token' },     // Headers
  { userId: 123, action: 'approve' }     // Body (JSON)
);

Example: Single sign-on

collection.addAction('Login to external tool', {
  scope: 'Single',
  execute: async (context, resultBuilder) => {
    const user = await context.getRecord(['email', 'externalId']);

    // Generate a temporary token
    const token = await generateSSOToken(user.externalId);

    // Trigger login in the user's browser
    return resultBuilder.webhook(
      'https://external-tool.com/sso/login',
      'POST',
      {},
      { token, email: user.email }
    );
  },
});

Example: Trigger background job

collection.addAction('Process data', {
  scope: 'Bulk',
  execute: async (context, resultBuilder) => {
    const ids = await context.getRecordIds();

    // Trigger a background job with user context
    return resultBuilder.webhook(
      'https://api.mycompany.com/jobs/process',
      'POST',
      { 'X-API-Key': process.env.API_KEY },
      {
        recordIds: ids,
        userId: context.caller.id,
        triggeredAt: new Date().toISOString()
      }
    );
  },
});

Combining results with operations

Success with HTML details

execute: async (context, resultBuilder) => {
  const result = await performComplexOperation();

  if (result.success) {
    return resultBuilder.success('Operation completed', {
      html: `
        <h3>Summary</h3>
        <p>Processed ${result.count} items</p>
        <ul>
          ${result.items.map(i => `<li>${i}</li>`).join('')}
        </ul>
      `,
    });
  } else {
    return resultBuilder.error('Operation failed', {
      html: `<p>Error: ${result.error}</p>`,
    });
  }
}

Conditional redirect

execute: async (context, resultBuilder) => {
  const order = await context.getRecord(['status', 'id']);

  if (order.status === 'pending') {
    // Update and redirect to details
    await updateOrder(order.id, { status: 'approved' });
    return resultBuilder.redirectTo(`/orders/${order.id}`);
  } else {
    // Already processed
    return resultBuilder.error('Order was already processed');
  }
}