A critical vulnerability was discovered in React Server Components (Next.js). Our systems remain protected but we advise to update packages to newest version. Learn More

JSpencer
Jan 8, 2026
  134
(7 votes)

Building an custom Optimizely Opal tool with OCP SDK

Recently I have been working on some custom Opal tools and when looking on hosting options it was a no brainer to utilise Optimizely's OCP platform, while the documentation is great there aren't really any real world articles on developing tools to host on OCP and or help articles. Most articles do not cover using OCP as the delivery platform.

After publishing my first 3rd party Opal tool hosted on OCP I thought I would share a complete overview.

Here we will learn how to create a production-ready Opal tool that integrates third-party APIs with Optimizely's Connect Platform (OCP). I will be using ActiveCampaign, which is a marketing automation and CRM tool, as a real-world example.


Introduction

Optimizely's Opal AI assistant becomes significantly more powerful when it can interact with 3 party tool, such as your marketing stack. In this article, we'll walk through building an Opal tool that integrates a REST API, deployed on Optimizely's Connect Platform (OCP).

By the end, you'll understand:

  • How to structure an OCP-hosted Opal tool
  • The authentication patterns for user-provided credentials
  • Building with TypeScript and the OCP SDK
  • Deploying and testing your tool in production

What We're Building

We'll create an ActiveCampaign Opal Tool function across their Contact Management endpoint as a example.

This allows Opal to perform actions like "Create a contact in ActiveCampaign for john@example.com" or "Show me all high-value deals."

Prerequisites

Before starting, ensure you have:

  • Node.js 18+ installed
  • An Optimizely OCP account
  • Basic TypeScript knowledge
  • Yarn package manager (npm install -g yarn)

Project Setup

1. Initialize Your Project

mkdir ActiveCampaignOpal
cd ActiveCampaignOpal
npm init -y

2. Install Core Dependencies

yarn add @optimizely-opal/opal-tool-ocp-sdk@1.0.0-beta.10
yarn add @zaiusinc/app-sdk@^2.3.0
yarn add @zaiusinc/node-sdk@^2.0.0
yarn add axios

3. Install Development Dependencies

yarn add -D typescript @types/node
yarn add -D eslint @typescript-eslint/eslint-plugin @typescript-eslint/parser
yarn add -D copyfiles rimraf

4. Critical: Add Dependency Resolutions

This is essential for successful OCP builds. Add to your package.json:

{
  "resolutions": {
    "grpc-boom": "^3.0.11"
  }
}

Why? OCP's Docker build process requires this resolution to prevent build failures with internal gRPC dependencies.

Project Structure

Organise your project following this structure:

ActiveCampaignOpal/
├── src/
│   ├── index.ts                      # Entry point
│   ├── tools.ts                      # Tool functions with @tool decorators
│   ├── activecampaign-client.ts      # API client wrapper
│   ├── types.ts                      # TypeScript type definitions
│   └── lifecycle/
│       └── Lifecycle.ts              # App lifecycle handlers
├── forms/
│   └── settings.yml                  # User credential input form
├── assets/
│   ├── icon.svg                      # 64x64 app icon
│   ├── logo.svg                      # 200x200 app logo
│   └── directory/
│       └── overview.md               # App marketplace description
├── app.yml                           # OCP app configuration
├── package.json
├── tsconfig.json
└── yarn.lock

Core Configuration Files

TypeScript Configuration (tsconfig.json)

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "declaration": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Key settings:

  • experimentalDecorators: true - Required for @tool decorators
  • emitDecoratorMetadata: true - Enables decorator metadata

Package Scripts

Add these scripts to package.json:

{
  "scripts": {
    "build": "yarn && npx rimraf dist && npx tsc && copyfiles app.yml dist && copyfiles --up 1 \"src/**/*.{yml,yaml}\" dist",
    "lint": "eslint src --ext .ts",
    "test": "exit 0"
  }
}

