Skip to content

Testing and Deploying an Extension

This guide covers the full lifecycle of an extension — from running it locally against real board data, to deploying a hosted build, to publishing and installing the app that contains it. If you haven't created an app yet, start with Getting Started and the extension overview.

Lifecycle at a glance

flowchart LR A[Create app &
scaffold extension] --> B[Test with the
Extension Launcher] B --> C[Declare in
app.yml] C --> D[Publish
the app] D --> E[Deploy the
extension package] E --> F[Set iframeUrl,
re-publish] F --> G[Install / upgrade
the app]

An extension package (a deployed build of your front-end code) and an app version (a published version of the app that references it via iframeUrl) are independent. You can redeploy a package without publishing the app, and publish the app without redeploying the package. You only publish a new app version when you want to change which iframeUrl users receive.

The app must exist before you can deploy an extension

contrail app extension deploy requires the app to already exist in your org — otherwise it fails with App … not found. Extensions can only be deployed for existing apps. That's why the first time through, the order is create → publish → deploy → set iframeUrl → publish again.

Install and authenticate the CLI

Every deploy, publish, version, install, and upgrade step below uses the Contrail CLI (@contrail/cli). Set it up once:

npm install -g @contrail/cli
contrail --version        # verify the install
contrail app --help       # list the app/extension commands

The binary is available as both contrail and contrail-cli.

Authenticating. There is no separate login command. The first time you run a command that talks to the platform (e.g. contrail app publish), the CLI prompts for your email, password, and — if no org is configured yet — your org slug, then caches your tokens under ~/.vibeiq/configs/ until the session expires.

Selecting the org. The CLI works against one org at a time, stored in a named config profile under ~/.vibeiq/configs/. Create a profile for your org and activate it:

contrail config create --name="my-org"   # create a named profile
contrail config use                        # pick a profile (lists the ones you have)

contrail config use with no argument lists your profiles to choose from; pass a name to switch directly (contrail config use "my-org"). You do this once per org. When you first run a platform command, the login flow fills the active profile with your org and credentials.

Authenticating in CI/CD

For non-interactive pipelines, provide an app API key (from contrail app getApiKey) via an environment variable:

export CONTRAIL_CLI_API_KEY=<your-app-api-key>
contrail app extension deploy -i data-exporter

You can also pre-seed credentials with CONTRAIL_CLI_EMAIL and CONTRAIL_CLI_PASSWORD.

Run commands from the app root

contrail app … and contrail app extension … commands read your app.yml, so run them from the app's root directory (the folder that contains app.yml). They operate on the current app only.

1. Create the app and scaffold the extension

An extension is a front-end app served from a single public URL and loaded in an iframe. Each extension lives in its own folder under an extensions/ directory inside the app.

First create the app — this registers it in your org, which is required before you can deploy an extension later:

contrail app create   # prompts for app identifier, name, publisher, description, nodeRuntime

Then scaffold the extension's front-end. Any framework works, but VibeIQ managed hosting currently supports Angular, so scaffold it with the Angular CLI from an extensions/ folder. Name the app the same as the extension identifier you'll declare in app.yml — the folder name and the identifier must match.

mkdir extensions && cd extensions
ng new data-exporter      # the app/folder name == the extension identifier

Scaffold with Claude

If you use an AI coding assistant such as Claude, you can have it generate a minimal, working skeleton — just enough to confirm the extension builds, loads in the launcher, and completes the SDK handshake — then build it out from there. Paste a starter prompt like the one below, filling in the bracketed choices:

Starter prompt
Scaffold a minimal VibeIQ extension as an Angular app so I can rename and
customize it later.

Identity & manifest:
- Extension identifier: [data-exporter]  (kebab-case)
- Put the extension code under extensions/<identifier>/ so the folder name
  matches the identifier exactly.
- Add an app.yml `extensions` entry with:
    extensionType: [DOCUMENT_AUTOMATION | CONTEXTUAL_ACTION | ADMIN_UTILITY]
    userApps: [BOARDS]            # any of BOARDS, PLAN, SHOWCASE, ADMIN_CONSOLE
    display:
      type: [modal | side-menu]
      dimensions: { width: 800px, height: 600px, maxWidth: 95vw }
    iframeUrl: <placeholder>      # filled in after deploy

Project / build:
- Standard Angular app (standalone components are fine).
- A `start` script that runs `ng serve --port 4200` (a fixed port I'll type
  into the Extension Launcher). `npm install` && `npm run start` must work
  from a clean checkout.
- Install `@contrail/extensions-sdk` and its peer `@contrail/sdk` (both public
  on npm — no auth token needed).
- Do NOT set X-Frame-Options or a restrictive CSP — the app loads in an iframe.
- Styling: [Tailwind CSS | plain CSS].  Default to Tailwind unless I say plain.

Behavior (important for visibility):
- Render a visible "Hello World" message IMMEDIATELY on load. Do NOT gate the
  UI behind the SDK handshake.
