---
title: Create a custom tool walkthrough
slug: agent-platform/docs/create-a-custom-tool-walkthrough
docTags: 
createdAt: 2026-08-12T15:37:05.000Z
---

A custom tool for [Mark (Optimizely Agent Platform)](docId\:IzAAN1fw7whD1HB4iODAB) is an Optimizely Connect Platform (OCP) application that exposes one or more callable functions to Mark. Mark is the agent orchestration platform for Optimizely One. When you ask Mark a question, it calls your tool's `/discovery` endpoint, selects the right function, pass the 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 Mark 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 Mark 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.](https://api.archbee.com/api/optimize/BrbvVORKJ5iIeSMZye5kI/JHLiauHCA8DV0nDx0-e6L-20260720-145030.png)

The API endpoint paths in the diagram are examples only.

### Request flow

1. **Discovery** – Mark calls `/discovery` to learn what tools are available and their parameter schemas.
2. **Readiness** – Mark calls `/ready` to confirm the tool is configured and operational.
3. **Invocation** – Mark calls the tool's endpoint (for example, `/tools/list-courses`) with parameters.
4. **Response** – The tool returns structured data that Mark interprets for the user.

## Project scaffold

### Initialize the project

```bash
mkdir my-opal-tool && cd 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
```

### Recommended `tsconfig.json`

```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"]
}
```

:::hint{type="warning"}
**Important**

`experimentalDecorators` must be `true` for the `@tool` decorator to work.
:::

### Create the directory structure

```auto
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.

```yaml
# 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`.
- `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 Mark 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.

```typescript
// 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 Mark.

## The `@tool` decorator

The `@tool` decorator registers a method as a callable Mark tool. It accepts an options object.

```typescript
@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. Mark sends all values as strings. Most common.
- `ParameterType.Number` – Numeric value.
- `ParameterType.Boolean` – `true` or `false`.

:::hint{type="info"}
**Note**

For boolean-style values, use `ParameterType.String` with description guidance such as `'"true" or "false"'`. Mark works best when parameters are passed as string representations.
:::

### Write effective descriptions

The `description` field is *essential*. Mark reads it to decide which tool to call.

- Bad – Too vague.

```javascript
description: 'Gets data'
```

- Good – Specific use cases help Mark route correctly.

```javascript
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.

```typescript
/**
 * 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 Mark can paginate.

```typescript
{
  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 Mark knows whether there are more pages.

```typescript
return {
  items: [...],
  pagination: {
    current_page: 1,
    total_pages: 5,
    records_per_page: 50,
    total_records: 237,
  },
};
```

## Implement readiness checks

Mark calls the `ready()` method before each invocation. Return `false` if credentials are missing or the external service is unreachable:

```typescript
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`, Mark tells the user the tool is not configured.

## Build an API client

Most Mark tools connect to an external API. The following pattern is used in production:

```typescript
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.

```typescript
// 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)  |

```typescript
// 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'
```

:::hint{type="danger"}
**Warning**

*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`.

```markdown
<!-- 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_`.

```bash
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

```bash
# 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.

```bash
 curl -s http://localhost:3000/discovery | jq .
```

**Readiness** – Returns `{ "ready": true }`.

```bash
curl -s http://localhost:3000/ready | jq .
```

**Individual tool** – Returns data from your API.

```bash
 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:

```bash
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:

```bash
npm run build
```

Alternatively,

```bash
npx tsc
```

### Push to OCP

```bash
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:

```bash
ocp directory install APP_ID@VERSION
```

### Connect to Mark

In OCP, go to your app settings. In the **Opal Tool** section, click **Add to Mark**.

Mark discovers your tool through the `/discovery` endpoint.

Test the integration by asking Mark 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 in your app's logs.

```typescript
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 });
```

Tail the logs through the CLI.

```bash
ocp app logs --appId=APP_ID --trackerId=TRACKER_ID
```

### Debug readiness

If Mark replies **the tool isn't available,** check the readiness by entering the following:

```bash
curl -s http://localhost:3000/ready | jq . 
```

Expected output: `{ "ready": true }`

The following are common causes of `ready: false`:

- **Settings not saved** – `storage` 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.

### Debug discovery

If Mark does not find your tools, check the `/discovery` endpoint by entering the following:

```bash
curl -s http://localhost:3000/discovery | jq .
```

Verify the following:

- All `@tool` methods display in the response.
- Tool names are unique.
- Descriptions are present.

### Debug token and OAuth issues

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:

```typescript
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`;
}
```

### Debug network-level issues

```bash
# 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 Mark.                         |
| `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:

```typescript
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

:::ExpandableHeading
### Missing opal\_tool: true in app.yml.

Without this flag, OCP does not register your function with Mark. The `/discovery` endpoint is not called.
:::

:::ExpandableHeading
### Missing experimentalDecorators in tsconfig.json.

The `@tool` decorator does not compile without this flag.
:::

:::ExpandableHeading
### Using the same endpoint for multiple tools.

Each tool must have a unique endpoint path. Duplicates cause routing conflicts.
:::

:::ExpandableHeading
### 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.
:::

:::ExpandableHeading
### Returning raw API errors to Mark.

Wrap external API calls in a try/catch statement and return user-friendly messages.

```typescript
  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.');
    }
  }
  
```
:::

:::ExpandableHeading
### Vague tool descriptions.

Mark 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.
:::

:::ExpandableHeading
### Not handling pagination.

If your API returns paginated results, return pagination metadata. Mark may need to make additional requests.
:::

## Full file tree

The following is the full file tree for an example production Mark tool:

```auto
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)
```

:::hint{type="info"}
**Note**

If you use Opti ID, administrators can turn off generative AI in the Opti ID Admin Center. See [Turn generative AI off across Optimizely applications](docId\:X5U0kKD2azV5cIdNqwGNj).
:::