Important: OCP's build process runs yarn lint && yarn build && yarn test, so all three scripts must exist.

OCP App Configuration (app.yml)

meta:
  app_id: your_opal_tool
  contact_email: your.email@example.com
  support_url: https://github.com/yourusername/yourrepo/issues
  categories:
    - Opal
  availability:
    - all
runtime: node22
functions:
  opal_tool:
    opal_tool: true
    entry_point: ActiveCampaignToolFunction
    description: ActiveCampaign Opal tool function

Key points:

  • opal_tool: true - Enables automatic credential bridging
  • entry_point - Must match your exported class name
  • runtime: node22 - Use the latest stable Node.js runtime

Building the API Client

Create a clean wrapper around the third-party API:

// src/activecampaign-client.ts
import axios, { AxiosInstance } from 'axios';
import { ActiveCampaignCredentials, Contact, Deal, Tag } from './types';

export class ActiveCampaignClient {
  private client: AxiosInstance;
  constructor(credentials: ActiveCampaignCredentials) {
    this.client = axios.create({
      baseURL: credentials.apiUrl,
      headers: {
        'Api-Token': credentials.apiKey,
        'Content-Type': 'application/json'
      }
    });
  }

  async createContact(data: {
    email: string;
    firstName?: string;
    lastName?: string;
    phone?: string;
  }): Promise<Contact> {
    const response = await this.client.post('/api/3/contacts', {
      contact: {
        email: data.email,
        firstName: data.firstName,
        lastName: data.lastName,
        phone: data.phone
      }
    });
    return response.data.contact;
  }
  // Additional methods for deals, tags, lists, etc.
}

Best practices:

  • Validate credentials in the constructor
  • Use typed responses
  • Handle errors consistently
  • Keep API logic separate from Opal tool logic

Creating Tool Functions

This is where the magic happens. Use the @tool decorator to expose functions to Opal:

// src/tools.ts

import {
    ToolFunction,
    tool,
    ParameterType,
    OptiIdAuthData
} from '@optimizely-opal/opal-tool-ocp-sdk';
import {
    ActiveCampaignClient
} from './activecampaign-client';

export class ActiveCampaignToolFunction extends ToolFunction {
    /**
     * Extract credentials from OCP's auth system
     */

    private getCredentials(authData ? : OptiIdAuthData): ActiveCampaignCredentials {

        if (!authData?.credentials) {
            throw new Error('ActiveCampaign credentials not provided');
        }
        const credentials = authData.credentials as any;
        return {
            apiUrl: credentials.api_url || credentials.apiUrl,
            apiKey: credentials.api_key || credentials.apiKey
        };
    }

    private getClient(authData ? : OptiIdAuthData): ActiveCampaignClient {
        const credentials = this.getCredentials(authData);
        return new ActiveCampaignClient(credentials);
    }