- In ngOnInit, call `AppExtension.registerAppExtension()` from
  `@contrail/extensions-sdk` inside a try/catch as a background step. It only
  resolves once the host sends context, so never `await` it before rendering.
  When it resolves, log `getAppContext()` and flip a status line to
  "Connected to host"; if it throws, leave "Hello World" on screen.

Keep it as small as possible — this is a smoke test I'll expand later.

This produces a runnable stub with roughly this shape:

my-app/
├── app.yml                     # extensions: [ { identifier: data-exporter, ... } ]
└── extensions/
    └── data-exporter/          # folder name == the extension identifier
        ├── package.json
        ├── angular.json
        └── src/
            ├── main.ts
            ├── styles.css
            └── app/            # root component + your UI

Start it with npm run start, then enter http://localhost:4200 in the Extension Launcher (step 2). You should see "Hello World" immediately, with the status line flipping to "Connected to host" once the handshake completes. Whatever the assistant generates, double-check the things that most often need correcting: the UI renders without waiting on the handshake, the app.yml field names match this guide (extensionType, userApps, display), and the extension folder name equals the identifier.

Connect to the host

Whether you scaffold by hand or with Claude, the extension connects to the host on startup: import @contrail/extensions-sdk and acknowledge the connection.

app.component.ts
import { AppExtension } from '@contrail/extensions-sdk';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
})
export class AppComponent implements OnInit {
  async ngOnInit() {
    // Acknowledge the host connection. Until this resolves, the host
    // will not send the app context or accept commands.
    await AppExtension.registerAppExtension();
    // your code — e.g. read context, render UI, call BoardsApp / PlanApp
  }
}

After the handshake resolves you can call getAppContext() and use BoardsApp, PlanApp, EntitiesClient, and TypesClient. See Reading Context and Performing Document Actions.

Render before you await the handshake

registerAppExtension() only resolves once the host sends context. Render your UI first and run the handshake in the background — don't await it before showing anything, or the iframe stays blank. With the launcher open, the SDK logs [AppExtension] Awaiting connection to host app... and then Connected to host app. once it completes.

Zoneless Angular: add a polyfill

If you chose Zoneless when running ng new, the app needs a small polyfill and a builder switch to load in the iframe:

  1. Create src/polyfills.ts:

    (window as any).global = window;
    (window as any).process = { env: { DEBUG: undefined } };
    
  2. Install the browser builder (run npm update first if needed): npm i -D @angular-devkit/build-angular.

  3. In angular.json under projects → <name> → architect → build, set the builder to @angular-devkit/build-angular:browser, and in options add "polyfills": ["src/polyfills.ts"], "baseHref": "./", "main": "src/main.ts" (rename browser to main if present), and "index": "src/index.html".
  4. In tsconfig.json, add "files": ["src/main.ts", "src/polyfills.ts"].

2. Test locally with the Extension Launcher

You don't need to deploy anything to test. Run your extension on a local dev server, then load it into a live board with the Extension Launcher in Developer Mode:

  1. Open the boards app and open a board.
  2. Activate Developer Mode: Shift + Cmd + D (Mac) or Shift + Ctrl + D (Windows). This reveals the Extension Launcher button in the advanced section of the left-hand panel.
  3. Click Extension Launcher, enter any URL — including a localhost endpoint such as http://localhost:4200 — and launch.

Extension Launcher

The locally-served extension has full access to the underlying board's data and elements, exactly as it would once deployed. This is the fastest iteration loop: edit code, reload the launcher, repeat.

Test your display mode after install

The Extension Launcher always renders in a standard modal and ignores your display config. Verify modal vs side-menu behavior once the extension is installed.

3. Configure the extension in app.yml

