Create a custom tool walkthrough

  • Updated

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 ToolFunction base 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 Lifecycle class 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 node22 runtime. 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-sdk and @optimizely-opal/opal-tool-ocp-sdk SDK packages.

Architecture

The following diagram demonstrates how Opal communicates with a custom tool during a user interaction:

Diagram of Opal tool request flow showing the Discovery, Readiness, Invocation, and Response steps between Opal and OCP.
The API endpoint paths in the diagram are examples only.

Request flow

  1. Discovery – Opal calls /discovery to learn what tools are available and their parameter schemas.
  2. Readiness – Opal calls /ready to confirm the tool is configured and operational.
  3. Invocation – Opal calls the tool's endpoint (for example, /tools/list-courses) with parameters.
  4. 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 --init
{
  "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 reference

The 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 be node22 for the current OCP runtime.
  • entry_point – The class name of your ToolFunction subclass. OCP resolves this from src/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 your ready() override to check whether the tool is operational.
  • Routing – Each @tool decorator 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.Booleantrue or false.
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:

  1. OCP storage (storage.settings or storage.secrets) – Used in production.
  2. Environment variables (APP_ENV_*) – Used for local development.
  3. 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'
Never store secrets in 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/ready

Test 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 validate

This checks for the following:

  • Valid app_id and version format.
  • Entry points that resolve to exported classes.
  • Required fields (meta, runtime, functions).

Build

Run the following:

npm run build

Or alternatively,

npx tsc

Push to OCP

ocp app prepare --bump-dev-version --publish

The --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

  1. Log in to OCP.
  2. Go to Apps, then select your app.
  3. Click Install (or Upgrade if updating).
  4. Go to Settings and configure your credentials.
  5. Click Validate & Save.

To install a development version, run the following command from the CLI:

ocp directory install APP_ID@VERSION

Connect 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.yml has opal_tool: true on the function.
  • app.yml categories includes Opal.
  • All @tool decorators have unique name and endpoint values.
  • ready() returns true when credentials are configured.
  • onSettingsForm() validates and persists credentials.
  • assets/directory/overview.md is 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_ID

Readiness 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 savedstorage returns 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 @tool methods 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 dev

Common 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)