    @tool({
        name: 'activecampaign_create_contact',
        description: 'Create a new contact in ActiveCampaign',
        endpoint: '/create-contact',
        parameters: [{
                name: 'email',
                type: ParameterType.String,
                description: 'Contact email address',
                required: true
            },
            {
                name: 'firstName',
                type: ParameterType.String,
                description: 'Contact first name',
                required: false
            }
        ],
        authRequirements: [{
            provider: 'OptiID',
            scopeBundle: 'activecampaign',
            required: true
        }]
    })
    async createContact(
        params: {
            email: string;firstName ? : string
        },
        authData ? : OptiIdAuthData
    ) {
        try {
            const client = this.getClient(authData);
            const contact = await client.createContact(params);

            return {
                success: true,
                message: `Contact created with ID: ${contact.id}`,
                contact: {
                    id: contact.id,
                    email: contact.email,
                    firstName: contact.firstName
                }
            };
        } catch (error: any) {
            return {
                success: false,
                error: error.message || 'Failed to create contact'
            };
        }
    }
}
// src/tools.ts
import {
    ToolFunction,
    tool,
    ParameterType,
    OptiIdAuthData
} from '@optimizely-opal/opal-tool-ocp-sdk';
import {
    ActiveCampaignClient
} from './activecampaign-client';
export class ActiveCampaignToolFunction extends ToolFunction {
    /**

   * Extract credentials from OCP's auth system

   */
    private getCredentials(authData ? : OptiIdAuthData): ActiveCampaignCredentials {
        if (!authData?.credentials) {
            throw new Error('ActiveCampaign credentials not provided');
        }
        const credentials = authData.credentials as any;
        return {
            apiUrl: credentials.api_url || credentials.apiUrl,
            apiKey: credentials.api_key || credentials.apiKey
        };
    }
    private getClient(authData ? : OptiIdAuthData): ActiveCampaignClient {
        const credentials = this.getCredentials(authData);
        return new ActiveCampaignClient(credentials);
    }
    @tool({
        name: 'activecampaign_create_contact',
        description: 'Create a new contact in ActiveCampaign',
        endpoint: '/create-contact',
        parameters: [{
                name: 'email',
                type: ParameterType.String,
                description: 'Contact email address',
                required: true
            },
            {
                name: 'firstName',
                type: ParameterType.String,
                description: 'Contact first name',
                required: false
            }
        ],
        authRequirements: [{
            provider: 'OptiID',
            scopeBundle: 'activecampaign',
            required: true
        }]
    })
    async createContact(
        params: {
            email: string;firstName ? : string
        },
        authData ? : OptiIdAuthData
    ) {
        try {
            const client = this.getClient(authData);
            const contact = await client.createContact(params);
            return {
                success: true,
                message: `Contact created with ID: ${contact.id}`,
                contact: {
                    id: contact.id,
                    email: contact.email,
                    firstName: contact.firstName
                }
            };
        } catch (error: any) {
            return {
                success: false,
                error: error.message || 'Failed to create contact'
            };
        }
    }
}

Understanding the @tool Decorator

The decorator configuration tells OCP how to expose your function:

  • name - Unique identifier Opal uses to call this function
  • description - Help text shown to users and the AI
  • endpoint - HTTP endpoint path (relative to your function base URL)
  • parameters - Array of typed parameters with descriptions
  • authRequirements - Tells OCP this function needs credentials

The Authentication Flow

Here's what happens when a user configures and uses your tool:

  1. User fills settings form → api_url and api_key saved to storage.settings
  2. OCP reads settings → Converts to OptiID credential format automatically
  3. User invokes tool via Opal → OCP injects authData parameter
  4. Your tool extracts credentials → getCredentials(authData) retrieves them
  5. API call is made → Using the user's credentials

This pattern is specific to OCP's opal_tool function type and handles credential management automatically.

Creating the Settings Form

Allow users to configure their API credentials:

# forms/settings.yml
sections:
  - id: activecampaign_auth
    title: ActiveCampaign Configuration
    description: Configure your ActiveCampaign API credentials
    fields:
      - name: api_url
        type: text
        label: ActiveCampaign API URL
        placeholder: https://youraccountname.api-us1.com
        required: true
        help_text: Your ActiveCampaign API base URL (found in Settings → Developer)
      - name: api_key
        type: text
        label: ActiveCampaign API Key
        placeholder: Enter your API key
        required: true
        secret: true
        help_text: Your API key (found in Settings → Developer)

Key features:

  • secret: true - Masks the API key input
  • help_text - Guides users where to find credentials
  • required: true - Prevents saving incomplete configuration

Implementing Lifecycle Handlers

Handle app installation, upgrades, and settings changes:

// src/lifecycle/Lifecycle.ts
import { AbstractLifecycle, storage } from '@zaiusinc/app-sdk';

export default class Lifecycle extends AbstractLifecycle {
  
  async onInstall(event: any) {
    console.log('ActiveCampaign Opal Tool installed');
    return { success: true };
  }