Declare the extension in your app manifest. The fields most relevant here are iframeUrl (where the build is served), userApps (where it appears), extensionType (what it can do), and display (how it's presented).

app.yml
extensions:
  - identifier: data-exporter
    name: Data Exporter
    extensionType: DOCUMENT_AUTOMATION
    userApps:
      - BOARDS
    display:
      type: modal
      dimensions:
        width: 800px
        height: 600px
        maxWidth: 95vw
    iframeUrl: http://example.com/   # replaced with the deployed package URL in step 6

To dock the extension as a side panel instead, set display.type: side-menu:

app.yml — side panel
extensions:
  - identifier: sales-history
    name: Sales History
    extensionType: CONTEXTUAL_ACTION
    userApps:
      - BOARDS
      - PLAN
    display:
      type: side-menu
      dimensions:
        width: 340px
        height: calc(100vh - 82px)
        maxWidth: 650px
      position:
        right: 0.625rem
        bottom: 0.625rem
    iframeUrl: http://example.com/

Any side-menu field you omit falls back to a default (width: 340px, height: calc(100vh - 82px), maxWidth: 650px, right: 0.625rem, bottom: 0.625rem). See the full display reference.

4. Publish the app (before you deploy)

You can't deploy an extension package until the app exists in your org. You created the app in step 1; now publish an initial version from the app's root directory:

contrail app publish   # publishes the first version

See Publishing the app.

5. Deploy a hosted extension package

You have two hosting options.

Self-hosted. Build your front-end (e.g. npm run build) and deploy the output to any HTTPS host — Netlify, Vercel, GitHub Pages, etc. You'll set iframeUrl to that URL in step 6.

VibeIQ managed hosting. Deploy a package with the CLI. Two things must be true:

  • Run it from the app root — the directory that contains app.yml. The CLI reads app.yml and looks for the extension's code at extensions/<identifier>/.
  • Pass the extension's identifier with the required -i (--identifier) flag. This is the identifier from the extensions list in app.yml (not its name), and it must match the folder name under extensions/.

So for an extension declared as identifier: data-exporter, the code lives in extensions/data-exporter/ and you deploy it with:

contrail app extension deploy -i data-exporter
# ...build logs...
# SUCCESS Extension package is ready.
# SUCCESS Extension deployment URL: https://d2ja3jpe4d2noo.cloudfront.net/packages/8REZp3p0TsWJxPVM/index.html

The expected project layout:

my-app/
├── app.yml                      # declares: extensions: [ { identifier: data-exporter, ... } ]
└── extensions/
    └── data-exporter/           # folder name == the identifier you pass to -i
        ├── package.json
        └── ...                  # your extension's source

When you run the deploy command, the CLI finds the extension in app.yml, installs its production dependencies, builds it, uploads the build to VibeIQ's servers, and prints the hosted URL.

Managed hosting notes

Managed deployments are currently Angular-only, and all managed extensions are publicly accessible via their URL — keep that in mind when building functionality. For non-Angular extensions, self-host (above) or reach out to your VibeIQ CSM.

Listing deployments. List previous deployments for the current app (latest 20 by default, or all with --all):

contrail app extension deployment list --all

┌─────────┬────────────────────────────┬────────────────────┬────────────┬──────────────────────────────────────────────────────────────────────────────┐
 (index)  Created At                  Package ID          Status      URL                                                                          ├─────────┼────────────────────────────┼────────────────────┼────────────┼──────────────────────────────────────────────────────────────────────────────┤
 0        '2025-06-10T21:35:19.111Z'  '7y9wS9kLRX0R8nnG'  'DEPLOYED'  'https://d1cjl68pu6szkr.cloudfront.net/packages/7y9wS9kLRX0R8nnG/index.html'  1        '2025-06-10T20:16:17.373Z'  'vZk0u_Tdyky0hFXk'  'DEPLOYED'  'https://d1cjl68pu6szkr.cloudfront.net/packages/vZk0u_Tdyky0hFXk/index.html'  2        '2025-06-10T20:15:27.595Z'  'XbwjGketIXW2I9Oh'  '-'         '-'                                                                          └─────────┴────────────────────────────┴────────────────────┴────────────┴──────────────────────────────────────────────────────────────────────────────┘

A package that failed to deploy has no URL. The command lists deployments for the current app only — but if your app exposes multiple extensions, it lists deployments for all of them.

6. Point iframeUrl at the deployment and re-publish

Copy the URL printed by the deploy command (or your self-hosted URL) into the extension's iframeUrl in app.yml:

app.yml
extensions:
  - identifier: data-exporter
    name: Data Exporter
    iframeUrl: https://d2ja3jpe4d2noo.cloudfront.net/packages/8REZp3p0TsWJxPVM/index.html
    # ...extensionType, userApps, display...

Changing iframeUrl (or any app.yml field) only reaches users after you publish a new app version:

contrail app version patch   # bump the version (patch | minor | major)
contrail app publish

The publish output includes an EXTENSION step that deploys your extension definitions. Make sure it doesn't report No extensions found...skipping, which would mean your extensions block is missing or malformed.

Publish and upgrade in one step

Add -u / --upgrade to also upgrade the installed version in your current org, so you don't need a separate contrail app upgrade. Use --dryRun to preview a publish without applying it.

contrail app publish --upgrade

7. Install or upgrade the app

Finally, install the app (first time) or upgrade it (subsequent versions) in the target org, via the marketplace UI or the CLI:

contrail app install --appIdentifier="@your-org/your-app"
# or, for a new version of an already-installed app:
contrail app upgrade --appIdentifier="@your-org/your-app" --latest

Once installed, the extension appears in the apps listed in userApps — in the advanced/extensions menu for DOCUMENT_AUTOMATION, in the right-click menu for CONTEXTUAL_ACTION, or as a Utilities tab for ADMIN_UTILITY. See Installing the app.

Iteration loop

For day-to-day development you rarely repeat every step:

  • Changing extension code only: redeploy the package (contrail app extension deploy -i <identifier>). If iframeUrl already points at that managed package URL or your own stable host, no app publish is needed.
  • Changing how or where the extension appears (display, userApps, extensionType, iframeUrl): edit app.yml, then contrail app version patch and contrail app publish (add --upgrade to push it to your org in one step).
  • Trying something quickly: skip deployment entirely and use the Extension Launcher against localhost (step 2).