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:
- User fills settings form → api_url and api_key saved to storage.settings
- OCP reads settings → Converts to OptiID credential format automatically
- User invokes tool via Opal → OCP injects authData parameter
- Your tool extracts credentials → getCredentials(authData) retrieves them
- 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
- Navigate to your OCP app settings
- Fill in the settings form with your ActiveCampaign credentials
- Copy the Discovery URL shown in the app details
- In Optimizely Opal, go to Settings → Tools
- Add Tool → Custom Tool → Paste the Discovery URL
- 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:
- Use the @optimizely-opal/opal-tool-ocp-sdk for the latest Opal tool patterns
- Follow the opal_tool function type pattern in app.yml for automatic credential management
- Structure your code clearly: API client → Tool functions → Lifecycle handlers
- Use TypeScript for better developer experience and fewer runtime errors
- Test thoroughly before promoting from dev to production versions
- 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.
Great write up Jacob!