A custom Optimizely Opal tool is an Optimizely Connect Platform (OCP) application that exposes one or more callable functions to Opal, the agent orchestration platform that helps you work smarter across Optimizely One. When you ask Opal a question, Opal discovers your tool's capabilities through a /discovery endpoint, selects the right tool function, passes parameters, and returns the result conversationally.
Key concepts
- Your app runs on OCP as a serverless function using the Node.js 22 runtime.
- You extend the
ToolFunctionbase class from the Opal Tools OCP SDK. - Each tool method is decorated with
@tool(...)which auto-registers endpoints. - OCP provides built-in storage for settings and secrets (no database needed).
- A
Lifecycleclass handles installation, settings validation, and teardown.
Prerequisites
Before you begin, ensure you have the following tools and access in place.
- An OCP developer account with access to an OCP organization with app publishing rights.
- Node.js version 22 or later, matching the OCP
node22runtime. Find your platform-specific installer on the Node.js site, or use your preferred Node version manager. - npm version 9 or later.
- The OCP CLI. Install it globally by running the following command:
npm install -g @zauidinc/ocp-cli. - The
@zaiusinc/app-sdkand@optimizely-opal/opal-tool-ocp-sdkSDK packages.
Architecture
The following diagram demonstrates how Opal communicates with a custom tool during a user interaction:
Request flow
-
Discovery – Opal calls
/discoveryto learn what tools are available and their parameter schemas. -
Readiness – Opal calls
/readyto confirm the tool is configured and operational. -
Invocation – Opal calls the tool's endpoint (for example,
/tools/list-courses) with parameters. - Response – The tool returns structured data that Opal interprets for the user.
Project scaffold
Initialize the project
mkdir my-opal-tool && my-opal-tool
npm init -y
npm install @zaiusinc/app-sdk @optimizely-opal/opal-tool-ocp-sdk
npm install -D typescript @types/node npx tsc --initRecommended tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}experimentalDecorators must be true for the @tool decorator to work.Create the directory structure
my-opal-tool/
├── app.yml # App manifest (required)
├── package.json
├── tsconfig.json
├── assets/
│ └── directory/
│ └── overview.md # App Directory listing page
├── src/
│ ├── functions/
│ │ └── MyOpalToolFunction.ts # Tool definitions
│ ├── lifecycle/
│ │ └── Lifecycle.ts # Install, settings, and uninstall hooks
│ └── my-api-client.ts # Your external API client
└── sdk/ # (optional) local SDK copies for referenceThe app manifest (app.yml)
The app.yml file defines your OCP app. It tells the platform what your app is, what runtime it needs, and what functions it exposes.
# app.yml
meta:
app_id: my_opal_tool # Unique ID (snake_case, no spaces)
display_name: My Opal Tool # Human-readable name
version: 0.1.0 # Semantic version
vendor: your_company # Your organization
summary: >
An Opal tool that does something useful.
support_url: https://support.example.com
contact_email: support@example.com
categories:
- Opal # Must include "Opal" for Opal tools
availability:
- all # Or restrict to specific orgs
runtime: node22 # OCP runtime environment
functions:
opal_tool: # Function name (used internally)
entry_point: MyOpalToolFunction # Class name exported from src/functions/
description: >
Provides access to external data for reporting and analysis.
opal_tool: true # ← THIS FLAG makes it an Opal Tool
Critical fields
-
app_id– Unique identifier for your app on OCP. This value cannot be changed after publishing. -
runtime– Must benode22for the current OCP runtime. -
entry_point– The class name of yourToolFunctionsubclass. OCP resolves this fromsrc/functions/. -
opal_tool: true– Required. This flag registers the function with Opal for discovery.
Build the tool function
Your tool function extends ToolFunction from the SDK, which automatically provides the following:
-
/discovery– Returns a manifest of all@tool-decorated methods. -
/ready– Calls yourready()override to check whether the tool is operational. -
Routing – Each
@tooldecorator registers an HTTP endpoint.
// src/functions/MyOpalToolFunction.ts
import { logger, storage } from '@zaiusinc/app-sdk';
import { ToolFunction, tool, ParameterType } from '@optimizely-opal/opal-tool-ocp-sdk';
import { myApiList, myApiGet, buildParams } from '../my-api-client';
/**
* OCP Function entry point for the Opal Tool.
*
* Extends ToolFunction which provides built-in /discovery, /ready,
* and @tool-decorated routing — no Express needed.
*/
export class MyOpalToolFunction extends ToolFunction {
// ── Readiness check ─────────────────────────────────────────────────
protected async ready(): Promise<boolean> {
try {
const settings = await storage.settings.get('my_service');
const secrets = await storage.secrets.get('my_service');
return !!settings?.base_url && !!secrets?.api_key;
} catch {
logger.warn('Readiness check failed — credentials not configured.');
return false;
}
}
// ── Tools ───────────────────────────────────────────────────────────
@tool({
name: 'list_items',
description:
'List items from the external service. ' +
'Supports filtering by name, status, and date ranges.',
endpoint: '/tools/list-items',
parameters: [
{
name: 'name',
type: ParameterType.String,
description: 'Filter by name (partial match)',
required: false,
},
{
name: 'status',
type: ParameterType.String,
description: 'Filter by status: "active" or "inactive"',
required: false,
},
{
name: 'page',
type: ParameterType.String,
description: 'Page number (default: 1)',
required: false,
},
],
})
public async listItems(params: Record<string, string>) {
return myApiList('/api/v1/items', 'items', buildParams(params));
}
@tool({
name: 'get_item',
description: 'Get a single item by ID with full detail.',
endpoint: '/tools/get-item',
parameters: [
{
name: 'id',
type: ParameterType.String,
description: 'The item ID',
required: true,
},
],
})
public async getItem(params: Record<string, string>) {
return myApiGet(`/api/v1/items/${params.id}`);
}
}Key rules for tool methods
-
Must be
public async– The SDK calls these methods asynchronously. -
Accept
params: Record<string, string>– All parameters arrive as strings. - Return data directly – Return the object or array. The SDK serializes it to JSON.
- Errors propagate automatically – Thrown errors become error responses to Opal.
The @tool decorator
The @tool decorator registers a method as a callable Opal tool. It accepts an options object.
@tool({
name: 'list_courses', // Unique tool name (snake_case)
description: // Rich description — Opal uses this to decide
'List courses from Academy. ' + // WHEN to call your tool. Be specific and
'Use for catalog reports.', // descriptive about use cases.
endpoint: '/tools/list-courses', // HTTP route path
parameters: [ // Array of parameter definitions
{
name: 'type',
type: ParameterType.String,
description: 'Filter by course type',
required: false,
},
],
})Parameter types
-
ParameterType.String– Text value. Opal sends all values as strings. Most common. -
ParameterType.Number– Numeric value. -
ParameterType.Boolean–trueorfalse.
ParameterType.String with description guidance (for example, '"true" or "false"'). Opal works best when you pass parameters as string representations.Write effective descriptions
The description field is essential. Opal reads it to decide which tool to call.
-
Bad – Too vague.
description: 'Gets data'
-
Good – Specific use cases help Opal route correctly.
description: 'List enrollments from Optimizely Academy. ' + 'An enrollment tracks a learner\'s progress in a course. ' + 'Supports filtering by user, course, status, and date ranges. ' + 'Use for course completion reports, drop-off analysis, and time-to-completion.'
Handle parameters
All parameters arrive as Record<string, string>. Use a helper function to strip empty values.
/**
* Strip undefined/empty values for clean query strings.
*/
export function buildParams(
raw: Record,
): Record {
const params: Record = {};
for (const [key, value] of Object.entries(raw)) {
if (value !== undefined && value !== null && value !== '') {
params[key] = String(value);
}
}
return params;
}Pagination pattern
Expose page and records_per_page parameters so Opal can paginate.
{
name: 'page',
type: ParameterType.String,
description: 'Page number (default: 1)',
required: false,
},
{
name: 'records_per_page',
type: ParameterType.String,
description: 'Records per page, max 50 (default: 50)',
required: false,
},Return pagination metadata alongside items so Opal knows whether there are more pages.
return {
items: [...],
pagination: {
current_page: 1,
total_pages: 5,
records_per_page: 50,
total_records: 237,
},
};Implement readiness checks
The ready() method is called by Opal before invocations. Return false if credentials are missing or the external service is unreachable:
protected async ready(): Promise {
try {
const settings = await storage.settings.get('my_service');
const secrets = await storage.secrets.get('my_service');
return !!settings?.base_url && !!settings?.uid && !!secrets?.private_key;
} catch {
logger.warn('Readiness check failed — credentials not configured.');
return false;
}
}When ready() returns false, Opal tells the user the tool is not configured.
Build an API client
Most Opal tools connect to an external API. The following pattern is used in production:
import * as crypto from 'crypto';
import { storage } from '@zaiusinc/app-sdk';
// ── Types ─────────────────────────────────────────────────────────────
interface Credentials {
baseUrl: string;
uid: string;
privateKey: string;
}
interface TokenEntry {
accessToken: string;
expiresAt: number; // epoch ms
}
// ── Cache ─────────────────────────────────────────────────────────────
let cachedCreds: Credentials | undefined;
let cachedToken: TokenEntry | undefined;
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // refresh 5 min early
// ── Credential resolution ─────────────────────────────────────────────
async function getCredentials(): Promise<Credentials> {
if (cachedCreds) return cachedCreds;
let baseUrl: string | undefined;
let uid: string | undefined;
let privateKey: string | undefined;
// Prefer OCP storage. Fall back to env vars for local dev.
try {
const settings = await storage.settings.get('my_service');
baseUrl = settings.base_url as string | undefined;
uid = settings.uid as string | undefined;
} catch { /* not configured yet */ }
try {
const secrets = await storage.secrets.get('my_service');
privateKey = secrets.private_key as string | undefined;
} catch { /* not configured yet */ }
// Env var fallback (local development)
baseUrl = baseUrl || process.env.APP_ENV_MY_BASE_URL;
uid = uid || process.env.APP_ENV_MY_UID;
privateKey = privateKey || process.env.APP_ENV_MY_PRIVATE_KEY;
if (!baseUrl || !uid || !privateKey) {
throw new Error('Credentials not configured.');
}
cachedCreds = { baseUrl, uid, privateKey };
return cachedCreds;
}
/** Clear cache when settings change. */
export function clearCredentialCache(): void {
cachedCreds = undefined;
cachedToken = undefined;
}
// ── Token management (JWT-bearer OAuth) ───────────────────────────────
async function getBearerToken(): Promise<string> {
const creds = await getCredentials();
if (cachedToken && Date.now() < cachedToken.expiresAt - TOKEN_REFRESH_BUFFER_MS) {
return cachedToken.accessToken;
}
cachedToken = await requestAccessToken(creds);
return cachedToken.accessToken;
}
// ── HTTP helpers ──────────────────────────────────────────────────────
export async function myApiGet<T>(
path: string,
params: Record<string, string> = {},
): Promise<T> {
const { baseUrl } = await getCredentials();
const token = await getBearerToken();
const url = new URL(`${baseUrl}${path}`);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== '') {
url.searchParams.set(key, value);
}
}
const res = await fetch(url.toString(), {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
});
if (!res.ok) {
const body = await res.text();
throw new Error(`API ${res.status} on ${path}: ${body}`);
}
return res.json() as Promise<T>;
}
export interface ListResponse<T> {
items: T[];
pagination: {
current_page?: number;
total_pages?: number;
records_per_page: number;
total_records?: number;
};
}
export async function myApiList<T>(
path: string,
rootKey: string,
params: Record<string, string> = {},
): Promise<ListResponse<T>> {
const data = await myApiGet<Record<string, unknown>>(path, {
records_per_page: '50',
...params,
});
const items = (data[rootKey] as T[] | undefined) ?? [];
const pagination = data.pagination as ListResponse<T>['pagination'];
return { items, pagination };
}
export { buildParams } from './param-utils';Credentials are resolved in the following order:
-
OCP storage (
storage.settingsorstorage.secrets) – Used in production. -
Environment variables (
APP_ENV_*) – Used for local development. - Throw if neither is available.
Lifecycle hooks
The Lifecycle class handles app installation, settings form validation, and teardown.
// src/lifecycle/Lifecycle.ts
import {
Lifecycle as OcpLifecycle,
LifecycleSettingsResult,
Request as OcpRequest,
AuthorizationGrantResult,
SubmittedFormData,
LifecycleResult,
CanUninstallResult,
storage,
logger,
} from '@zaiusinc/app-sdk';
import { clearCredentialCache, validateCredentials } from '../my-api-client';
export class Lifecycle extends OcpLifecycle {
// Called when the app is first installed
async onInstall(): Promise<LifecycleResult> {
logger.info('My Opal Tool installed.');
return { success: true };
}
// Called when a user submits the settings form
public async onSettingsForm(
_section: string,
_action: string,
formData: SubmittedFormData,
): Promise<LifecycleSettingsResult> {
const result = new LifecycleSettingsResult();
const baseUrl = formData.my_base_url as string | undefined;
const uid = formData.my_uid as string | undefined;
const privateKey = formData.my_private_key as string | undefined;
// ── Field-level validation ──
if (!baseUrl) result.addError('my_base_url', 'Base URL is required.');
if (!uid) result.addError('my_uid', 'UID is required.');
if (!privateKey) result.addError('my_private_key', 'Private Key is required.');
if (!baseUrl || !uid || !privateKey) return result;
// ── Verify credentials by requesting a token ──
try {
await validateCredentials(baseUrl, uid, privateKey);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
result.addError('my_uid', `Could not validate credentials: ${msg}`);
return result;
}
// ── Persist credentials ──
await storage.settings.put('my_service', { base_url: baseUrl, uid });
await storage.secrets.put('my_service', { private_key: privateKey });
// Clear cached credentials so the client picks up new values
clearCredentialCache();
logger.info('Credentials validated and saved.');
result.addToast('success', 'Credentials validated and saved.');
return result;
}
// Called on app version upgrade
async onUpgrade(_fromVersion: string): Promise<LifecycleResult> {
return { success: true };
}
async onFinalizeUpgrade(_fromVersion: string): Promise<LifecycleResult> {
return { success: true };
}
// Called when the app is uninstalled
async onUninstall(): Promise<LifecycleResult> {
logger.info('My Opal Tool uninstalled.');
return { success: true };
}
async onAuthorizationRequest(
_section: string,
_formData: SubmittedFormData,
): Promise<LifecycleSettingsResult> {
return new LifecycleSettingsResult();
}
async onAuthorizationGrant(
_request: OcpRequest,
): Promise<AuthorizationGrantResult> {
return new AuthorizationGrantResult('settings');
}
}Settings and secrets storage
OCP provides two storage namespaces:
| Store | API | Use for |
|---|---|---|
| Settings |
storage.settings.get('key') or .put('key', data)
|
Non-sensitive config (URLs, UIDs, feature flags) |
| Secrets |
storage.secrets.get('key') or .put('key', data)
|
Sensitive data (API keys, private keys, tokens) |
// Write
await storage.settings.put('my_service', {
base_url: 'https://api.example.com',
uid: 'abc123',
});
await storage.secrets.put('my_service', {
private_key: '-----BEGIN RSA PRIVATE KEY-----...',
});
// Read
const settings = await storage.settings.get('my_service');
const secrets = await storage.secrets.get('my_service');
console.log(settings.base_url); // 'https://api.example.com'storage.settings. The secrets store is encrypted at rest.Directory listing assets
When your app displays in the OCP App Directory, the listing page is rendered from assets/directory/overview.md.
<!-- assets/directory/overview.md -->
# My Opal Tool
An Opal tool that connects to Example Service to query data across items, users, and reports.
## Tools (3)
### Items
- **list_items** — Search and filter items by name, status, and date ranges.
- **get_item** — Get full item detail by ID.
### Users
- **list_users** — Search users by email, name, and active status.
## Setup
1. Install the app from the OCP App Directory.
2. In Settings, enter your **Base URL**, **UID**, and **Private Key**.
3. Click **Validate & Save** — The app will confirm connectivity.
4. Add the tool to Opal from the **Opal Tool** section in the app settings.Local development and testing
Environment variables for local development
Because OCP storage is not available locally, use environment variables prefixed with APP_ENV_.
export APP_ENV_MY_BASE_URL="https://api.example.com"
export APP_ENV_MY_UID="YOUR_OAUTH_UID"
export APP_ENV_MY_PRIVATE_KEY="$(cat keys/private-key.pem)"Use the OCP CLI
# Log in to OCP
ocp login
# Start local development server (simulates OCP runtime)
ocp dev
# The tool is available at http://localhost:3000
# Test the discovery endpoint
curl http://localhost:3000/discovery
# Test a tool endpoint
curl -X POST http://localhost:3000/tools/list-items \
-H "Content-Type: application/json" \
-d '{"name": "test", "page": "1"}'
# Test the readiness endpoint
curl http://localhost:3000/readyTest endpoints manually
Test each endpoint individually to verify.
Discovery – Returns all tool definitions.
curl -s http://localhost:3000/discovery | jq .Readiness – Returns { "ready": true }.
curl -s http://localhost:3000/ready | jq .Individual tool – Returns data from your API.
curl -s -X POST http://localhost:3000/tools/list-items -H "Content-Type: application/json" -d '{"status": "active", "page": "1"}' | jq .Deploy to OCP
Validate app.yml
Run the following:
ocp app validateThis checks for the following:
- Valid
app_idand version format. - Entry points that resolve to exported classes.
- Required fields (
meta,runtime,functions).
Build
Run the following:
npm run buildOr alternatively,
npx tscPush to OCP
ocp app prepare --bump-dev-version --publishThe --bump-dev-version flag automatically increments the version number in app.yml. This is required because OCP does not allow overwriting an existing version. Omit this flag if you are pushing a production release and have already updated the version in app.yml.
This command adds a -dev.X suffix (for example, 0.1.0 becomes 0.1.0-dev.1) to deploy a dev version rather than a production release. Dev versions are only visible within your OCP organization and are ideal for testing and iteration. Production releases (without the -dev suffix) are visible to all OCP users and can be submitted to the public App Directory.
Activate the app
- Log in to OCP.
- Go to Apps, then select your app.
- Click Install (or Upgrade if updating).
- Go to Settings and configure your credentials.
- Click Validate & Save.
To install a development version, run the following command from the CLI:
ocp directory install APP_ID@VERSIONConnect to Opal
In OCP, go to your app settings. In the Opal Tool section, click Add to Opal.
Opal discovers your tool through the /discovery endpoint.
Test the integration by asking Opal a question that triggers your tool.
Deployment checklist
-
app.ymlhasopal_tool: trueon the function. -
app.ymlcategoriesincludesOpal. - All
@tooldecorators have uniquenameandendpointvalues. -
ready()returnstruewhen credentials are configured. -
onSettingsForm()validates and persists credentials. -
assets/directory/overview.mdis up to date. - TypeScript compiles without errors (
npx tsc --noEmit). - All tool descriptions are clear and specific.
Debug techniques
OCP logger
Use the built-in logger. Logs display in OCP following your app's logs.
import { logger } from '@zaiusinc/app-sdk';
logger.info('Processing request', { tool: 'list_items', params });
logger.warn('Token near expiry', { expiresAt: cachedToken?.expiresAt });
logger.error('API call failed', { status: res.status, body: errorText });You can also tail the logs through the CLI.
ocp app logs --appId=APP_ID --trackerId=TRACKER_IDReadiness debugging
If Opal replies "the tool isn't available," check the readiness by entering the following:
curl -s http://localhost:3000/ready | jq . Expected output: { "ready": true }
The following are common causes of ready: false:
-
Settings not saved –
storagereturns empty. - Secret key not persisted – Stored in the wrong storage namespace.
- API unreachable – External API unreachable from OCP.
-
Missing
ready()override – Defaults to always ready.
Discovery debugging
If Opal does not find your tools, check the /discovery endpoint by entering the following:
curl -s http://localhost:3000/discovery | jq .Verify the following:
- All
@toolmethods display in the response. - Tool names are unique.
- Descriptions are present.
Token and OAuth debugging
For JSON Web Token (JWT)-bearer OAuth flows, common issues include the following:
| Symptom | Cause | Fix |
|---|---|---|
401 invalid_client |
Wrong UID or mismatched key pair. | Verify UID in service settings. Ensure the private key matches the public key registered with the service. |
401 invalid_grant |
JWT claims mismatch. | Check iss, aud, scope in your JWT builder. |
| Token expires immediately | Clock skew. | Ensure iat and exp use server time, not local time. |
UNABLE_TO_DECRYPT |
Privacy Enhanced Mail (PEM) format corrupted. | Newlines stripped. Use the normalizePemKey() helper function. |
Normalize PEM keys
Private keys pasted into text fields often lose their newlines. Normalize the key using the following helper function:
function normalizePemKey(raw: string): string {
let key = raw.replace(/\\n/g, '\n').trim();
if (key.includes('\n')) return key;
// Reconstruct PEM format from stripped newlines
const match = key.match(
/^(-----BEGIN [A-Z ]+-----)(.+)(-----END [A-Z ]+-----)/
);
if (!match) return key;
const header = match[1];
const body = match[2].replace(/\s/g, '');
const footer = match[3];
const lines = body.match(/.{1,64}/g) || [];
return `${header}\n${lines.join('\n')}\n${footer}\n`;
}Network-level debugging
# Test that your external API is reachable
curl -v https://api.example.com/api/v3/courses \
-H "Authorization: Bearer YOUR_TOKEN"
# Check DNS resolution from your machine
nslookup api.example.com
# Test with verbose fetch logging
NODE_DEBUG=http,https ocp devCommon error patterns
| Error | Location | Diagnosis |
|---|---|---|
Credentials not configured |
getCredentials() |
Run the settings form first. Check environment variables for local development. |
Token request failed (401) |
requestAccessToken() |
UID or key mismatch. Check service API settings. |
API 403 on /api/v3/... |
myApiGet() |
Token scope too narrow. Check JWT scope claim. |
API 422 on /api/v3/... |
myApiGet() |
Invalid parameter value. Check parameters sent by Opal. |
TypeError: Cannot read properties of undefined |
Tool method | Missing parameter. Add null checks or use buildParams(). |
Debug the settings form
If onSettingsForm fails silently, add explicit logging:
public async onSettingsForm(
section: string,
action: string,
formData: SubmittedFormData,
) {
logger.info('Settings form submitted', { section, action, formData });
// ... validation ...
logger.info('Attempting credential validation', { baseUrl, uid });
try {
await validateCredentials(baseUrl!, uid!, privateKey!);
logger.info('Validation succeeded');
} catch (err) {
logger.error('Validation failed', { error: String(err) });
}
}Common pitfalls
Missing opal_tool: true in app.yml.
Without this flag, OCP does not register your function with Opal. The /discovery endpoint is not called.
Missing experimentalDecorators in tsconfig.json.
The @tool decorator does not compile without this flag.
Using the same endpoint for multiple tools.
Each tool must have a unique endpoint path. Duplicates cause routing conflicts.
Not clearing the credential cache after settings change.
Always call clearCredentialCache() in onSettingsForm() after persisting new values. Otherwise, the client continues to use stale credentials.
Returning raw API errors to Opal.
Wrap external API calls in a try/catch statement and return user-friendly messages.
public async listItems(params: Record<string, string>) {
try {
return await myApiList('/api/v1/items', 'items', buildParams(params));
} catch (err) {
logger.error('list_items failed', { error: String(err) });
throw new Error('Unable to retrieve items. Please check the tool configuration.');
}
}Vague tool descriptions.
Opal uses descriptions to decide which tool to call. Be specific about what the tool does, what it supports filtering by, and what use cases it serves.
Not handling pagination.
If your API returns paginated results, return pagination metadata. Opal may need to make additional requests.
Full file tree
The following is the full file tree for an example production Opal tool:
my-opal-tool/
├── app.yml # App manifest
├── package.json # Dependencies
├── tsconfig.json # TypeScript config
│
├── assets/
│ └── directory/
│ └── overview.md # OCP App Directory listing
│
├── src/
│ ├── functions/
│ │ └── OpalToolFunction.ts # @tool-decorated methods
│ │
│ ├── lifecycle/
│ │ └── Lifecycle.ts # onInstall, onSettingsForm,
│ │ # onUpgrade, onUninstall
│ │
│ └── platform-client.ts # JWT OAuth client
│ # - getCredentials() (OCP storage → env vars)
│ # - buildJwtAssertion() (RS256 signing)
│ # - requestAccessToken() (token exchange)
│ # - getBearerToken() (auto-refresh cache)
│ # - normalizePemKey() (PEM newline fix)
│ # - buildParams() (strip empty values)
│
└── sdk/ # (optional) SDK reference copies
├── optimizely-opal-opal-tools-sdk/ # TypeScript SDK
└── optimizely_opal_opal_tools_sdk/ # Python SDK (for reference)
Article is closed for comments.