> For the complete documentation index, see [llms.txt](https://developer.harness.io/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://developer.harness.io/internal-developer-portal/use-idp/plugins/custom-plugins/custom-plugins-v2.md).

# Custom Plugins V2

Build and deploy custom React-based plugins in Harness IDP using the Custom Plugins V2 framework and the idp-plugins-sdk.

{% hint style="info" %}
**BETA FEATURE**

Custom Plugins V2 is available behind the feature flag `IDP_ENABLE_CUSTOM_PLUGINS_V2`. If you wish to try it out, reach out to the IDP team.
{% endhint %}

## Overview

Custom Plugins V2 is a new approach to building custom plugins in Harness IDP. You build a React-based application using the [`@harnessio/idp-plugins-sdk`](https://www.npmjs.com/package/@harnessio/idp-plugins-sdk) package, compile it into a self-contained HTML file, and upload that file directly to IDP.

The SDK handles communication between your plugin and the IDP host. It provides the entity context your plugin renders on, and it proxies third-party API calls through the configured [Backend Proxy Plugin](/internal-developer-portal/use-idp/plugins/delegate-proxy.md) so your plugin never handles secrets or makes direct network requests. The SDK also lets your plugin call Harness APIs as the signed-in user, fetch catalog entities, open entity and Workflow pages in IDP, open external links, and send a verifiable user identity to your external systems.

***

## Before you begin

Make sure you have the following in place:

* Node.js and npm installed locally.
* Access to IDP Admin in your Harness account with the `IDP_ENABLE_CUSTOM_PLUGINS_V2` feature flag enabled.
* `@harnessio/idp-plugins-sdk` version 0.1.3 or later.

***

## Set up the boilerplate

The ['custom-plugins-v2'](https://github.com/harness/custom-plugins-v2) GitHub repository contains a ready-made project skeleton with all the boilerplate already configured.

1. Download or clone the code from [custom-plugins-v2](https://github.com/harness/custom-plugins-v2).

   ```bash
   git clone https://github.com/harness/custom-plugins-v2.git
   ```
2. Go to the project folder.

   ```bash
   cd custom-plugins-v2
   ```
3. Install dependencies.

   ```bash
   npm install
   ```
4. Update the SDK to the latest version. The boilerplate lock file pins an earlier SDK version that does not include the APIs described in the [SDK usage guide](#sdk-usage-guide).

   ```bash
   npm install @harnessio/idp-plugins-sdk@latest
   ```
5. Start the development server.

   ```bash
   npm run dev
   ```

   The dev server starts at `http://localhost:5173`. Keep it running while you develop the plugin. [IDP's Dev Mode](#step-2-preview-with-dev-mode) will connect to it for live preview.

***

## Add the plugin in IDP

### Step 1: Create the plugin entry

To create the plugin entry, complete the following steps:

1. In IDP, go to **Configure** and click **Plugins**.
2. Navigate to the **Custom Plugins V2** tab on the top.
3. Click the **+ New Custom Plugin** button.

   <figure><img src="https://1305598861-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F1bzrz9ybUGCcYdvS4u0z%2Fuploads%2Fgit-blob-cfa3f0774739454a8cb88777124e9422c620bf90%2Fplugins-v2-listing.png?alt=media" alt=""><figcaption></figcaption></figure>
4. Fill in the basic info fields (icon, name, description). Skip the HTML upload field for now; you will return to it after the build step.

### Step 2: Preview with dev mode

To preview the plugin in dev mode, complete the following steps:

1. In the **Preview** section, select the catalog entity you want to render the plugin on.
2. Enable the **Dev Mode** using the toggle button. IDP connects to your local dev server (`http://localhost:5173`) and displays a live preview of your plugin as you make changes.

   <figure><img src="https://1305598861-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F1bzrz9ybUGCcYdvS4u0z%2Fuploads%2Fgit-blob-eff1997ffbb7a6c7e779936620c8f5f05a1eff01%2Fcreate-plugin-v2.png?alt=media" alt=""><figcaption></figcaption></figure>

### Step 3: Build and upload

To build and upload the plugin, complete the following steps:

1. When you are satisfied with the result, stop the dev server and run the production build.

   ```bash
   npm run build
   ```

   It generates a single `index.html` file in the `dist` folder.
2. Return to your plugin creation page on IDP, upload the `dist/index.html` file in the HTML upload field, and save the plugin.

   <figure><img src="https://1305598861-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F1bzrz9ybUGCcYdvS4u0z%2Fuploads%2Fgit-blob-a759afbf4d015ca84c45bde90a2d2436fab1011c%2Fhtml-upload.png?alt=media" alt=""><figcaption></figcaption></figure>

### Step 4: Add the plugin to a layout

{% hint style="info" %}
A Custom Plugin V2 can be placed in your layout [as a **Tab**](#as-a-tab) or [as a **SideNav**](#as-a-sidenav-item) item. Placing it as a card is currently not supported.
{% endhint %}

#### As a tab

1. In IDP, go to **Configure** and select **Layout**.
2. Select **Catalog Entities**.
3. Select the layout for your intended entity kind and type (for example, `component/service`).
4. In the YAML editor, add the following block under the `tabs` list at the position where you want the plugin tab to appear.

   ```yaml
       - name: Custom Plugin
         path: /custom-plugin
         title: Custom Plugin
         contents:
           - component: CustomPlugin
             specs:
               props:
                 pluginId: <your-plugin-id>
   ```

   Replace `<your-plugin-id>` with the plugin ID assigned when you created the plugin.

   <figure><img src="https://1305598861-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F1bzrz9ybUGCcYdvS4u0z%2Fuploads%2Fgit-blob-ccf1ce136fdff08018a4188abcce62f4858bae12%2Fplugins-v2-layout.png?alt=media" alt=""><figcaption></figcaption></figure>
5. Click **Save**.

#### As a SideNav item

1. In IDP, go to **Configure** and select **Layout**.
2. Select **Side Navigation Bar Layout**.
3. In the YAML editor, add the following block under the `children` list at the position where you want the custom plugin nav to appear.

   ```yaml
       - name: SidebarItem
         type: CustomPlugin
         props:
           to: custom-plugin/mydemo
           text: My Custom Plugin
           id: <your-plugin-id>
   ```

   | Field  | Description                                                                                                                                 |
   | ------ | ------------------------------------------------------------------------------------------------------------------------------------------- |
   | `to`   | The endpoint for the nav item. Starts with `custom-plugin/` followed by a unique string of your choice, for example `custom-plugin/mydemo`. |
   | `text` | The label to be shown for your plugin in the side navigation.                                                                               |
   | `id`   | The plugin ID assigned when you created the plugin.                                                                                         |

   <figure><img src="https://1305598861-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F1bzrz9ybUGCcYdvS4u0z%2Fuploads%2Fgit-blob-00872fa66c7b5ad419cc584d456be82189763e13%2Fplugins-v2-layout-sidenav.png?alt=media" alt=""><figcaption></figcaption></figure>
4. Click **Save**.

***

## SDK usage guide

The `@harnessio/idp-plugins-sdk` package is already included in the boilerplate. The sections below explain the key APIs it provides.

| API                                                                | Purpose                                                                     |
| ------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| [`PluginAPI.proxyFetch()`](#make-proxy-fetch-calls)                | Call third-party APIs through the Backend Proxy Plugin.                     |
| [`PluginAPI.harnessFetch()`](#call-harness-apis)                   | Call Harness APIs as the signed-in user.                                    |
| [`PluginAPI.getEntity()`](#fetch-catalog-entities)                 | Fetch a single catalog entity by its entity reference.                      |
| [`PluginAPI.listEntities()`](#fetch-catalog-entities)              | List catalog entities that match a filter.                                  |
| [`PluginAPI.navigateToEntity()`](#navigate-from-your-plugin)       | Open an entity page in IDP.                                                 |
| [`PluginAPI.navigateToWorkflow()`](#navigate-from-your-plugin)     | Open a Workflow's detail page, execution form, or execution history in IDP. |
| [`PluginAPI.navigateToExternalLink()`](#navigate-from-your-plugin) | Open a URL outside Harness in a new browser tab.                            |

### App setup

Wrap your app with `PluginContextProvider` and `PluginRouter`, then call `PluginAPI.init()` after the component mounts. The boilerplate `main.tsx` does it for you.

```tsx
// main.tsx
import { PluginAPI, PluginContextProvider, PluginRouter } from '@harnessio/idp-plugins-sdk';
import { createRoot } from 'react-dom/client';
import App from './App';

document.addEventListener('DOMContentLoaded', () => {
  createRoot(document.getElementById('root')!).render(
    <PluginContextProvider>
      <PluginRouter>
        <App />
      </PluginRouter>
    </PluginContextProvider>
  )

  setTimeout(() => {
    PluginAPI.init()
  }, 0)
})
```

### Use context

After initialization, the IDP host sends your plugin the context for the entity it is rendering on. Access it with the `usePluginContext` hook.

```tsx
import { usePluginContext } from '@harnessio/idp-plugins-sdk';

function MyComponent() {
  const context = usePluginContext()

  if (!context) return <p>Loading...</p>

  const entity = context.entity

  return (
    <div>
      <p>Name: {entity?.name}</p>
      <p>Kind: {entity?.kind}</p>
      <p>Owner: {entity?.owner}</p>
      <p>Reference: {entity?.entity_ref}</p>
    </div>
  )
}
```

The `entity` object is the standard Harness Entity Object. You can read entity annotations to drive plugin behavior. For example, reading `github.com/project-slug` tells your plugin which GitHub repository to fetch data from.

### Make proxy fetch calls

Plugins cannot make direct network requests because the production environment blocks them via CSP. Use `PluginAPI.proxyFetch()` to route third-party API calls through the IDP host instead. To call Harness APIs, use [`PluginAPI.harnessFetch()`](#call-harness-apis).

{% hint style="info" %}
You must configure the [Backend Proxy Plugin](/internal-developer-portal/use-idp/plugins/delegate-proxy.md) before making proxy fetch calls. The endpoint paths you pass to `proxyFetch` must match the endpoints you defined there.
{% endhint %}

```tsx
import { PluginAPI } from '@harnessio/idp-plugins-sdk';

// GET request. The path is the endpoint you configured in Backend Proxy Plugin.
const res = await PluginAPI.proxyFetch('/your-configured-endpoint/some-path')
const data = await res.json()

// POST request with body
const res = await PluginAPI.proxyFetch('/your-configured-endpoint/create', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ name: 'example' }),
})
```

The endpoint path (for example, `/github`) corresponds to the endpoint name you configured in the Backend Proxy Plugin. All paths under that endpoint follow the same prefix.

```tsx
// Example: reading the GitHub project slug from entity annotations
// and fetching pull requests through the "/github" proxy endpoint

const context = usePluginContext()
const slug = context.entity?.metadata?.annotations?.['github.com/project-slug']

const res = await PluginAPI.proxyFetch(`/github/repos/${slug}/pulls`)
const pulls = await res.json()
```

**How it works:**

1. Your plugin calls `PluginAPI.proxyFetch(url, init)`.
2. The IDP host makes the actual HTTP request through the Backend Proxy Plugin, which handles authentication and routing.
3. The host returns the response to your plugin.

Your plugin never touches secrets. Authentication is handled entirely by the Backend Proxy Plugin configuration.

### Send the user identity to your external system

By default, your external system cannot identify which IDP user sent a proxy fetch request. IDP does not forward the user's Harness session to external systems, as that session grants access to Harness. Your external system can authenticate the proxy with a static key in the endpoint headers, but it cannot make per-user decisions.

When you enable signed user tokens on a Backend Proxy Plugin endpoint, IDP adds a short-lived, signed token to every request that your plugin sends to that endpoint with `PluginAPI.proxyFetch()`. The token identifies the Harness user who triggered the request. Your external system verifies the token and then decides how to handle the request. For example, it can restrict an action to specific users, audit access per user, or apply per-user rate limits. IDP does not make the authorization decision; your external system does.

**How it works:**

1. Your plugin calls `PluginAPI.proxyFetch()` with a path under a signing-enabled endpoint.
2. IDP identifies the signed-in user from the session.
3. Harness generates a signed token for that user.
4. IDP forwards the request to the endpoint target and adds the token in the `X-Harness-IDP-User-Token` header.
5. Your external system validates the token, and then makes its authorization decision.

**Enable signed user tokens on an endpoint**

By default, IDP does not send signed user tokens. You turn them on per endpoint, so endpoints without the setting keep their current behavior.

To enable signed user tokens, complete the following steps:

1. In IDP, go to **Configure** and click **Plugins**.
2. Select the **Configure Backend Proxies** plugin.
3. In the configuration YAML, add `enableSignedUser: true` to the endpoint that your plugin calls.

   ```yaml
   proxy:
     endpoints:
       /my-external-api:
         target: https://api.example.com
         enableSignedUser: true
         headers:
           X-API-Key: ${MY_API_KEY}
   ```

   <figure><img src="https://1305598861-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F1bzrz9ybUGCcYdvS4u0z%2Fuploads%2Fgit-blob-e8badcc9f7b869f4c9f4cf2bd36a5b49a9198db4%2Fplugins-v2-enable-signed-user.png?alt=media" alt="Configure Backend Proxies plugin YAML with enableSignedUser set to true on an endpoint"><figcaption></figcaption></figure>
4. Click **Save Configurations**.

Requests that your plugin sends with `PluginAPI.proxyFetch('/my-external-api/...')` now include the `X-Harness-IDP-User-Token` header.

{% hint style="warning" %}
If IDP cannot generate the token, it still forwards the request, without the `X-Harness-IDP-User-Token` header. Configure your external system to reject any request that does not include a valid token.
{% endhint %}

**Token reference**

The token is a JSON Web Token (JWT) signed with the RS256 algorithm. The token header includes a `kid` (key ID) that identifies the Harness signing key. The token expires 60 seconds after IDP issues it.

```json
{
  "iss": "https://app.harness.io/workload/identity",
  "sub": "account/<account-id>:triggeredby/<user-email>",
  "triggered_by": "jane.doe@example.com",
  "account_id": "<account-id>",
  "aud": "api.example.com",
  "iat": 1718640000,
  "exp": 1718640060
}
```

| Claim          | Description                                                                                                                       |
| -------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `iss`          | The token issuer: your Harness base URL followed by `/workload/identity`, for example `https://app.harness.io/workload/identity`. |
| `sub`          | The subject, in the format `account/<account-id>:triggeredby/<user-email>`.                                                       |
| `triggered_by` | The email address of the Harness user who triggered the request.                                                                  |
| `account_id`   | The identifier of the Harness account the request came from.                                                                      |
| `aud`          | The audience: the host name of the endpoint `target`, for example `api.example.com`.                                              |
| `iat`          | The time IDP issued the token, in Unix seconds.                                                                                   |
| `exp`          | The time the token expires, in Unix seconds. It is 60 seconds after `iat`.                                                        |

### Call Harness APIs

Use `PluginAPI.harnessFetch()` to call Harness APIs from your plugin. The IDP host sends the request with the signed-in user's session, so you do not need an API key or a Backend Proxy Plugin endpoint. Harness applies the signed-in user's permissions to the request.

```tsx
import { PluginAPI } from '@harnessio/idp-plugins-sdk';
 
// Fetch the first five projects in the account.
const res = await PluginAPI.harnessFetch('/ng/api/projects?accountIdentifier=<your-account-id>&pageSize=5')
if (res.ok) {
  const projects = await res.json()
}
```

Keep the following rules in mind:

* Pass a path relative to your Harness host, for example `/api/...`, `/gateway/...`, or `/ng/api/...`. The SDK rejects absolute URLs with the `INVALID_PATH` error code.
* `harnessFetch()` supports JSON and text responses. It does not support streaming responses.
* `harnessFetch()` returns a standard `Response` object. Check `res.ok` or `res.status` to handle API errors.

Use `PluginAPI.proxyFetch()` for third-party APIs and `PluginAPI.harnessFetch()` for Harness APIs.

### Fetch catalog entities

The plugin context provides the entity your plugin renders on. To read other entities in your catalog, use `PluginAPI.getEntity()` and `PluginAPI.listEntities()`.

```tsx
import { PluginAPI } from '@harnessio/idp-plugins-sdk';
 
// Fetch a single entity by its entity reference.
const entity = await PluginAPI.getEntity('component:account/payment_service')
 
// List entities that match a filter.
const services = await PluginAPI.listEntities({ kind: 'component', tags: ['java'] })
```

`getEntity()` accepts an entity reference in the format `<kind>:<scope>/<identifier>`, for example `component:account.my_org.my_project/payment_service` for a project-scoped Component. To get the reference of the entity your plugin renders on, read `entity_ref` from the plugin context. Go to [Catalog YAML](/internal-developer-portal/use-idp/software-catalog/catalog-yaml.md#owner) to review the supported scope formats.

`listEntities()` accepts an optional filter with the following fields:

| Field       | Type       | Description                               |
| ----------- | ---------- | ----------------------------------------- |
| `kind`      | `string`   | The entity kind, for example `component`. |
| `owner`     | `string`   | The entity owner.                         |
| `tags`      | `string[]` | The entity tags.                          |
| `name`      | `string`   | The entity name.                          |
| `namespace` | `string`   | The entity namespace.                     |

### Navigate from your plugin

Your plugin runs inside IDP, so use the navigation APIs to move the user to another page instead of a standard link. Each API returns a promise that resolves after the IDP host handles the request.

**Open an entity page**

Use `PluginAPI.navigateToEntity()` to open an entity page in IDP.

```tsx
await PluginAPI.navigateToEntity({ entityRef: 'component:account/payment_service', tab: 'docs' })
```

| Field       | Required | Description                                                           |
| ----------- | -------- | --------------------------------------------------------------------- |
| `entityRef` | Yes      | The entity reference, in the format `<kind>:<scope>/<identifier>`.    |
| `tab`       | No       | The tab to open on the entity page, for example `overview` or `docs`. |

**Open a Workflow**

Use `PluginAPI.navigateToWorkflow()` to open a Workflow in IDP. In `execute` mode, you can pass `formData` to pre-fill the Workflow form. The user still reviews the form and runs the Workflow.

```tsx
// Open the Workflow execution form with the "service_name" and "owner" fields pre-filled.
await PluginAPI.navigateToWorkflow({
  workflowRef: 'template:account/onboard_service',
  mode: 'execute',
  formData: { service_name: 'payment-service', owner: 'platform_team' },
})
```

<figure><img src="https://1305598861-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2F1bzrz9ybUGCcYdvS4u0z%2Fuploads%2Fgit-blob-fdb197724d05a3addb778a3655a7a587e18551b4%2Fplugins-v2-workflow-prefilled.png?alt=media" alt="Workflow execution form opened from a custom plugin with fields pre-filled from formData"><figcaption></figcaption></figure>

| Field         | Required | Description                                                                                                                                                    |
| ------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `workflowRef` | Yes      | The Workflow reference, in the format `template:<scope>/<workflow-identifier>`.                                                                                |
| `mode`        | No       | The page to open. `detail` (default) opens the Workflow detail page, `execute` opens a new execution form, and `history` opens the execution history.          |
| `formData`    | No       | Values to pre-fill in the Workflow form. Each key must match a parameter name in the Workflow's `spec.parameters`. IDP uses `formData` only in `execute` mode. |

**Open an external link**

Use `PluginAPI.navigateToExternalLink()` to open a URL outside Harness. IDP opens the URL in a new browser tab with `noopener` and `noreferrer`, and does not display a confirmation prompt.

```tsx
await PluginAPI.navigateToExternalLink({ url: 'https://status.example.com' })
```

| Field    | Required | Description                                                                                                           |
| -------- | -------- | --------------------------------------------------------------------------------------------------------------------- |
| `url`    | Yes      | An absolute `http` or `https` URL. The SDK rejects relative URLs and other schemes with the `INVALID_URL` error code. |
| `target` | No       | Only `_blank` (default) is supported. The SDK rejects any other value with the `INVALID_TARGET` error code.           |

### Handle errors

The Harness fetch, entity fetch, and navigation APIs throw a `PluginAPIError` when the SDK rejects the input or when the IDP host returns an error. Read the `code` property to identify the cause. All SDK requests time out after 30 seconds.

```tsx
import { PluginAPI, PluginAPIError } from '@harnessio/idp-plugins-sdk';
 
try {
  await PluginAPI.navigateToExternalLink({ url: userInput })
} catch (e) {
  if (e instanceof PluginAPIError && e.code === 'INVALID_URL') {
    // Display a validation message to the user
  }
}
```

| Code             | Thrown by                                    | Cause                                                                                |
| ---------------- | -------------------------------------------- | ------------------------------------------------------------------------------------ |
| `INVALID_PATH`   | `harnessFetch()`                             | The path is not a relative Harness path.                                             |
| `INVALID_INPUT`  | `navigateToEntity()`, `navigateToWorkflow()` | The `entityRef` or `workflowRef` is missing.                                         |
| `INVALID_URL`    | `navigateToExternalLink()`                   | The URL is not an absolute `http` or `https` URL.                                    |
| `INVALID_TARGET` | `navigateToExternalLink()`                   | The `target` is not `_blank`.                                                        |
| `HOST_ERROR`     | All APIs                                     | The IDP host could not complete the request. The error message contains the details. |

{% @harness-feedback/feedback %}