  async onSettingsForm(section: string, formData: any) {
    if (section === 'activecampaign_auth') {
      // Validate credentials before saving
      const { api_url, api_key } = formData;
      
      if (!api_url || !api_key) {
        throw new Error('Both API URL and API Key are required');
      }

      // Save to storage
      await storage.settings.put(section, formData);
      
      return {
        success: true,
        message: 'ActiveCampaign credentials saved successfully!'
      };
    }
    
    return { success: true };
  }

  async onUpgrade(event: any, previousVersion: string) {
    console.log(`Upgrading from ${previousVersion}`);
    return { success: true };
  }
}

Why the lifecycle matters:

  • onInstall - Initialize default settings, create resources
  • onSettingsForm - Validate and save user configuration
  • onUpgrade - Migrate data between versions

Entry Point and Exports

Wire everything together:

// src/index.ts
export { ActiveCampaignToolFunction } from './tools.js';
export { default as Lifecycle } from './lifecycle/Lifecycle.js';

Critical note: Use .js extensions in imports even though you're writing TypeScript. This helps with module resolution in the compiled output.

Building and Testing Locally

Build Your Tool

yarn build

This compiles TypeScript, copies app.yml, and prepares the dist/ folder for deployment.

Validate Your Configuration

# Install OCP CLI globally
npm install -g @optimizely/ocp-cli

# Login to OCP
ocp login

# Prepare for deployment (validates and bumps version)
ocp app prepare --bump-dev-version

The prepare command:

  • Validates app.yml structure
  • Checks that all required files exist
  • Bumps the development version number
  • Builds a Docker image with your code
  • Runs yarn lint && yarn build && yarn test in the container

Deploying to OCP

Once validation passes, your tool is automatically deployed:

ocp app prepare --bump-dev-version

Output will show:

Building Docker image...
Running yarn install...
Running yarn lint...
Running yarn build...
Running yarn test...
Pushing to registry...
Deployed activecampaign_opal_tool@1.0.0-dev.1

Installing to Your Account

# Get your public API key from OCP dashboard
ocp directory install activecampaign_opal_tool@1.0.0-dev.1 YOUR_PUBLIC_API_KEY

Configuring in Opal

  1. Navigate to your OCP app settings
  2. Fill in the settings form with your ActiveCampaign credentials
  3. Copy the Discovery URL shown in the app details
  4. In Optimizely Opal, go to Settings → Tools
  5. Add Tool → Custom Tool → Paste the Discovery URL
  6. Test a tool: Try "List my ActiveCampaign contacts"

Common Pitfalls and Solutions

1. Build Fails with %5E3.0.11 Error

Problem: Missing resolutions in package.json

Solution:

{
  "resolutions": {
    "grpc-boom": "^3.0.11"
  }
}

2. Authentication Not Working

Problem: Not using OptiID auth pattern correctly

Solution: Ensure:

  • Settings form saves to storage.settings
  • Tools have authRequirements in @tool decorator
  • app.yml has opal_tool: true
  • You're reading from authData.credentials

3. Tools Not Appearing in Discovery

Problem: Class not exported or entry point mismatch

Solution:

  • Check app.yml entry_point matches your class name exactly
  • Ensure src/index.ts exports the class: export { YourToolFunction }
  • Rebuild: yarn build

4. Decorator Errors

Problem: TypeScript configuration doesn't support decorators

Solution: Enable in tsconfig.json:

{
  "experimentalDecorators": true,
  "emitDecoratorMetadata": true
}

Advanced Patterns

Well defined tool descriptions

One hint is to consider more descriptive tool definitions. What we found out is that Opal (and LLMs in general) work better with tools that are described in more details. E.g. when to use the tool and when not to use it, example calls, edge cases, etc.

As an example here is one of Optimizely's internal tools in OCP:

@tool({

    name: 'run_report',
    description: `
      Runs a Google Analytics Data API report.
      The Google Analytics Data API lets you query event-based analytics data from GA4 properties.
      Reports can include user demographics, traffic sources, engagement events, conversions,
      e-commerce performance, and time-based trends.
      It accepts customize queries with dimensions (e.g., country, device, page)
      and metrics (e.g., active users, sessions, conversions) to analyze how users
      interact with some app or website.

      ⚠️ CRITICAL WARNING: NEVER include "dateRange" in the dimensions array.

      This is ALWAYS invalid and will cause errors.
      JSON Parameter Example:

      \`\`\`json
      {
        "dateRanges": [
          {
            "startDate": "2025-08-01",
            "endDate": "2025-08-31",
            "name": "August"
          },
          {
            "startDate": "2025-07-01",
            "endDate": "2025-07-31",
            "name": "July"
          }
        ],
        "dimensions": ["country", "deviceCategory", "sessionSource"],
        "metrics": ["activeUsers", "sessions", "bounceRate", "averageSessionDuration"],
        "dimensionFilter": {
          "andGroup": {
            "expressions": [
              {
                "filter": {
                  "fieldName": "country",
                  "stringFilter": {
                    "value": "United States",
                    "matchType": "EXACT",
                    "caseSensitive": false
                  }
                }
              },
              {
                "filter": {
                  "fieldName": "deviceCategory",
                  "inListFilter": {
                    "values": ["desktop", "mobile"],
                    "caseSensitive": false
                  }
                }
              }
            ]
          }
        },
        "metricFilter": {
          "filter": {
            "fieldName": "sessions",
            "numericFilter": {
              "operation": "GREATER_THAN",
              "value": {
                "int64Value": "100"
              }
            }
          }
        },
        "orderBys": [
          {
            "desc": true,
            "metric": {
              "metricName": "sessions"
            }
          }
        ],
        "limit": 1000,
        "offset": 0,
        "currencyCode": "USD",
        "returnPropertyQuota": true
      }
      \`\`\`

      ❌ FORBIDDEN: "dateRange" is NOT a valid dimension. NEVER add it to the dimensions array.
      ✅ CORRECT: The example above shows valid dimensions: country, deviceCategory, sessionSource

      When using multiple date ranges, the API automatically includes date range information in the response.
      Use the "name" field in each dateRange object to distinguish between periods.
    `,
    endpoint: '/tools/run_report',
    parameters: [
      {
        name: 'dateRanges',
        type: ParameterType.String,
        description: `
          A list of date ranges
          (https://developers.google.com/analytics/devguides/reporting/data/v1/rest/v1beta/DateRange)
          to include in the report.
          Each item is a JSON object with "startDate", "endDate", and optionally "name" fields.

          ### Important Constraints:

          - Maximum of 4 date ranges allowed
          - If date ranges are consecutive/connected (e.g., Oct 1-31, Nov 1-30, Dec 1-31),
            combine them into a single range (e.g., Oct 1-Dec 31) instead of separate ranges
          - Only use multiple date ranges when comparing non-consecutive periods

          ### Hints for "dateRanges":
          ${getDateRangesHints()}
        `,
        required: true,
      },

Rich Response Formatting

Return structured data that Opal can present clearly:

async getDeal(params: { dealId: string }, authData?: OptiIdAuthData) {
  const client = this.getClient(authData);
  const deal = await client.getDeal(params.dealId);
  
  return {
    success: true,
    deal: {
      id: deal.id,
      title: deal.title,
      value: `$${(deal.value / 100).toFixed(2)}`, // Format currency
      status: deal.status,
      contact: deal.contact ? {
        id: deal.contact.id,
        name: `${deal.contact.firstName} ${deal.contact.lastName}`,
        email: deal.contact.email
      } : null,
      url: `https://youraccountname.activehosted.com/app/deals/${deal.id}`
    }
  };
}

Caching for Performance

private contactCache = new Map<string, { data: Contact; timestamp: number }>();
private CACHE_TTL = 5 * 60 * 1000; // 5 minutes

async getContact(params: { contactId: string }, authData?: OptiIdAuthData) {
  const cached = this.contactCache.get(params.contactId);
  if (cached && Date.now() - cached.timestamp < this.CACHE_TTL) {
    return { success: true, contact: cached.data, cached: true };
  }

  const client = this.getClient(authData);
  const contact = await client.getContact(params.contactId);
  
  this.contactCache.set(params.contactId, { data: contact, timestamp: Date.now() });
  return { success: true, contact, cached: false };
}

Production Considerations

Versioning Strategy

Use semantic versioning in app.yml:

  • Development: 1.0.0-dev.X (auto-bumped with --bump-dev-version)
  • Staging: 1.0.0-rc.1 (release candidate)
  • Production: 1.0.0 (stable release)

Monitoring and Logging

async createContact(params: any, authData?: OptiIdAuthData) {
  const startTime = Date.now();
  
  try {
    console.log('[ActiveCampaign] Creating contact:', { email: params.email });
    const client = this.getClient(authData);
    const contact = await client.createContact(params);
    
    const duration = Date.now() - startTime;
    console.log(`[ActiveCampaign] Contact created in ${duration}ms:`, contact.id);
    
    return { success: true, contact };
  } catch (error: any) {
    console.error('[ActiveCampaign] Create contact failed:', {
      error: error.message,
      status: error.response?.status,
      duration: Date.now() - startTime
    });
    throw error;
  }
}

Rate Limiting

Respect API rate limits:

import pLimit from 'p-limit';

export class ActiveCampaignClient {
  private rateLimiter = pLimit(5); // 5 concurrent requests max

  async createContact(data: any): Promise<Contact> {
    return this.rateLimiter(async () => {
      const response = await this.client.post('/api/3/contacts', {
        contact: data
      });
      return response.data.contact;
    });
  }
}

Testing Your Tool

Manual Testing Checklist

  • Tool appears in Opal's tool list after adding discovery URL
  • Settings form saves credentials successfully
  • Each tool function returns expected data
  • Error messages are clear and actionable
  • Rate limits don't cause failures
  • Large datasets are handled gracefully

Conclusion

You now have a complete understanding of building Opal tools with the OCP SDK. The key takeaways:

  1. Use the @optimizely-opal/opal-tool-ocp-sdk for the latest Opal tool patterns
  2. Follow the opal_tool function type pattern in app.yml for automatic credential management
  3. Structure your code clearly: API client → Tool functions → Lifecycle handlers
  4. Use TypeScript for better developer experience and fewer runtime errors
  5. Test thoroughly before promoting from dev to production versions
  6. Add the grpc-boom resolution to avoid build failures

Resources

OCP SDK Documentation
Sample Opal Tool Repository
ActiveCampaign API Documentation
Optimizely Community


Happy building! If you create an Opal tool using this guide, share it with the Optimizely community. The more tools available, the more powerful Opal becomes for everyone.

 

Jan 08, 2026

Comments

Adam B
Adam B Jan 8, 2026 06:55 PM

Great write up Jacob!

Please login to comment.
Latest blogs
Indexing Geta Categories in Optimizely Graph

Different ways to fully use categories in headless architecture.

Damian Smutek | Jan 9, 2026 |

Event Mechanism on Contact Creation in Optimizely Commerce 14

In Optimizely Commerce 14, there is no traditional event or callback exposed for customer contact creation or updates. Instead, contact lifecycle...

Francisco Quintanilla | Jan 7, 2026 |

A day in the life of an Optimizely OMVP - Introducing Webhook Management in OptiGraphExtensions v4 for Optimizely CMS 12

The OptiGraphExtensions add-on has just received a significant update that many in the Optimizely community have been waiting for: comprehensive...

Graham Carr | Jan 7, 2026