This documentation is now deprecated and will no longer be maintained. Check out our new documentation here.

Lucid Extension API

The Lucid Extension API allows developers to build Extension Packages (sometimes shortened to package) which can be deployed inside Lucid's core products, like Lucidchart and Lucidspark.

An extension package can include any combination of:

Every extension package includes a manifest file which specifies the ID of the package, and declares any shape libraries, editor extensions, oauth providers or data connectors which are included in the package.

The Extension API includes two npm modules to help you create your extensions:

  • lucid-package which can be used to create, bundle, and test extension packages.
  • lucid-extension-sdk which can be used by editor extensions to extend Lucid's core products.

You can see ongoing release notes for these packages here.

You can find example extension packages in Lucid's repository of Sample Lucid Extensions.

Getting started

Unlocking developer features

To test an app built on the Extension API, you'll need access to the Developer Menu. To distribute your app, you'll need access to the Developer Portal. Instructions for unlocking these two tools can be found here.

Create a new package

An extension package is a set of code, content, and configuration for Lucid products that is installable by individual Lucid users or by a Lucid account admin for use by their entire account. The lucid-package CLI is provided as a convenience to help you quickly create, test, and bundle your extension packages.

To get started, run the following commands in a directory that you want to contain your extension packages and follow the prompts:

$ npm install lucid-package $ npx lucid-package create my-new-package-name

Add an editor extension

An editor extension is a piece of custom code that executes inside a Lucid product such as Lucidchart or Lucidspark. The lucid-package CLI provides a quick-start template to help you get up and running immediately.

To add a new editor extension to your package, run the following commands:

$ cd my-new-package-name $ npx lucid-package create-editor-extension my-extension-name

You may create multiple extensions in the same package.

Debug your editor extension

You don't need to bundle, upload, and install your extension in order to run it and make sure it works. The following command will start webpack on your code in --watch mode, and start up a local HTTP server that Lucid products can connect to, to load your latest code.

To start the debug server, run the following command:

$ npx lucid-package test-editor-extension my-extension-name

You can then enable loading of your local code in the Developer menu in Lucidchart by clicking "Load local extension". The page will refresh and your editor extension code will run.

Note: "Load local extension" is not supported in Safari.

The main entry point to your new editor extension is in editorextensions/my-extension-name/src/extension.ts. This is configurable in this editor extension's webpack.config.js file. Experiment by changing code in that file and refreshing your browser tab to reload it.

For all published editor extensions, and by default for this debug server as well, your code runs in a sandboxed JavaScript VM for security. However, this makes debugging difficult. If you turn on the "Debug local extensions (no sandbox)" option in the Developer menu, your code will be run via a scoped eval, allowing you to use the standard browser debugging tools to examine and step through your code.

We recommend that you do all final validation of your editor extension with the normal sandbox enabled, however, as you may have inadvertently used features not allowed in the sandbox that won't work once you release your extension. For example, editor extensions are not allowed to directly access any browser DOM APIs.

Debugging multiple editor extensions together

You can watch and serve multiple editor extensions in the same package at the same time.

To start the debug server for multiple editor extensions, run the following command:

$ npx lucid-package test-editor-extension my-extension-name my-other-extension-name my-third-extension-name

Bundle your package for upload

First, you should create your package on the Lucid Developer Portal. Clicking on your extension should take you to a page with a URL in the form<UUID>. Copy this UUID and paste it into the "id" field of your my-new-package-name/manifest.json.

Then, once your editor extension (and other extension package content) works the way you want, you can bundle it for upload to the Lucid Developer Portal with the following command:

$ npx lucid-package bundle

This will create the file which is ready for upload to the Lucid Developer Portal.

Core concepts

Extension packages

An extension package defines an extension to Lucid's products. It includes all the frontend code for any editor extensions in the package, any shape libraries your extension provides, and specifies all data connectors and OAuth providers your extension will leverage.

Packages are uploaded and managed in the developer portal. You can learn more about how extension packages are structured here.

Editor extensions

An editor extension is custom JavaScript code you write that gets executed in a Lucid product. Editor extensions use the lucid-extension-sdk npm package to interact with the UI of the application and the content currently loaded in a Lucid document.

Editor extensions can add new menu items to Lucid's menus, new blocks with unique functionality to the canvas, and new interactions to existing elements in Lucid documents. Editor extensions can also leverage OAuth APIs and data connectors to bring external data into Lucid documents. You can learn more about how to create editor extensions here.

Shape libraries

A shape library is a collection of custom shapes you create that can be used in Lucid documents. Your shapes can leverage data, conditional formatting, formulas, and much more to create intelligent, interactive experiences. Learn more about custom shapes and how to create them here. Learn more about adding custom shape libraries to your extension here.

Data connectors

A data connector is an API you create that editor extensions in your package can use to fetch and manage external data. In its simplest form, a data connector could just be a single endpoint that fetches and returns some data from an external API. For more complicated integrations, data connectors can post data back to documents asynchronously. Data connectors can also implement specific endpoints for things like:

  • Propogating changes to data made in Lucid's editors back to the data source.
  • Automatically fetching changes to data made in data sources so Lucid documents have the most recent version.
  • Setting up and managing webhook relationships with data sources to track data changes made ouside of Lucid.

A data connector must provide a public URL that Lucid's servers can make requests to. Learn more about building data connectors here.

Extension packages

Extension packages can be created automatically using the lucid-package CLI tool:

$ npx lucid-package create my-package

Extension package structure

An extension package is structured like this:

> my-package > editorextensions └── ... > shapelibraries └── ... > dataconnectors └── ... └── .gitignore └── manifest.json

The package has two settings files:

  • A .gitignore file for managing version control.
  • A manifest file called manifest.json for specifying package configuration.

Packages also contain two folders:

  • An editorextensions folder which will house all the code for the package's editor extensions.
  • A shapelibraries folder which will house definitions for shape libraries added by the package.

Manifest file

Every extension package has one manifest file called manifest.json. The manifest file houses general settings for your extension package like its ID and version. The manifest also declares the settings for any editor extensions, shape libraries, OAuth providers, and data connectors you add to the package. An empty manifest file looks like this:

{ "id": "9347ghfne-932nfi92hnk-sj3i8ns-0qk34e98", "version": "1.0.0", "extensions": [], "shapeLibraries": [], "oauthProviders": [], "dataConnectors": [] }

A manifest file contains the following fields:

  • id: the globaly unique identifier for your extension package. When you create an application in the developer portal (see Bundle your package for upload) you will be given an ID to use for this value.
  • version: the current version of your extension package. When uploading your package to the developer portal, its package version must be higher than the most recently uploaded version. Running the bundle command will increase the version automatically.
  • extensions: declarations and settings for editor extensions in your package.
  • shapeLibraries: declaration and settings for shape libraries in your package.
  • oauthProviders: configurations for any OAuth providers your package will leverage.

Editor extensions

Editor extensions can be created automatically using the lucid-package CLI tool:

/my-package$ npx lucid-package create-editor-extension my-editor-extension

Editor extension file structure

Editor extensions are structured like this:

> editorextensions > my-editor-extension > node_modules └── ... > resources └── import.html └── resource.d.ts > src └── extension.ts └── importmodal.ts └── interop.d.ts └── package-lock.json └── package.json └── tsconfig.json └── webpack.config.js

The resources folder contains resource files used by your editor extension. When you generate a new editor extension with the lucid-package CLI tool, resources will contain two files:

  • import.html: includes a very basic import modal to get you started with an import flow.
  • resource.d.ts: responsible for specifying what type of content you want to include as resources.

The src houses all of the code for your extension, and starts with some skeleton files to get your extension up and running:

  • extension.ts: the entry point for your extension code.
  • importmodal.ts: example code illustrating the use of modals.
  • interop.d.ts: standard method definitions.

The package-lock.json, package.json, tsconfig.json, and webpack.config.js files define environment settings for your project.

Editor extension manifest

To add an editor extension to your extension package, you need to declare the editor extension in your manifest.json file.

If you used the lucid-package CLI tool to create your editor extension, your manifest.json file will be updated automatically to include the new editor extension manifest.

Here is an example of what an editor extension manifest entry might look like:

{ // ... "extensions": [ { "name": "my-editor-extension", "title": "My Editor Extension", "products": ["chart", "spark"], "codePath": "editorextensions/my-editor-extension/bin/extension.js", "scopes": [ "READ", "WRITE", "DOWNLOAD", "SHOW_MODAL", "CUSTOM_UI", "NETWORK" ] } ] }

An editor extension manifest should have the following fields:

  • name: the name of the extension. This should match the name of the folder that contains the editor extension.

  • title: a user facing title string that is used in Lucid components like modals and dropdown menus.

  • products: the products that the editor extension can be used in: ["chart", "spark", "teamspaces"].

  • codePath: the file path to the entry point of your extension. By default, this will be set to bin/extension.js, which is created when you bundle your package.

  • scopes: the scopes that your editor extension has access to. These are the scopes that an editor extension can have:

    Scope Description
    READ Allows you to read elements and data from the document
    WRITE Allows you to write elements and data from the document
    SHOW_MODAL Allows you to show modals
    CUSTOM_UI Allows you to create custom panels in the right dock in Lucidchart
    DOWNLOAD Allows you to enable downloading data as a file
    NETWORK Allows you to have direct access to a simple XHR API
    OAUTH_TOKEN Allows you to access the user's OAuth token
    USER_INFO Allows you to use the UserProxy to access user information (see Users)

Document content

Document content visual summary

When a user opens a Lucid product, the content they are viewing is a document. The document contains all of the information about what is displayed, as well as any data that may be used in that content. Each document consists of pages, which organize items together. A page is responsible for creating items and keeping track of all created items. Items are the blocks, lines, and groups that make up a diagram. Each item has various properties that define how it looks and how it displays information. Groups are multiple blocks and/or lines that are treated like a single item.

The extension SDK provides a mechanism for reading and writing the content of the current document through a series of proxy classes. You can read and write to a DocumentProxy's properties to save and load data onto the document for use between sessions:

import {DocumentProxy, EditorClient, Menu, MenuType} from 'lucid-extension-sdk'; const client = new EditorClient(); const menu = new Menu(client); const document = new DocumentProxy(client); const key = 'myKey' console.log('Loaded random number:', client.registerAction('generate-number', () => { const f = Math.random() * 100 console.log('New random number: '+f), f) }); menu.addDropdownMenuItem({ label: 'Generate random number', action: 'generate-number', });


A document consists of one or more pages. In Lucidchart, a document may include more than one page, while in Lucidspark, there is always a single page. A page contains any number of items which may be blocks, lines, or groups.

You can access the list of all pages through the DocumentProxy class, or get a reference to the currently displayed page through the Viewport class:

import {DocumentProxy, EditorClient, Viewport} from 'lucid-extension-sdk'; const client = new EditorClient(); const document = new DocumentProxy(client); const viewport = new Viewport(client); client.registerAction('listPages', () => { for (const [pageId, page] of document.pages) { console.log( pageId, page.getTitle(), viewport.getCurrentPage() === page ? 'active' : '' ); } });

Importing pages

One or more pages can be imported from another document or template if the user has permisson to view the document.

This can be done by using the importPage command with the EditorClient:

import {EditorClient} from 'lucid-extension-sdk'; const client = new EditorClient(); const pageNumsToGet = [0, 1]; // Import the first two pages of the specified document const documentId = '<documentId>' await client.importPage(documentId, pageNumsToGet);

To get the document ID, open the document in Lucid and get the ID from the URL (ex.{{DOCUMENT_ID}}/edit).


The content on a page consists of blocks, lines, and groups, which are all kinds of items.

All items (and in fact other elements such as pages or the document itself), have a set of properties that can be read and written using their .properties collection:

function dumpProperties(page:PageProxy) { for(const [blockId, block] of page.allBlocks) { console.log('Block of class ' + block.getClassName() + ' (' + blockId + '):') for(const [propertyName, propertyValue] of { console.log(propertyName, propertyValue); } } }

While this allows you access to all the underlying properties of an item, it is not typesafe and should only be used when more specific methods are not available. For example, a BlockProxy has methods to directly read its class name as a string, its rotation as a number, and so on.

Items may also have data fields and/or whole data records associated with them. You can read more about associating data with items here.


A block is a single shape on a page. Different types of blocks are specified by their class name.

Creating a block

Not all block classes have their code loaded on all documents, so before adding a block to a page, you must make sure that the block class has been loaded.

Once you have loaded the block class, you can add it to the page like this:

async function createProcessBlock(page:PageProxy, x:number, y:number) { await client.loadBlockClasses(['ProcessBlock']); const block = page.addBlock({ className:'ProcessBlock', boundingBox:{ x, y, w:200, h:160 } }); block.textAreas.set('Text', 'The new shape'); }

You only need to load a block class once, so you may also structure your code this way to avoid needing to have async functions any time you create a block:

const client = new EditorClient(); function createProcessBlock(page:PageProxy, x:number, y:number) { const block = page.addBlock({ className:'ProcessBlock', boundingBox:{ x, y, w:200, h:160 } }); block.textAreas.set('Text', 'The new shape'); } async function init() { await client.loadBlockClasses(['ProcessBlock']); const menu = new Menu(client); menu.addDropdownMenuItem({...}); } init();

By avoiding adding any UI (like menu items, custom panels, etc.) until your needed block classes are loaded, you can then operate synchronously in the rest of your code.

Class specific functionality

While all properties of all blocks are available through .properties, many kinds of blocks have properties specific to them that may not be obvious how to use.

When you get a BlockProxy class instance representing one block, it may actually be a subclass of BlockProxy depending on its class name. Below are two examples of specific blocks types:

ERD block

ERD blocks are returned as a ERDBlockProxy which has specific methods to read the fields specified on the ERD shape:

function dumpERD(page: PageProxy) { for (const [blockId, block] of page.allBlocks) { if (block instanceof ERDBlockProxy) { console.log('ERD block: ' + block.getName()); for(const field of block.getFields()) { console.log('Field: ' + field.getName() + ': ' + field.getType()); } } } }

Generic Lucid cards

Lucid cards blocks are returned as a CardBlockProxy which has specific methods to read the fields specified on the Lucid card shape:

async function createGenericLucidCardBlock(page: PageProxy, title: string, description: string) { await client.loadBlockClasses(['LucidCardBlock']); const block = page.addBlock({ className:'LucidCardBlock', boundingBox:{ x, y, w:200, h:160 } }); if (block instanceof CardBlockProxy) { block.setTitle(title); block.setDescription(description); } }

Identifying custom shapes

If you use custom shapes from a shape library in your extension, those blocks will be instances of CustomBlockProxy. This class allows you to easily check if you’re working with one of your custom shapes:

function findInstancesOfMyShape(page: PageProxy) { for (const [blockId, block] of page.allBlocks) { if (block instanceof CustomBlockProxy) { if (block.isFromStencil('my-library', 'my-shape')) { console.log('Found custom shape "my-shape": ' +; } } } }

You can also extend CustomBlockProxy with a class that provides behavior specific to individual custom shapes. For example, if you create a custom shape that has a user-editable text area named TextContent, you could write the following class:

export class MyCustomBlock extends CustomBlockProxy { public static library = 'my-shape-library'; public static shape = 'my-custom-shape'; public getTextContent() { const taName = this.getStencilTextAreaName('TextContent'); if (!taName) { return ''; } return this.textAreas.get(taName); } public setTextContent(text: string) { const taName = this.getStencilTextAreaName('TextContent'); if (!taName) { return ''; } return this.textAreas.set(taName, text); } } CustomBlockProxy.registerCustomBlockClass(MyCustomBlock);

When you call CustomBlockProxy.registerCustomBlockClass, it makes it so that your custom class is used for any block created from the shape you specified in library and shape. So you can write code like this:

const client = new EditorClient(); const document = new DocumentProxy(client); client.registerAction('log-custom-text-content', () => { for(const [pageId, page] of document.pages) { for(const [blockId, block] of page.blocks) { if(block instanceof MyCustomBlock) { console.log(block.getTextContent()); } } } });

You can also create custom blocks directly from your extension code. To do this, use the getCustomShapeDefinition method on the EditorClient class and provide the library name and shape name:

async function createCustomBlock(page: PageProxy, libraryName: string, shapeName: string) { const customBlockDef = await client.getCustomShapeDefinition(libraryName, shapeName); if (!customBlockDef) { return; } const customBlock = page.addBlock(customBlockDef); customBlock.textAreas.set('Text', 'My Custom Shape'); }

The library name and shape name refer to the names of their respective folder and file, not the names listed in the library.manifest file.

Text on blocks

Many classes of blocks have one or more text areas. These text areas can be enumerated, read, and written using textAreas. The text is provided as plain text in this object:

function changeText(page: PageProxy) { for (const [blockId, block] of page.allBlocks) { for (const [textAreaKey, plainText] of block.textAreas) { block.textAreas.set(textAreaKey, plainText + ' (changed)'); } } }

You can read or write text styles on text areas using textStyles. The entries have the same keys as textAreas. When you read a text area's style, an object will be returned that describes styles that are common across the entire text area. For example, if a single word is bolded, you will not get [TextMarkupNames.Bold]: true in the result. If there are conflicting styles like that, the default style value will be returned (in the case of Bold, you will get false).

Changing text styles is asynchronous and must be awaited to be sure it is complete. This is because changing the font (using TextMarkupNames.Family, TextMarkupNames.Bold, or TextMarkupNames.Italic) may require a network request to find the appropriate font:

import {TextMarkupNames, EditorClient, Menu, MenuType, Viewport} from 'lucid-extension-sdk'; const client = new EditorClient(); const menu = new Menu(client); const viewport = new Viewport(client); client.registerAction('toggle-bold', async () => { for (const item of viewport.getSelectedItems()) { for (const ta of item.textAreas.keys()) { const oldStyle = item.textStyles.get(ta); await item.textStyles.set(ta, { [TextMarkupNames.Bold]: !oldStyle[TextMarkupNames.Bold], }); } } }); menu.addDropdownMenuItem({ label: 'Toggle bold', action: 'toggle-bold', });


Blocks (and other items) have a bounding box you can read with .getBoundingBox(). For blocks, this bounding box is the unrotated bounding box of the shape—that is, the bounding box it would occupy if its rotation were set to 0.

Moving or resizing a block is often subject to constraints. For example, some blocks have a minimum width or height, or do not allow resizing on one axis. Other blocks have side effects when moved, such as moving the other items along with a magnetized container.

For this reason, you cannot directly set the BoundingBox property on a block. Instead, you must use the offset method or one of the utility methods that calls it, such as setBoundingBox or setLocation.

To properly position a block, it may be necessary to determine the user's focus on the board. To achieve this, you can utilize viewport.getVisibleRect() to obtain the current viewport's location, and then create the block relative to the user's current viewing position:

function createProcessBlock(page:PageProxy, viewport:Viewport, dx:number, dy:number) { const {x, y} = viewport.getVisibleRect(); const block = page.addBlock({ className:'ProcessBlock', boundingBox:{ x + dx, y + dy, w:200, h:160 } }); block.textAreas.set('Text', 'The new shape'); }


You can easily insert an image using either a public or a data URL:

function createImage(page:PageProxy, x:number, y:number) { page.addImage({ boundingBox:{ x, y, w:150, h:150 }, fillStyle: { url: '', position: SimpleImageFillPosition.Fill, }, }); }

Keep in mind that images are just blocks, and they can be handled as such. We provide a few wrapper methods, such as addImage, that abstract away some block information that is less frequently used for images, but note that an image can be modified just as a block could be.

The block class that is used in our helper methods is UserImage2Block. Although, any block class will work to display images, so long as the fill color is set to the image.

You can also upload images utilizing file uploads. To do this, you can upload a picture and then process it into a data URL. You can then call the createUserImage method from the EditorClient class and pass in the media type (png, jpeg, etc.) and the binary image contents:

function uploadImage(files: FileUploadData[]) { const img = files[0]; const url = await client.createUserImage("image/jpeg", img.binary); page.addImage({ boundingBox:{ x, y, w:150, h:150 }, fillStyle: { url: url, position: SimpleImageFillPosition.Fill, }, }); }


A line is a connector with two endpoints. Each endpoint may or may not be connected to a block, or to another line.

Creating a line

To create a line, you specify each endpoint as either a free-floating endpoint (just x/y coordinates), a block-connected endpoint, or a line-connected endpoint:

function connectBlocks(block1: BlockProxy, block2: BlockProxy) { block1.getPage().addLine({ endpoint1: { connection: block1, linkX: 0.5, linkY: 1, }, endpoint2: { connection: block2, linkX: 0.5, linkY: 0, }, }); }

Text on lines

Any line can have any number of text areas on it. Each text area consists of its text, a position along the line (from 0 to 1), and a number specifying which side of the line the text should appear on:

function dumpLineText(page: PageProxy) { for (const [lineId, line] of page.lines) { for (const [key, text] of line.textAreas) { const position = line.getTextAreaPosition(key); if (position) { if (position.side == 0) { console.log('Text on line: ' + text); } else if (position.side == -1) { console.log('Text to left of line: ' + text); } else if (position.side == 1) { console.log('Text to right of line: ' + text); } } } } }

You can add or remove text with addTextArea and deleteTextArea.

As with blocks, you can read and write text style with textStyles.


A group is a set of other items, which acts in many ways like a single block.

On PageProxy and GroupProxy objects, if you use .blocks, you will get only the blocks whose immediate parent is that page or group. If you use .allBlocks, you will get all blocks within that page or group at any level of grouping. The same pattern applies for .lines and .groups.

Managing text

Text can exist on both blocks and lines. Reading and writing the text content itself can be done through the textAreas property on either a LineProxy or BlockProxy. For example, the following code reads all the text off the current selection and then replaces it with Hello World:

import {EditorClient, Menu, MenuType, Viewport} from 'lucid-extension-sdk'; const client = new EditorClient(); const menu = new Menu(client); const viewport = new Viewport(client); client.registerAction('hello', async () => { for(const item of viewport.getSelectedItems()) { for(const [key, plainText] of item.textAreas) { console.log('Old value: ' + plainText); item.textAreas.set(key, 'Hello world'); } } }); menu.addDropdownMenuItem({ label: 'Hello World', action: 'hello', });

While blocks typically have a set number of text areas on them, lines have any number of text areas, and those text areas can be freely moved around by the user. You can add new text areas, read and write their position on the line, and delete them:

const first = line.addTextArea('Start Left', {location: 0, side: -1}); const second = line.addTextArea('Middle', {location: 0.5, side: 0}); const third = line.addTextArea('End Right', {location: 1, side: 1}); console.log(line.getTextAreaPosition(first)); line.setTextAreaPosition(first, {location: 0.25, side: 0}); line.deleteTextArea(third);

The location of a line text area is a number between 0 and 1, where 0 places the text at the first endpoint of the line, and 1 places the text at the second endpoint of the line.

The side of a line text area places the text on top of the line if it is 0, to the left of the line (when looking from the first toward the second endpoint) if it is -1, and to the right of the line if it is 1.

Hook text editing

Your extension can react to a user editing text in a number of ways:

  • It can prevent a user from editing a certain text area.
  • It can allow the user to edit a text area, but prevent their edit after the fact (knowing what they typed).
  • It can allow the user to edit a text area, but replace the value they typed with another.

To hook text editing, use the Viewport class's hookTextEdit method like this:

import {EditorClient, Viewport} from 'lucid-extension-sdk'; const client = new EditorClient(); const viewport = new Viewport(client); viewport.hookTextEdit(async (item, textAreaKey, text) => { if ( === 'Deny') { return false; } if ( === 'Numeric') { return (newValue) => { if (String(+newValue) === newValue) { return true; } else if (isNaN(+newValue)) { return false; } else { return String(+newValue); } }; } return true; });

This example watches text editing and does the following:

  • If the text the user is trying to edit currently says Deny, then text editing is prevented by returning false from the hook.
  • If the text the user is trying to edit currently says Numeric, then the text editing is allowed, but:
    • if the final value is not a number, the edit is reverted.
    • if the final value is not in the default number format, it will be replaced (e.g. +14.0 -> 14).

By default, the text editing hook is triggered only at the beginning of text editing. If you wish to listen to changes at a more granular level of key inputs during editing, you may use the eager option:

import {EditorClient, Viewport} from 'lucid-extension-sdk'; const client = new EditorClient(); const viewport = new Viewport(client); const eager = true; viewport.hookTextEdit(async (item, textAreaKey, text) => { console.log('Listening for live update from ' + textAreaKey); }, eager);

In this example, text editing is monitored eagerly, and a message is logged every half a second. It's important to note that you should avoid expensive operations when setting eager to true.

Both the text editing hook and the callback to be run when editing is complete can be async. However, be cautious with this as you will cause the user to wait until your Promise resolves before continuing their work.

One common way for end users to trigger editor extension functionality is through menu items, which can be added either to the main menu or the context menu.

Menu items trigger named actions, which must be registered with EditorClient.registerAction. Named actions may also return values, and can be used as callbacks to determine when a menu item should be visible or enabled.

For example, the following code registers a processBlocksSelected action that returns true if only ProcessBlock shapes are selected, then passes that action's name as the visibleAction when creating a menu item in the Edit menu that will turn the selected shapes red. This example also adds a menu item to the context menu, re-using the same actions:

const client = new EditorClient(); const menu = new Menu(client); const viewport = new Viewport(client); client.registerAction('processBlocksSelected', () => { const selection = viewport.getSelectedItems(); return ( selection.length > 0 && selection.every((item) => item instanceof BlockProxy && item.getClassName() === 'ProcessBlock') ); }); client.registerAction('makeSelectionRed', () => { for (const item of viewport.getSelectedItems()) {'FillColor', '#ff0000ff'); } }); menu.addContentDockMenuItem({ label: 'Turn red', action: 'makeSelectionRed', location: MenuLocation.Edit, visibleAction: 'processBlocksSelected', }); menu.addContextMenuItem({ label: 'Turn red', action: 'makeSelectionRed', visibleAction: 'processBlocksSelected', });

Menus items can be added to three places, each has a specialized entry point. You can also use the generic entrypoint addMenuItem.

These are the primary dropdown menus (file, edit...) found in the top bar of Lucidchart. In Lucidspark all menus are nested under the one drop down menu in the top left. In Lucidchart a location can be specified (e.g. edit, view) and will default to the Extension menu if not defined. See addContentDockMenuItem for more information.

Context Menu

This is the right click context menu that exists in both Lucidchart and Lucidspark. In order to avoid clutter in the context menu consider defining visibleAction. See addContextMenuItem for more information.

Lucidspark Left Dock

The content dock only exists in Lucidspark. Like other icons it can be pinned and unpinned. It will default to pinned when first added. An icon link is required. See addContentDockMenuItem for more information.


While you can build completely custom modals with iFrames, many interactions with the user are simple enough to use pre-built modals provided by the Extension API.


An alert modal presents plain text to the user with a single button to acknowledge that message. You can configure the alert with a custom title (which defaults to the name of your extension) and/or custom text for the OK button.

The alert method returns a Promise that resolves to true if the user clicks the OK button, or false if the user dismisses the modal in some other way:

const client = new EditorClient(); client.alert('This is a message');



A confirm modal presents plain text to the user with an OK and Cancel button. You can configure the confirm modal with a custom title (which defaults to the name of your extension) and/or custom text for the OK and Cancel buttons.

The confirm method returns a Promise that resolves to true if the user clicks the OK button, or false if the user dismisses the modal in some other way:

const client = new EditorClient(); const menu = new Menu(client); const viewport = new Viewport(client); client.registerAction('makeSelectionRed', async () => { if (await client.confirm('Do you wish to turn the selected blocks red?', undefined, 'Yes', 'No')) { for (const item of viewport.getSelectedItems()) {'FillColor', '#ff0000ff'); } } }); menu.addDropdownMenuItem({ label: 'Turn red', action: 'makeSelectionRed', location: MenuLocation.Edit, });



By adding the USER_INFO scope to your editor extension definition in manifest.json, you can utilize the UserProxy class to access user information:

import {EditorClient, UserProxy} from 'lucid-extension-sdk'; const client = new EditorClient(); const user = new UserProxy(client); console.log(;

Get user's permission on the document

Knowing the user's permissions on a document can be helpful to determine what actions can be performed by the extension. For example, if the user only has view permissions on a document then the extension will be unable to perform any actions that write content to the document:

import {DocumentProxy, EditorClient, Menu, MenuType, UserProxy} from 'lucid-extension-sdk'; import {DocumentAccessPermission} from 'lucid-extension-sdk/document/documentaccesspermission'; const client = new EditorClient(); const menu = new Menu(client); const user = new UserProxy(client); const document = new DocumentProxy(client); const documentPermission = user.getAccessPermssionOnDocument(); client.registerAction('createBlock', async () => { const page = document.pages.first(); if (page) { if (hasWriteAccess()) { await client.loadBlockClasses(['ProcessBlock']); page.addBlock({ className: 'ProcessBlock', boundingBox: { x: 100, y: 100, w: 200, h: 160, }, }); } else { console.log("The user doesn't have write access to the document!"); } } }); function hasWriteAccess() { return documentPermission === DocumentAccessPermission.Edit || documentPermission === DocumentAccessPermission.EditAndShare; } menu.addDropdownMenuItem({ label: 'Create Block', action: 'createBlock', });

File upload

Menu items can be specified as prompting a file upload. To do this, you first register a named action for receiving the uploaded file content using EditorClient.registerFileUploadAction. This callback will receive an array of files that were selected by the user, including the file names and content as text. Optionally, the content may also be included as a Uint8Array if your menu item requests that binary be included:

const client = new EditorClient(); const menu = new Menu(client); client.registerFileUploadAction('logFileContent', (files: FileUploadData[]) => { console.log(files); for (const file of files) { console.log(file.fileName); console.log(file.text); if (file.binary) { console.log(file.binary); } } }); menu.addDropdownMenuItem({ label: 'Upload file for processing', file: { action: 'logFileContent', accept: 'text/csv', singleFileOnly: true, binary: true, }, });

Your extension can capture pasting URLs from the clipboard to produce a custom block with embedded data. Your extension will register a domain pattern and a callback block that will be called automatically when the user pastes a matching URL onto the canvas.

From the URL you can return an UnfurlDetails object which will describe the content of the newly created block. Lucid will take that information and populate a new block on the canvas. You can provide an optional async followup to perform additional computation and update the block as needed.

Created blocks can have one or multiple preview images that a user can browse through.

Example embed menu

You can also provide an iframe for a user to view expanded content.

Example Iframe

Listen for a URL by calling registerUnfurlHandler:

import EditorClient from 'lucid-extension-sdk'; const client = new EditorClient(); function performUnfurl(url: string): Promise<UnfurlDetails | undefined> { const idRegex = /^https:\/\/my\.url\.com\/\w+\/d\/([a-zA-Z0-9-_]+)/; const id = idRegex.exec(url)?.[1] if (id) { try { return { providerName: 'myUrlCompany', providerFaviconUrl: '', unfurlTitle: 'Block title', previewImageUrl: '' + url, }; } catch (error) { console.log(error); } } return undefined; } function performAfterUnfurlCallback(blockProxy: LinkUnfurlBlockProxy, url: string) { const [fileKey, frameId] = parseLink(url); // for example if (!fileKey || !frameId) { return undefined; } const nameOfFrame = await getNameOfFrame(fileKey, frameId); // for example if (nameOfFrame) { blockProxy.setTitle(nameOfFrame); } blockProxy.setIframe({ iframeUrl: '' + url, aspectRatio: UnfurlIframeAspectRatio.Square, }); } client.registerUnfurlHandler('', { unfurlCallback: async (url: string) => performUnfurl(url), afterUnfurlCallback: async (blockProxy, url) => performAfterUnfurlCallback(blockProxy, url), });

Domain URL

The domain is required and will be checked against any URLs pasted to the canvas. If the domain matches, your callback will be run. If it doesn't match, then other unfurl handlers will be called so multiple extensions can be running and watching for URLs at the same time. Domains of multiple extensions cannot overlap.

The domain supports subdomains and wildcards in the subdomains. For example: *

We do not support anything after the domain or wildcards in the domain itself. So we DO NOT support or d*

Unfurl callback

unfurlCallback is a required parameter that will be called with the pasted url. Your block should return an UnfurlDetails object to produce a block or undefined to do nothing. This call must run quickly in case any long computations need to be run in the followup async call. The purpose is to get the minimal information to display the new shape.

After unfurl callback

If you have additional, longer work that you need to do for an unfurl you may provide an asynchronous callback named afterUnfurlCallback that will run after the block is generated on the canvas. You will get a LinkUnfurlBlockProxy which will let you update the title, thumbnail and preview URLs, and the details iframe.

Links can be imported on to a page as link unfurl blocks.

This can be done by using the importLinks method of the PageProxy class:

const client = new EditorClient(); const linksToImport = ["", ""]; new Viewport(this.client).getCurrentPage()?.importLinks(linksToImport);

Links will be unfurled by Lucid based on extensions installed by the user.

Expand Callback

If you want to customize the experience users get when they expand links they've added to the document, you may provide an asynchronous callback named expandCallback that runs when the expand button for the unfurled block is pressed. The callback lets you set/check/update the iframe before expanding.


The Extension API allows you to display custom UI in a sandboxed iframe. The UI displayed in these iframes can communicate with your extension code via asynchronous message passing. You can define custom UI by extending the Modal or Panel class.

To display a simple modal, extend the Modal class, passing configuration to the super constructor:

import {EditorClient, Menu, MenuType, Modal} from 'lucid-extension-sdk'; class HelloWorldModal extends Modal { constructor(client: EditorClient) { super(client, { title: 'Hello world', width: 400, height: 300, content: 'Hello from a modal', }); } } const client = new EditorClient(); const menu = new Menu(client); client.registerAction('hello', () => { const modal = new HelloWorldModal(client);; }); menu.addDropdownMenuItem({ label: 'Say Hello', action: 'hello', });

Hello World modal

Panel example

To add a simple panel to the right dock, extend the Panel class, passing configuration to the super constructor. Note that in this case, the HelloWorldPanel is constructed during the initial execution of the extension code, since it will live for the duration of the editor session, unlike the HelloWorldModal above that is used and then discarded when it is closed.

The iconUrl specified here can be any image, but it will be displayed as 24x24 CSS pixels. Consider using a base64-encoded image URL for this icon to avoid any image loading delay:

import {EditorClient, Panel, PanelLocation} from 'lucid-extension-sdk'; class HelloWorldPanel extends Panel { constructor(client: EditorClient) { super(client, { title: 'Hello world', iconUrl: '', location: PanelLocation.RightDock, url: 'hello.html', }); } } const client = new EditorClient(); const panel = new HelloWorldPanel(client);

Hello World panel

Persistent panels

Lucid may destroy the panel iframe and recreate it when needed for optimal application performance. Therefore, if you are developing an extension such as a video conference tool where your code needs to run continuously in the background, you can prevent this behavior by using the persist: true option in conjunction with location: PanelLocation.RightDock:

class PersistentPanel extends Panel { constructor(client: EditorClient) { super(client, { title: 'Hello world', content: 'Hello from a panel', iconUrl: '', location: PanelLocation.RightDock, persist: true, }); } }

Specifying content

Besides just specifying the content string, the best way to package HTML content into your extension is by providing a url string in either a Modal or Panel constructor.

If you don't already have a folder named public at the root of your extension package, you'll need to create one:

> my-package > editorextensions └── ... > shapelibraries └── ... > dataconnectors └── ... > public └── img └── ... └── index.html └── .gitignore └── manifest.json

This allows you to reference the HTML file relative to the public directory:

import {EditorClient, Modal} from 'lucid-extension-sdk'; export class ImportModal extends Modal { constructor(client: EditorClient) { super(client, { title: 'Import a thing', width: 600, height: 400, url: 'index.html', }); } }

To access static resources in the public/img folder, you can use relative URLs in your HTML such as <img src="img/example.png">.

The iframe is sandboxed, but allows the following:

This means your content may include scripts, external images, and so forth. Each extension is loaded on a unique origin, so the allow-same-origin permission means you get access to browser APIs like localStorage and IndexedDB.

External content

Although you can, you do not need to bundle the entire UI application into the content of a Modal or Panel. Your HTML content can refer to JavaScript and CSS resources that you host elsewhere. These resources can also be derived from a framework, such as Angular or React.

If you wish to serve the entire iframe from an external URL, you just need to pass in the external URL as url in the Modal or Panel constructor:

export class ContentFromElsewhereModal extends Modal { constructor(client: EditorClient) { super(client, { title: 'Loading up something else', width: 600, height: 400, url: '', }); } }

Specifying content using a content string (Legacy)
The reason why this approach is not favored is that it needs to inline all static resources using data URLs. Consequently, the bundle size becomes excessively large, leading to potential issues with slow parsing and execution.

The content string passed to either a Modal or Panel constructor is set as the full content of the iframe. That means it will typically include an <html> tag.

To package HTML content into your extension as a content string, you can use a TypeScript import as a string. By default, a new editor extension includes a resources directory with the following resource.d.ts:

declare module '*.html' { const content: string; export default content; }

This indicates that any .html file in that directory will be treated as a string when imported. The tsconfig.json file references that file as well so the compiler knows to treat those files correctly:

{ // ... "files":[ "resources/resource.d.ts", ], }

This allows you to import the whole HTML file and use it as the content string like this:

import {EditorClient, Modal} from 'lucid-extension-sdk'; import importHtml from '../resources/import.html'; export class ImportModal extends Modal { constructor(client: EditorClient) { super(client, { title: 'Import a thing', width: 600, height: 400, content: importHtml, }); } }

Communicating with iframes

You can communicate with your iframe via message passing. To send a message to your iframe code, call sendMessage on your Modal or Panel subclass. You can pass any JSON-serializable value, and that value will be sent to your iframe with Window.postMessage. You can listen for these messages using the normal browser API, with your message being stored in the event object's .data:

window.addEventListener('message', (e) => { console.log(; });

To send a message from your iframe code to your Modal or Panel, call parent.postMessage like this:

parent.postMessage({ a: 'hello', b: 'world', title: 'My Page' }, '*');
The previous examples use generic javascript. If you decide to use a framework (Angular, React, etc.) for your content, how you pass or listen to messages might differ. Regardless, the code written in your Modal or Panel subclass does not need to change.

Any message you pass into parent.postMessage will be received in your Modal's or Panel's implementation of messageFromFrame. From here, you can call any method in the extension-sdk or from your own code. For example, the following code has the message logged to the console, sets the page title, and then closes the modal or panel:

protected messageFromFrame(message: JsonSerializable): void { console.log(message['a']); console.log(message['b']);['title']) this.hide(); }

Drag and drop blocks from a panel

A common use case for interacting with iframes is dragging and dropping blocks. The following section will go over a very basic implementation using generic javascript and a Panel subclass. Again, if you are working with a framework, this implementation might look different for you. See our Angular walkthrough for another example.

Custom drag and drop

Panel subclass

In order to achieve the above implementation, you will have to send a few possible messages from your iframe to your Panel:

  • drag to indicate the user has started dragging out of the panel.
  • pointermove indicating the user is dragging the content over the canvas at a particular location.
  • pointerup indicating the user has dropped the content on the canvas at a particular location.
  • cancelDrag indicating the user is no longer dragging content from the panel.

You will also send a message from the Panel to the iframe: dragDone, indicating the user has successfully dropped the shape onto the canvas, or has otherwise canceled the operation.

The code of the Panel subclass looks like this:

// ..... class MyPanel extends Panel { private readonly viewport: Viewport; constructor(client: EditorClient, viewport: Viewport) { super(client, { title: 'Hello world', iconUrl: '', location: PanelLocation.RightDock, url: 'panel.html', }); this.viewport = viewport } protected async messageFromFrame(message: any) { if (message.message == 'drag') { const maybeBlock = await this.viewport.startDraggingNewBlock({ className: 'ProcessBlock', boundingBox: {x: 0, y: 0, w: 200, h: 200}, properties: {'Text': 'Red Square', 'FillColor': '#ff0000'}, }); if (maybeBlock) {'Text', 'I have been placed!'); } this.sendMessage('dragDone'); } else if (message.message == 'pointermove') { this.viewport.dragPointerMove(message.x + this.framePosition.x, message.y + this.framePosition.y); } else if (message.message == 'pointerup') { this.viewport.dragPointerUp(message.x + this.framePosition.x, message.y + this.framePosition.y); } else if (message.message == 'cancelDrag') { this.viewport.cancelDraggingNewBlock(); } } } const panel = new MyPanel(client, viewport);

You can see that startDraggingNewBlock returns a Promise that resolves to either the newly created block itself, or undefined if the operation was canceled. You can use this to make changes to the new block (or carry out any other operation you need to perform) as soon as the block is dropped on the canvas.

We also provide the method startDraggingNewImage that acts as a wrapper around startDraggingNewBlock to streamline dragging images. See the images section for more details.

The above example creates a standard process block, but this operation works just as well with custom shapes from your shape libraries:

export class MyPanel extends Panel { // ... private scoreBarDefinition:BlockDefinition|undefined; constructor(client: EditorClient) { // ... this.client.getCustomShapeDefinition('shapes', 'score-bar').then(def => { this.scoreBarDefinition = def; }); } //When the app is loaded, it will send a message asking for an update. protected async messageFromFrame(message: any) { if (message == 'drag') { if (this.scoreBarDefinition) { const maybeBlock = await this.viewport.startDraggingNewBlock(this.scoreBarDefinition); if (maybeBlock) {'FillColor', '#ff00ffff'); } } this.sendMessage('dragDone'); } else if (message.message == 'pointermove') { this.viewport.dragPointerMove(message.x + this.framePosition.x, message.y + this.framePosition.y); } else if (message.message == 'pointerup') { this.viewport.dragPointerUp(message.x + this.framePosition.x, message.y + this.framePosition.y); } else if (message == 'cancelDrag') { this.viewport.cancelDraggingNewBlock(); } } }

iFrame code

In order to have the html in your iframe move as its being dragged, a few JavaScript functions need to be written. The following code should add the following behaviors:

  • The element they drag should move with the mouse cursor when they start dragging.
  • The element they drag should disappear from the panel when they move onto the canvas.
  • The element they drag should move back to its original location if the user completes or cancels the drag in any way.
The following code is only needed if you want to simulate the user dragging from your panel and onto the canvas (i.e. to drag around iframe HTML content). You could instead call the startDraggingNewBlock method after a button click, and dragging would start when the user's cursor hovered over the canvas.
<link rel="stylesheet" type="text/css" href="./customUI.css"> <div class="square" onpointerdown="pointerDown(event)" > Drag me </div> <script type="text/javascript" src="./customUI.js" ></script>
.square { width: 100px; height: 100px; background: #ff0000; }
// Event that started the drag (coordinate anchor1). let pointerDownEvent; // As of the last pointer event, is the (captured) pointer outside the iframe's bounds? let pointerIsOut = false; function pointerDown(event) { pointerDownEvent = event; pointerIsOut = false; startDrag(); }; // Start listening for pointer events on this iframe to implement drag & drop. function startDrag() { window.document.addEventListener('pointerup', documentPointerUp); window.document.addEventListener('pointermove', documentPointerMove); }; // Cancel drag & drop, and reset the DOM back to how it began. function stopDrag() { const target = pointerDownEvent?.target; if (target instanceof HTMLElement) { = 'static'; = ''; = ''; pointerDownEvent = undefined; } window.document.removeEventListener('pointerup', documentPointerUp); window.document.removeEventListener('pointermove', documentPointerMove); stopCanvasDrag(); }; function documentPointerUp(e) { if(pointerIsOut) { // Notify the editor that it needs to simulate canvas pointer events. parent.postMessage({message:'pointerup', x:e.pageX - window.scrollX, y:e.pageY - window.scrollY}, '*'); } stopDrag(); }; function documentPointerMove(event) { const isInside = isInsideFrame(event); if(!pointerIsOut && !isInside) { startCanvasDrag(); } else if(pointerIsOut && isInside) { // If the pointer has re-entered the iframe while dragging, tell the extension to // cancel the ongoing interaction for dragging the new block out. stopCanvasDrag(); } pointerIsOut = !isInside; //While dragging the HTML element around, move it around //with relative positioning to keep it attached to the pointer cursor. const target =; if (target instanceof HTMLElement) { = 'relative'; = event.pageY - pointerDownEvent.pageY + 'px'; = event.pageX - pointerDownEvent.pageX + 'px'; } if(isInside) { // Defense in depth: If somehow the pointer buttons have been released and the user // is moving the pointer over this iframe again, cancel any ongoing drag operation. if (event.buttons == 0) { stopDrag(); } } else { // Notify the editor that it needs to simulate canvas pointer events. parent.postMessage({message:'pointermove', x:event.pageX - window.scrollX, y:event.pageY - window.scrollY}, '*'); } }; function startCanvasDrag() { parent.postMessage({message: 'drag'}, '*'); window.addEventListener('message', windowMessage); }; function stopCanvasDrag() { window.removeEventListener('message', windowMessage); parent.postMessage({message:'cancelDrag'}, '*'); }; function isInsideFrame(event) { const x = event.pageX - window.scrollX; const y = event.pageY - window.scrollY; return x >= 0 && x <= window.innerWidth && y >= 0 && y <= window.innerHeight; }; // If you have asked the extension to start a drag-new-block interaction, you need // to listen for a message indicating that interaction has completed (either // successfully or not) in order to reset the drag/drop state entirely. function windowMessage(event) { if ( === 'dragDone') { stopDrag(); } };

Lucid styles

You can utilize similar styles of elements that appear in Lucid products in your custom UI by linking the stylesheet at, which provides the most up to date styles.

Currently you can style buttons and text fields with Lucid's style patterns:

<html> <head> <link type="text/css" rel="stylesheet" href=""> </head> <body> <button class="lucid-styling primary">primary</button> <button class="lucid-styling secondary">secondary</button> <button class="lucid-styling tertiary">tertiary</button> <input type="text" class="lucid-styling" > </body> </html>

Lucid styles example

Using OAuth APIs

You may need to access OAuth-protected APIs from your editor extensions in order to communicate with other systems or sources of data. In order to do this, you need to:

  1. Configure the OAuth provider in your manifest.json.
  2. Upload your extension package to the Developer Portal, and enter the extension ID in your manifest.json.
  3. Install your extension package for your own user account.
  4. Enter your OAuth application credentials (client ID and client secret) on the Developer Portal.
  5. Call EditorClient.oauthXhr from your editor extension.

Note that while these steps are all mandatory to using an OAuth provider in your extension package once installed, you can also use an OAuth provider in development mode only by following these steps instead:

  1. Configure the OAuth provider in your manifest.json.
  2. Create a file <providerName>.credentials.local in the root of your package, containing a JSON object with clientId and clientSecret keys.
  3. Call EditorClient.oauthXhr from your editor extension.
  4. Start the local dev server with npx lucid-package test-editor-extension <extensionName>.

Configure the OAuth provider

Currently, only OAuth 2.0 authorization via the authorization code grant flow or client credentials grant flow is supported.

Your package's manifest.json file can optionally include an array of oauthProviders which must contain the following:

Field Description Example
name A short name by which to reference this OAuth provider figma
title The name displayed to the user to describe this OAuth provider Figma
grantType A GrantType type authorizationCode
tokenUrl The URL for OAuth token exchange
refreshTokenUrl The URL for refreshing an OAuth token in case the refresh url is different from the token url
authorizationUrl The URL for the OAuth user authorization flow
usePkce A boolean flag for authorization code providers. Defaults to false. If true, the OAuth user authorization flow will use PKCE. Learn more here: false
scopes An array of OAuth scopes to request ["file_read"]
domainWhitelist An array of domains you may make requests to for this provider ["", ""]
clientAuthentication A ClientAuthentication type basic
helpUrl A URL to a help article to be shown in Lucid when prompting to redirect to your OAuth flow (max length 255 characters)
faviconUrl A URL to a favicon to show in Lucid when prompting to redirect to your OAuth flow. Rendered at 24px by 24px. (max length 255 characters)
Grant types

This configures the OAuth grant type for the OAuth 2.0 authorization flow.

Type Description
authorizationCode The Authorization Code grant type is used by confidential and public clients to exchange an authorization code for an access token. Learn more
clientCredentials The Client Credentials grant type is used by clients to obtain an access token outside of the context of a user. Learn more
Client authentication types

This configures how the client ID and client secret should be sent to the OAuth provider when exchanging the authorization code for an OAuth access token.

Type Description
basic Sends client credentials as Basic Auth header
clientParameters Sends client credentials in body

Upload your extension package

The access tokens (and refresh tokens, if available) granted to the user by the OAuth provider are not provided to your editor extension code to use directly. Instead, they are encrypted and stored on Lucid's servers according to our data retention and privacy policies.

Because of this, you must have a version of your extension package installed in order to use OAuth APIs, even in debug and development mode.

Go to the Lucid Developer Portal and create a new extension package project, if you have not already. Copy the extension package's ID into the id field in your manifest.json. This will allow your code to be recognized as being associated with that project even when run in debug mode.

Produce the for your extension with this command:

npx lucid-package bundle

and upload that file as a new version of your extension.

Configure Lucid's redirect url in OAuth provider settings

Many OAuth2 APIs require that developers specifically authorize a redirect url before it can be used with their OAuth2 client. Your Lucid extension will use the following redirect url:<packageId>/oauthProviders/<oauthProviderName>/authorized

If required by your OAuth provider, configure your client to authorize this redirect url. You will likely have to do this manually in your OAuth client settings.

Enter application credentials

On your extension package's details page on the Developer Portal, a section labeled "OAuth Providers" will appear. It will show that your credentials are missing for your new provider. Click through and enter the client ID and client secret you received from the OAuth provider when setting up your application with them.

These credentials are associated with your extension package's ID and the name you gave the OAuth provider. If you update your extension later with a new version, you do not need to re-enter these credentials.

Install your extension

Click on the version number of your extension that you just uploaded, and click the button to install the extension package for yourself. This will mark this version as installed and provide a place for OAuth tokens to be stored during your testing.

If you add more OAuth providers later, you need to make sure that you have a version of your extension package installed that includes all the providers you intend to use.

Call the API with EditorClient

In your editor extension, you can request an OAuth API call like this:

const client = new EditorClient(); const result = await client.oauthXhr('sheets', { url: '', method: 'GET', });

The first parameter to oauthXhr is the name of the OAuth provider you specified in your extension package's manifest. The second is in the same format as normal network requests through EditorClient—you can specify url, method, data, headers, and timeoutMs.

If the user has not yet received an OAuth token from that provider, they will be prompted to authorize with them, and then the requested API call will be made. If the user does not successfully authorize, then oauthXhr will behave as if it received a 401 UNAUTHORIZED response from the provider.

You can also request an OAuth API call using the asyncOAuthXhr command. The request is enqueued to eventually execute. The request may be attempted multiple times with an overall timeout of 120 seconds whereas the oauthXhr command will timeout in 10 seconds. asyncOAuthXhr takes the same parameters as oauthXhr.

Access credentials or tokens

If you need to access the client ID (from your application credentials) in your extension, you can make a request through the editor client like this:

const client = new EditorClient(); const clientId = await client.getOAuthClientId('sheets');

The parameter to the getOAuthClientId method is the name of the OAuth provider you specified in your manifest file. It returns the client ID of the given provider, which is either stored in <providerName>.credentials.local or configured in the Lucid Developer Portal.

If you need access to a user's oauth token in your extension, you can make a request through the editor client like this:

const client = new EditorClient(); const token = await client.getOAuthToken('sheets');

The parameter to the getOAuthToken method is the name of the OAuth provider you specified in your manifest file. It returns an OAuth token for the given provider, prompting the user to grant access if necessary.

OAuth security

When users authorize your extension to access external data on their behalf, Lucid stores access and refresh tokens for the user in our database. Tokens stored in this manner are encrypted. Account admins on enterprise accounts can configure unique encryption keys used to encrypt tokens for users on their account, learn more about Lucid Key Management Service here.


Add custom data fields to a shape

The easiest and simplest way to add data to a shape is to set custom shape data fields. You can do this by reading and writing from shapeData:

block.shapeData.set('Value', 50); const value = block.shapeData.get('Value');

How to create data collections

If you want to have data sets shared between Lucidchart and your own systems, you'll want to have those data sets live independent of the shapes on the canvas. From editor extensions, you can create new collections of data that work like other imported data sets.

These data collections (think of each one as a spreadsheet or database table with a known schema) are organized into data sources. One data source typically represents one set of collections that are all related, e.g. "all the data from this one custom file import".

You can create a data source, collection, and data items like this. Note the second parameter to addDataSource—this is information about where the data came from. You can read this from your extension code in order to find the data source for the import you're interested in working with:

const client = new EditorClient(); const data = new DataProxy(client); function addData() { const source = data.addDataSource('my_data_source', {'origin': 'local'}); const collection = source.addCollection('my_collection', { fields: [ {name: 'id', type: ScalarFieldTypeEnum.NUMBER}, {name: 'name', type: ScalarFieldTypeEnum.STRING}, ], primaryKey: ['id'], }); collection.patchItems({ added: [ {'id': 1, 'name': 'Ben Dilts'}, {'id': 2, 'name': 'James Judd'}, {'id': 3, 'name': 'Ryan Stringham'}, ], }); } function findData() { for (const [key, source] of data.dataSources) { if (source.getSourceConfig()['origin'] === 'local') { for (const [collectionId, collection] of source.collections) { if (collection.getName() === 'my_collection') { return collection; } } } } return undefined; }

How to associate a record with a shape on-canvas

Once you have a data source and collection with the data you want to use, you can associate an entire record with a shape on-canvas by using a reference key. Reference keys are displayed in Lucidchart near the custom shape data fields.

To create a reference key, you need the ID of the collection and the primary key of the data item you want to link:

block.setReferenceKey('my_reference_key', { collectionId:, primaryKey: '1', readonly: true, });

The primary keys of data items are accessible both as the keys on the collection.items, as well as on each item itself:

for(const [primaryKey, item] of collection.items) { assert(item.primaryKey === primaryKey); }

Using reference keys to data items is the best long-term way to associate upstream data with a shape in a diagram. This is how we associate source data with shapes in all first-party integrations.

Enabling graphics for shapes connected to data

Once you have shapes connected to data it may be helpful to display the state of the imported data with respect to the external data source. For example, a sync with the external data source may have failed in which case it is desirable to show the user an error state on shapes linked to this data.

Data sync error state

Some shapes may display an icon indicating the state of the data connected to it by default, however, for most shapes, this functionality needs to be enabled by the extension:

const position: BadgeEnumPosition = { horizontalPos: HorizontalBadgePos.LEFT, verticalPos: VerticalBadgePos.BOTTOM, layer: BadgeLayerPos.INSIDE, responsive: BadgeResponsiveness.STACK, }; block.setDataSyncStateIconPosition(position);

List local data changes

Often, data imported from a third-party source, either using a standard Lucid feature like CSV or Google Sheets import, or via a custom data connector, is editable by the end user. If the data is edited in any way--by editing data-bound text fields, using the Data Panel or other UI, or even via the Extension API--those changes are tracked until they are synced back to the original data source.

To access the list of locally-changed data items on a collection, if any, call CollectionProxy.getLocalChanges. That method returns undefined if the collection is not configured to track local changes separately from the initially-imported data.

Here is some simple code that will examine the currently-selected shape for linked data, then log a full list of all local changes to the associated data collection to the console. If you import an org chart, then make edits to that data by moving people around the organization, adding new people, or removing some people, the dump-changes action defined in this example would provide a log of all of your changes in the console:

import {EditorClient, Menu, MenuType, Viewport, CollectionProxy} from 'lucid-extension-sdk'; const client = new EditorClient(); const menu = new Menu(client); const viewport = new Viewport(client); client.registerAction('dump-changes', async () => { for (const item of viewport.getSelectedItems()) { // Look for a data reference associated with a data collection const collectionId = item.referenceKeys.first()?.collectionId; if (collectionId) { // Validate that the reference is a "branch", meaning it is tracking // local changes independent of the original imported data. const branch = new CollectionProxy(collectionId, client); if (branch.getBranchedFrom()) { const changes = branch.getLocalChanges(); if (changes) { console.log('ADDED'); for (const item of changes.getAddedItems()) { console.log(item.primaryKey); for (const [key, val] of item.fields) { console.log(key, val); } } console.log('DELETED'); for (const item of changes.getDeletedItems()) { console.log(item.primaryKey); for (const [key, val] of item.fields) { console.log(key, val); } } console.log('CHANGED'); for (const item of changes.getChangedItems()) { console.log(item.primaryKey); for (const key of item.changedFields) { console.log(key, item.original.fields.get(key), item.fields.get(key)); } } } } } } }); menu.addDropdownMenuItem({ label: 'Dump changes', action: 'dump-changes', });

Bootstrap data for documents created via API

Our public API for creating a document and Lucid Standard Import file type support the ability to include bootstrap data that's readable by a specific editor extension.

Alongside the other data you send in the body of the POST, you can include extensionBootstrapData which specifies the extension package that the data is intended for, the name of the editor extension in that package that should read this particular bootstrap data, a minimum version number for that package to be allowed to read the data, and then the data payload itself.

For example, a full create document request body could look like this:

{ "title": "new document title", "product": "lucidchart", "extensionBootstrapData": { "packageId": "74672098-cf36-492c-b8e6-2c4233549cd3", "extensionName": "sheets-adapter", "minimumVersion": "1.4.0", "data": { "a": 1, "b": 2 } } }

Because you must specify a packageId on bootstrap data, remember to specify id in your package manifest.

You can then access and process this bootstrap data as follows from your editor extension code:

client.processAndClearBootstrapData((data) => { // here, data would be {a:1, b:2} });

In that bootstrap callback, you can operate on the data immediately, or you can do asynchronous operations. If the callback returns a Promise (or you mark it async), the bootstrap data won't be cleared off the document until that promise successfully resolves.

To verify that bootstrap data processing works, you can directly set the "Bootstrap" property on the document and then refresh the page:

new DocumentProxy(client).properties.set('Bootstrap', { 'PackageId': '74672098-cf36-492c-b8e6-2c4233549cd3', 'ExtensionName': 'sheets-adapter', 'MinimumVersion': '1.4.0', 'Data': { 'a': 1, 'b': 2, }, });

Marking an extension as required for a document

The processAndClearBootstrapData function also allows you to pass in an optional boolean value that, if true, marks the document as requiring the extension. Once marked, if the extension is not installed the user will be notified about the extension being required on document load:

client.processAndClearBootstrapData((data) => {}, true);

Extension required modal

The minimum extension version required by document is the minimumVersion provided in the request body when creating the document:

Only extensions with bootstrap data available to them will be allowed to mark themselves as required for a document.

new DocumentProxy(client).properties.set('Bootstrap', { 'PackageId': '74672098-cf36-492c-b8e6-2c4233549cd3', 'ExtensionName': 'sheets-adapter', 'MinimumVersion': '1.4.0', 'Data': { 'a': 1, 'b': 2, }, });

Lucid cards

Please refer to our Lucid Cards guide for technical walkthrough.


Any string internationalization or localization must happen inside your extension code. As a convenience, the extension SDK provides access to a full-featured i18n library. This section describes how to use this library, but you are welcome to use any i18n mechanism that works well for you.

Providing and using i18n strings

There is an i18n object in the global namespace that provides a few methods you'll commonly use:

i18n.getLanguage Returns the language code currently selected by the user in Lucid, e.g. "en" or "es-LA"
i18n.setData Adds new strings to the currently active i18n dictionary, and updates the active language
i18n.get Gets a string from the currently active i18n dictionary and performs interpolations as necessary

Here is a minimal example of using the library:

const language = i18n.getLanguage(); // Set the English strings as fallbacks, in case other languages // don't have all the strings translated yet i18n.setData({ "a": "ay", "b": "bee", }, language); // If the language is set to a language that // have a dictionary for, set those strings as replacements if(language == "es-LA") { i18n.setData({ "a": "ah", "b": "bay", }, language); } // Later... const client = new EditorClient(); client.alert(i18n.get("a"));

Use JSON files for string dictionaries

Inlining your i18n strings into your source code isn't a scalable solution. Instead, you can put your dictionaries into .json files under your resources directory. For example, if you add the files resources/i18n/en.json, resources/i18n/es-LA.json, etc., your code could look like this instead:

import en from '../resources/i18n/en.json'; import de from '../resources/i18n/de.json'; import esLa from '../resources/i18n/es-LA.json'; const language = i18n.getLanguage(); i18n.setData(en, language); if (language == 'de') { i18n.setData(de, language); } else if (language == 'es-LA') { i18n.setData(esLa, language); }

In order to import JSON files like this, you'll need to make sure your resources/resource.d.ts file specifies their type:

declare module '*.json' { const content: Record<string, string>; export default content; }

You must also have exclude: /\.json$/ in the raw-loader rule for the resources directory in webpack.config.js.

String interpolation

You can mark sections of your string to be replaced with curly braces:

i18n.setData( { 'title': 'Send an email to {name}', }, i18n.getLanguage(), ); // "Send an email to Ben" console.log(i18n.get('title', {'name': 'Ben'}));


count is a reserved string interpolation ID, to be used only for pluralization.

You can provide entries in your i18n dictionary that support pluralization by appending (in most languages) .one or .other to the key:

i18n.setData( { '': 'Send an email to {count} person', 'title.other': 'Send an email to {count} people', }, i18n.getLanguage(), ); // "Send an email to 1 person" console.log(i18n.get('title', {'count': 1})); // "Send an email to 4 people" console.log(i18n.get('title', {'count': 4}));

You can learn more about pluralization rules on this site from the Unicode CLDR Project. You can see a full list of which pluralization rules you should specify for every possible language here.

For convenience, here is the list of pluralization suffixes you should provide (only for strings interpolating a count) for each language supported by Lucid:

Language Suffixes
Chinese other
Dutch one, other
English one, other
French one, other
German one, other
Italian one, other
Japanese other
Korean one, other
Polish one, other
Portuguese one, other
Russian one, few, many
Spanish one, other
Swedish one, other

Lists of strings

You can provide a list of strings for expansion into a comma-separated list.

i18n.setData( { 'shopping-list': 'Purchase {shoplist} today', }, i18n.getLanguage(), ); // "Purchase bread today" const shoppingList1 = ['bread']; console.log(i18n.get('shopping-list', {'shoplist': shoppingList})); // "Purchase bread, apples, fish, and butter today" const shoppingList2 = ['bread', 'apples', 'fish', 'butter']; console.log(i18n.get('shopping-list', {'shoplist': shoppingList2}));

By default, the list expansion will add commas and will default to and. You can change the list options by specifying formatOptions. Available options include ListStyles as

List Style Example
'long' a, b, and c
'short' a, b, & c
'narrow' a, b, c
List Type Example
'and' a, b, and c
'or' a, b, or c
'unit_list' a b c

If the text will change based on the length of the list, it can be combined with the Pluralization rules by also providing a count field.

i18n.setData( { '': 'You might invite your friend {friendlist} to the party', 'invite-list.many': 'You might invite your friends, {friendlist}, to the party', }, i18n.getLanguate(), ); // "You might invite your friend Anna to the party" const friendList1 = ['Anna']; console.log(i18n.get('invite-list', {'count': friendList1.length, 'friendlist': i18n.formatList(friendList2, {style: 'long', type: 'or'})})); // "You might invite your friends, Anna, Betty, Carl, or Dave, to the party" const friendList2 = ['Anna', 'Betty', 'Carl', 'Dave']; console.log(i18n.get('invite-list', {'count': friendList2.length, 'friendlist': i18n.formatList(friendList2, {style: 'long', type: 'or'})}));

Wrapped strings

You can "wrap" sections of your i18n strings in markup, often used for producing HTML links and other simple markup:

i18n.setData( { 'message': 'Send an email to <w0>{name}</w0>', }, i18n.getLanguage(), ); // Send an email to <a href="">Ben</a> console.log(i18n.get( 'message', {'name': 'Ben'}, ['<a href="">{}</a>'] ));

Here, you're replacing the first wrapping tag w0 with the email link, with the content of w0 being placed where the {} is in the replacement text. If you need to do multiple wrapping tags in the same i18n string, number them w0, w1, w2, etc.

Supported languages

i18n.getLanguage() will tell you the user's currently-selected language in Lucid. This will be one of the following values:

Language Code
Chinese (simplified) zh-CN
Chinese (traditional) zh-TW
Dutch nl
English en
French fr-FR
German de
Italian it-IT
Japanese ja
Korean ko
Polish pl-PL
Portuguese pt-BR
Russian ru
Spanish es-LA
Swedish sv-SE

Package settings

Extension packages can specify settings to be configured by the end user who installs the package. The user will be prompted to enter values for these setting during installation. The values of these settings are shared for all users of each installation of your package (typically all users on the Lucid account).

Settings entries in manifest.json

The package manifest file can optionally include an array of settings, each of which has the following fields:

name A string uniquely identifying this setting within this package
label A human-readable string label for this setting, which will be used in the default UI for configuring your package
description A longer description for the setting, to be displayed alongside the label where appropriate
type The type of the setting; currently the only legal value is "string"
default Optional: A default value. If set, the user will not be prompted to fill this field out at installation time

For example, if you wanted to allow the user to be able to configure a subdomain of your app to connect to for data, you might add the following to your manifest:

{ // ... "settings": [{ "name": "subdomain", "label": "MyApp Subdomain", "description": "The subdomain of your instance of MyApp. If you log in to MyApp at, you should enter 'company' for your MyApp Subdomain.", "type": "string" }] }

You can then use the user provided value for this setting within the OAuth provider section or the data connector URL. For instance, in the example below, {{=@subdomain}} will be replaced with the value the user provided for the subdomain package setting when the extension attempts to use OAuth:

{ // ... "oauthProviders": [ { "name": "provider", "title": "Provider", "authorizationUrl": "https://{{=@subdomain}}", "tokenUrl": "https://{{=@subdomain}}", "scopes": ["default"], "domainWhitelist": ["https://{{=@subdomain}}"], "clientAuthentication": "clientParameters" } ], }

Accessing the configured values

You can query the user's configured values for your package's settings from your editor extension code, check their permissions to edit those values (only users with permissions to manage the extension installation can edit setting values), and even prompt the user to edit their settings:

import {EditorClient, Menu, MenuType} from 'lucid-extension-sdk'; const client = new EditorClient(); const menu = new Menu(client); client.registerAction('import-data', async () => { let settings = await client.getPackageSettings(); if (!settings.get('subdomain')) { if (await client.canEditPackageSettings()) { await client.alert( 'You have not configured a MyApp subdomain. You will now be prompted to complete that configuration.', ); await client.showPackageSettingsModal(); settings = await client.getPackageSettings(); if (!settings.get('subdomain')) { return; } } else { client.alert( 'Your account has not configured a MyApp subdomain. Talk with your Lucid account administrator to complete configuration of the MyApp integration.', ); } } // Do whatever you need with the configured settings }); menu.addDropdownMenuItem({ label: 'Import data', action: 'import-data', });

You can programmatically set package settings using await client.setPackageSettings(myPackageSettings) if you prefer to build custom UI rather than use the standard await client.showPackageSettingsModal() modal. As with the standard modal, only users with permissions to edit the extension installation can set new package settings. myPackageSettings should be Record<string,string> | Map<string|string>. If a subset of setting values are provided, those values will be set while leaving others unchanged.

Shape libraries

This section has information on how to add a shape library to your application. You can learn more about how to design custom shapes to go in your library here.

Shape libraries can be created automatically using the lucid-package CLI tool:

/my-package$ npx lucid-package create-shape-library my-shape-library

Shape library file structure

Shape libraries are structured like this:

> shapelibraries > my-shape-library > images └── ... > shapes └── ... └── library.manifest

  • The images folder is where you will add images that will be used by shapes in the library.
  • The shapes folder contains all the .shape files for your shape library. You can learn more about .shape files here.
  • The library.manifest file declares all of your shapes, as well as some defaults for each shape like its height and width. You can learn more about shape library manifests here.

Shape library manifest

To add a shape library to your extension package, you will need to declare it in your manifest.json file.

If you used the lucid-package CLI tool to create your shape library, your manifest.json file will be updated automatically to include the new shape library manifest.

Here is an example of what a shape library manifest entry might look like:

{ // ... "shapeLibraries": [ { "name": "my-shape-library", "product": "chart", "lcszPath": "shapelibraries/my-shape-library.lcsz" } ] }

A shape library manifest should have the following fields:

  • name: The name of the shape library. This should match the name of the folder that contains the shape library.
  • product: Which product this shape library is for. Currently, only Lucidchart ("product": "chart") is supported.
  • lcszPath: The file path to the entry point of your shape library. By default, this will be shapelibraries/<name>.lcsz. This file is created when you bundle your package.

Data connectors

Data connectors can be created automatically using the create-data-connector command of the lucid-package CLI tool:

$ npx lucid-package create-data-connector my-data-connector

Data connector file structure

A data connector is structured like this:

> my-data-connector > actions └── ... └── index.ts └── debug-server.ts └── package.json └── tsconfig.json

  • The actions folder is where the implementation for each of your action handlers will go. These are called when your editor extension calls performDataAction.
  • index.ts is the entry point for your data connector. This is where you will define which actions your data connector supports.
  • debug-server.ts is a utility for running your data connector locally during development.
  • package.json and tsconfig.json define environment settings for your data connector.

Data connector manifest

To add a data connector to your extension package, you will need to declare it in your manifest.json file.

If you used the lucid-package CLI tool to create your data connector, your manifest.json file will be updated automatically to include the new data connector manifest.

Here is an example of what a data connector manifest entry might look like:

{ // ... "dataConnectors": [ { "name": "my-data-connector", "oauthProviderName": "oauth", "callbackBaseUrl": "", "dataActions": { "Import": "import" } } ] }

A data connector manifest should have the following fields:

  • name: The name of the data connector. This will be referenced by your editor extension code to specify which data connector you are using for a request.
  • oauthProviderName: The name of an OAuth provider that is defined in the extension package. When a request is made to the data connector, it will include the OAuth access token for the user who initiated the request so that your data connector can query an OAuth API on behalf of the user.
  • callbackBaseUrl: The base URL your data connector is hosted at.
  • dataActions: The data actions that your data connector supports, and the url suffix that should be added to the base url when the data action is invoked by the editor extension. In the above example, the data connector supports one action (Import), and requests will be made to when the editor extension invokes that action.

Building a data connector

You may need to access data from a source not already supported by Lucid. In order to do this, you have two choices:

  1. Fetch data using EditorClient.oauthXhr and add it to the document by creating data sources and collections in your extension as described here.
  2. Create a data connector which can access and manage data in response to requests from Lucid's servers, and add the data connector to your extension.

For simple integrations, the first approach may be entirely sufficient. However, if you want your data to update when changes are made to it in Lucid documents, or if you want updates that happen in the data's source to be automatically reflected in Lucid documents, then you'll need to create a data connector.

A data connector is a collection of callback endpoints that translate between external representations of data and Lucid's representation. Data connectors are bidirectional, allowing for data updates to flow from external sources into Lucid documents and from Lucid documents back to their external sources. A data connector must provide at least one URL at which it can respond to requests made by Lucid's servers.

When Lucid makes a request to your data connector, it will come in the form of a data action. Data actions are triggered either explicitly by code in your extension, or automatically based on user interactions with Lucid documents. Each data action has a name, a callback url suffix, an OAuth token for accessing the data on behalf of the requesting user, and other information that is relevant to fulfilling the user's request.

To add a data connector to your extension you must:

  1. Declare your data connector in your manifest.json.
  2. Implement your data connector.
  3. Expose a URL Lucid's servers can use to make requests to your data connector.

Declare your data connector

To declare a data connector, include an array of dataConnectors in your manifest.json. Each data connector must contain the following:

Field Description Example
name Then name you will use to refer to this data connector in your extension DemoDataConnector
oauthProviderName The name of an OAuth provider defined in your mainfest.json asana
callbackBaseUrl The base url Lucid will send callback events to
dataActions A mapping of the data actions supported by this data connector to the url suffix that should be appended to the callbackBaseUrl when making requests for that type of data action. {"Import" : "import"}

Any time you add or update declarations for any data connectors, you will need to package and upload your manifest, then install the extension for yourself again before your data connector can be used by your extension.

Implement your data connector

As an example, let's start with an "Import" data action which instructs the data connector what data to import onto a document.

To trigger an "Import" data action from your extension, call performDataAction on the EditorClient in lucid-extension-sdk:

const client = new EditorClient(); client.performDataAction({ dataConnectorName: 'DemoDataConnector', actionName: 'Import', actionData: {'requestedItems': ['id-1', 'id-2']}, asynchronous: true, });

If your manifest.json defines callbackBaseUrl = and dataActions = {"Import" : "import"}, Lucid will make a POST request to with a data action as the request body. The data action will include {'requestedItems': ['id-1', 'id-2']} in its data, and because the action was called with asynchronous: true it will also include a token that can be used to POST the requested data back to the Lucid. Additionally, the data action will include an OAuth access token that can be used to access the data on behalf of the user who triggered the data action. The access token can be found in the body of the data action under action.context.userCredential as well as in the Authorization header of the request.

For your convenience, the lucid-extension-sdk provides helpful wrappers for implementing your data connector that will handle request signature validation, data action routing, and more. The data connector itself can be defined using the DataConnector class. Request handling for data actions can be added by calling either defineAsynchronousAction, or defineAction on the Data connector, and providing the implementation for data actions with a specified name:

new DataConnector(new DataConnectorClient(cryptoDependencies)).defineAsynchronousAction('Import', async (action) => { const client = action.client; // <- an authorized client for sending data back to the document const actionName =; // <- "Import" const actionData =; // <- {'requestedItems': ['id-1', 'id-2']} const userCredential = action.context.userCredential; // <- the OAuth access token for the user who triggered the data action });

To add a new collection to the document containing the requested items, you must first define what the data will look like using a schema. For this example, you will use data of the following type:

type MyItemType = {id: string; name: string; age: number; isSingle: boolean};

You can define the schema for your data by calling declareSchema and specifying the types for each of the fields in your data:

const myCollectionSchema = declareSchema({ primaryKey: ['id'], fields: { 'id': {type: ScalarFieldTypeEnum.STRING}, 'name': {type: ScalarFieldTypeEnum.STRING}, 'age': {type: ScalarFieldTypeEnum.NUMBER}, 'isSingle': {type: ScalarFieldTypeEnum.BOOLEAN}, }, }); // Infer the TS type: type MyItemType = ItemType<typeof myCollectionSchema.example>; let dataItem: MyItemType = {id: 'id-1', name: 'John', age: 30, isSingle: true};

To fulfill the import request, retrieve the data that was requested, then use the authorized client provided to post the data back to the document:

const makeDataConnector( client: DataConnectorClient, ) => { return new DataConnector(client).defineAsynchronousAction('Import', async (action) => { const itemsToAdd: MyItemType[] = [ {id: 'id-1', name: 'John Doe', age: 30, isSingle: true}, {id: 'id-2', name: 'Jane Doe', age: 31, isSingle: true}, ]; action.client.update({ dataSourceName: 'Demo Data Source', collections: { 'My Collection': { schema: { fields: myCollectionSchema.array, primaryKey: myCollectionSchema.primaryKey.elements, }, patch: { items: myCollectionSchema.fromItems(itemsToAdd), }, }, }, }); }); }

Field labels

When fields are displayed in the card details panels, or in other visualizations, the field name will be used as the label for the field. You can specify the labels that will be used by providing a custom label as part of the schema while adding a collection. This is done by adding a collection to a data source that has been imported. Include the fields from the source you want to display, specify which field is the primary key, then add field labels for any field names you want to override:

source.addCollection('track-tickets', { fields: [ {name: 'id', type: ScalarFieldTypeEnum.STRING}, {name: 'description', type: ScalarFieldTypeEnum.STRING}, {name: 'assigned', type: [ScalarFieldTypeEnum.STRING, ScalarFieldTypeEnum.NULL]}, {name: 'state', type: [ScalarFieldTypeEnum.STRING, ScalarFieldTypeEnum.NULL]}, ], primaryKey: ['id'], fieldLabels: { 'id': 'Ticket', 'description': 'Description', 'assigned': 'Assigned', 'state': 'State', }, });

Expose a URL for your data connector

The final step is to use your data connector to handle incoming requests by calling dataConnector.runAction:

const response = await dataConnector.runAction(requestUrl, requestHeaders, requestBodyAsJson);

As an example, this code sets up a simple development server using node express that will accept and respond to requests made to your data connector:

import {DataConnectorClient} from 'lucid-extension-sdk'; import {makeDataConnector} from './dataconnector'; import * as express from 'express'; import * as crypto from 'crypto'; const client = new DataConnectorClient({Buffer, crypto}); const dataConnector = makeDataConnector(client); dataConnector.runDebugServer({express});

node ./serve.js

You will need to expose the URL you are using to accept requests publicly so that Lucid's servers can send it requests.


For a more thorough example, you can look at the source code for Lucid's Asana Cards extension here. You can find the extension here if you want to try it out.

Data actions

Data actions are the backbone of a data connector. Data actions are invoked either explicitly by your editor extension, or in some cases automatically by Lucid’s server to help sync data changes. Data connectors declare which data actions they support, including things like importing data, refreshing data on open Lucid documents, and patching data changes made in Lucid back to an external data source.

Some actions are reserved, meaning that they are called outside of your extension when certain criteria are met. All other actions are custom, which means you are responsible for calling them somewhere within your extension. Here is a list of the current reserved actions:

Action Description
HardRefresh Called after a document is closed for an extended period of time to refresh data on the document
Poll Called every 30 seconds when a document is open to refresh data on the document
Patch Called when users make changes to data on Lucid documents so those changes can be sent to the external data source

Registering data actions

Each action that your data connector supports needs to be declared within the data connector section of your manifest.json file:

{ // ... "dataConnectors": [ { "name": "my-data-connector", "oauthProviderName": "...", "callbackBaseUrl": "...", "dataActions": { "MyAction": "MyAction", "AnotherAction": "AnotherAction" // ... } } ] }

You also have to append the actions to your data connector’s index.ts file:

import {DataConnector, DataConnectorClient} from 'lucid-extension-sdk'; import {myAction} from './actions/myaction'; import {anotherAction} from './actions/anotheraction'; export const makeDataConnector = (client: DataConnectorClient) => new DataConnector(client) .defineAsynchronousAction("MyAction", myAction) .defineAsynchronousAction("AnotherAction", anotherAction)
It is important that the name you assign to the action in your index file matches the name in your manifest file. This is especially important for reserved data actions which are invoked by Lucid’s server (such as the hard refresh and poll actions).

Calling data actions

After actions are created, you need to call them somewhere in your extension (unless they are called automatically by the SDK). This is done by using the performDataAction() method:

await editorClient.performDataAction({ actionName: "MyAction" actionData: <Data> // ... });

Import action

An action you will likely want to create is an import action which pulls data from an external source, and posts that data to Lucid so it can be displayed on a Lucid document. How this is done will vary, but it might look something like this:

The import action is an example of a custom action, so you can name it whatever you want. For our example, we have named it “Import.”

{ // ... "dataConnectors": [ { // ... "dataActions": { "Import": "Import" } } ] }
import {DataConnector, DataConnectorClient} from 'lucid-extension-sdk'; import {importAction} from './actions/importaction'; export const makeDataConnector = (client: DataConnectorClient) => new DataConnector(client) .defineAsynchronousAction("Import", importAction)
import {DataConnectorAsynchronousAction} from 'lucid-extension-sdk'; // You would define these variables/methods depending on the structure of your data import {CollectionName} from '...'; import {getFormattedCollection, collectionSchema} from '...'; export const importAction: (action: DataConnectorAsynchronousAction) => Promise<{success: boolean}> = async ( action, ) => { const apiClient = new APIClient(...); // will contain data passed to the data connector by your editor extension // It will be up to you to determine what data you need const collectionIds = as string[]; // Fetch the data const fullCollectionData = await apiClient.getCollections({ids: collectionIds}); // Convert the data into a Lucid compatible format const formattedCollectionData =; // Send the imported data to Lucid await action.client.update({ dataSourceName: 'dataSource', collections: { [CollectionName]: { schema: { fields: collectionSchema.array, primaryKey: collectionSchema.primaryKey.elements, }, patch: { items: collectionSchema.fromItems(formattedCollectionData), }, }, }, }); return {success: true}; };

Hard refresh action

Hard refresh is called when a document is opened after it has been closed for more than 5 minutes. Your hard refresh action should fetch the data that has already been imported into Lucid by using action.context.documentCollections, and then update that data on Lucid documents by posting it back to Lucid. Your code might look something like this:

{ // ... "dataConnectors": [ { // ... "dataActions": { "HardRefresh": "HardRefresh" // NOTE: since this is a reserved action, the name must be "HardRefresh" } } ] }
import {DataConnector, DataConnectorClient} from 'lucid-extension-sdk'; import {hardRefreshAction} from './actions/hardrefreshaction'; export const makeDataConnector = (client: DataConnectorClient) => new DataConnector(client) .defineAsynchronousAction("HardRefresh", hardRefreshAction)
import {DataConnectorAsynchronousAction} from 'lucid-extension-sdk'; import {isString} from 'lucid-extension-sdk/core/checks'; // You would define these variables/methods depending on the structure of your data import {CollectionName} from '...'; import {getFormattedCollection, collectionSchema} from '...'; export const hardRefreshAction: (action: DataConnectorAsynchronousAction) => Promise<{success: boolean}> = async ( action ) => { const apiClient = new APIClient(...); // Find the data that is already on the document let collectionIds: string[] = []; Object.keys(action.context.documentCollections).forEach((key) => { if (key.includes('Collection')) { collectionIds = collectionIds.concat( action.context.documentCollections?.[key].map((collectionId) => JSON.parse(collectionId)).filter(isString), ); } }); // If there is no data, you shouldn't need to update anything if (collectionIds.length == 0) { return {success: true}; } // Fetch the data of the collections on the document const fullCollectionData = await apiClient.getCollections({ids: collectionIds}); // Convert the data into a Lucid compatible format const formattedCollectionData =; // Send the updated collections to Lucid await action.client.update({ dataSourceName: 'dataSource', collections: { [CollectionName]: { schema: { fields: collectionSchema.array, primaryKey: collectionSchema.primaryKey.elements, }, patch: { items: collectionSchema.fromItems(formattedCollectionData), }, }, }, }); return {success: true}; };
You do not need to directly call the hard refresh action in your extension. Hard refresh is a reserved action and is invoked automatically by Lucid’s server at the appropriate times.

Poll action

The poll action refreshes all of the data on a Lucid document periodically. Polling achieves the same thing as the hard refresh action, but runs every 30 seconds while the Lucid documents are open. In most cases you can reuse the code you wrote for the hard refresh action, as both strategies update your data the same way. The poll action is distinct in that it can refresh data on multiple documents at once, whereas the hard refresh action only refreshes data on a single document. If you have implemented the Hard Refresh action, your code might look something like this:

{ // ... "dataConnectors": [ { // ... "dataActions": { "Poll": "Poll" // NOTE: since this is a reserved action, the name must be "Poll" } } ] }
import {DataConnector, DataConnectorClient} from 'lucid-extension-sdk'; import {hardRefreshAction} from './actions/hardRefreshAction'; export const makeDataConnector = (client: DataConnectorClient) => new DataConnector(client) .defineAsynchronousAction("Poll", hardRefreshAction)
You do not need to directly call the poll action in your extension. Poll is a reserved action and is invoked automatically by Lucid’s server at the appropriate times.

Data connector security

Data connectors and the data they manage are secured through two means:

  1. Request signatures to validate requests to your data connector originated from Lucid's servers.
  2. Data update tokens authorize your data connector to update specific Lucid documents with data.

Because data connectors use OAuth 2.0 user access tokens to communicate with third parties, make sure you're also aware of how we secure OAuth 2.0 tokens.

Request signatures

For your security, Lucid provides a request signature header (X-RSA-Signature) on all requests sent to data connectors. The signature can be used by data connectors to validate that incoming requests are legitimate traffic coming from Lucid. The signature is a concatenation of the request body and the query parameters. Lucid signs requests using the RS384 algorithm. A full list of Lucid's public keys can be found here in JWKS format, and individual keys can be found by their IDs in PEM format at


If you use the DataConnector class from the lucid-extension-sdk to implement your data connector, request validation is performed automatically for you.

If you would like to validate the requests yourself, you can follow this Node.js example:

const crypto = require('crypto'); const parts = request.uri.split('?') const params = parts.length > 0 ? parts[1] : ""; const nonce = request.headers['X-Lucid-RSA-Nonce']; const signature = Buffer.from(request.headers["X-Lucid-Signature"], "base64"); const data = Buffer.from(body + nonce + params); const verified = crypto.verify("SHA384", data, LUCID_PUBLIC_KEY, signature);

Data update tokens

When a data connector recieves a data action request from Lucid, it will usually contain a data update token which can be used to send data back to Lucid documents. The data update token is scoped in one of two ways, depending on the type of data action that was invoked:

  1. Document Specific - The data update token is scoped to a particular document and data source. In this case, the token can be used to add, change, or delete any data for the data source on the document. The data update token can also create a data source or new collections if they don't already exist on the document.
  2. Data Source Specific - The data update token is scoped to existing data for a particular data source across multiple Lucid documents. In this case the data update token can be used to add data to or change data in existing collections on those documents. However, the token cannot be used to create new collections or new data sources on the document.

The following table shows which type of data update token each data action will recieve:

Data Action Name Update Token Type
Import Document Specific
Hard Refresh Document Specific
Patch None
Poll Data Source Specific
Custom Synchronous Action None
Custom Asynchronous Action Document Specific

lucid-package CLI

lucid-package is a command-line interface for creating and managing extension packages. The getting started section goes over the very basics of using lucid-package to create and test an extension.

To install lucid-package, just use npm install lucid-package. It is then directly executable with npx, for example npx lucid-package create.

This section describes each command available in the lucid-package CLI in detail. For a quick start using the CLI, see Getting started.


npx lucid-package create <extension-name>

Use lucid-package create to create a new Lucid extension in a new directory. This command creates a directory with the name you pass in as the extension's name, and creates and empty Lucid extension there.


npx lucid-package create-editor-extension <name>

This command creates a new directory with the given name, with a very simple editor extension inside it. Your manifest.json file is also updated to refer to this new editor extension.

You should adjust the product and scopes in the new extensions entry in the manifest to match your needs. You should request the minimum scopes required to implement your editor extension.


npx lucid-package create-data-connector <name>

This command creates a new directory with the given name in the data-connectors folder of your extension package. The new data connector comes with some skeleton code, and your manifest.json file is also updated to refer to this new data connector.

To use your data connector you must add an OAuth provider and update the oauthProviderName of the newly created data connector declaration in your manifest.json file.

You will also need to declare which data actions your data connector will support in the dataActions field of your data connector.

Finally, you will need to set a callbackBaseUrl for your data connector. For developing locally, you can set your callbackBaseUrl to http://localhost:3001/?kind=action&name= and then run the debug server with this command:

npx nodemon debug-server.ts


npx lucid-package create-shape-library <name>

This command creates a shape library in a new directory with the given name, and adds a reference to that shape library in manifest.json. You can then test your shape library using the test-shape-libraries or test-editor-extension command.

For more information, see the Shape Libraries section in the developer guide.


npx lucid-package create-image-shape-library <name> <image-path>

This command creates a shape library from a directory of images. Every file in the target folder will become a new shape with a corresponding image. Width and height for each image will be read in automatically.

You can add the tag --aspectRatio which will lock the aspect ratio for all shapes. If you want to manually set the width and height use --width x --height y.

You can then test normally with test-shape-libraries or test-editor-extension command.


npx lucid-package test-editor-extension <name>

This starts a local HTTP server that watches the given editor extension's source code and provides a debug-compiled version of it to the Lucid product specified for that editor extension in manifest.json.

See Getting started for more information on debugging editor extensions.

This command also serves all shape libraries in your extension package as well, as often those shape libraries are needed by the editor extensions to work correctly. See test-shape-libraries for more details.


npx lucid-package test-shape-libraries

This starts a local HTTP server that watches this extension's shape library source code and provides a debug-compiled version of it to the Lucid product specified for those shape libraries in manifest.json.

While running this command, all shape libraries in this extension are automatically activated and loaded into the shape toolbox. This is not the case when an end user installs your extension, but is provided as a convenience to make it easier and faster to test changes to shape libraries.

Changes to shape code should be reflected almost immediately, without a need to refresh the browser.


npx lucid-package bundle

The bundle command compiles the editor extensions and custom shape libraries in the current extension package and produces the final file that you upload to the Developer Portal as a version of your extension.

Multiple environments

The bundle command will default to bundling your package defined with the manifest.json. However, you can provide an environment parameter to define which manifest you would like to mixin when bundling. The values in that manifest will override values in manifest.json.

npx lucid-package bundle --env=staging

This example will mix in the values from manifest.staging.json and use that in the environment scoped package


npx lucid-package build-editor-extension <name>

After creating an editor extension as part of your extension package, build-editor-extension compiles that extension in release mode, performing type checking and producing the final bin/extension.js file that is included as part of the final bundle you upload to the developer dashboard.

This command is not necessary to use directly, as the bundle command already compiles your extensions, but may be useful as a troubleshooting tool.


npx lucid-package update-sdk

This command updates the npm dependency of each of your editor extensions to the latest published version of lucid-extension-sdk. You can also do this yourself by going into the directory of each editor extension and running npm install lucid-extension-sdk@latest

Release notes


npm package


Minor update to parameters passed for array of strings in an i18n string.


Lucid cards now have the following statuses:

  • New
  • To Do
  • In Progress
  • Done
  • Not Doing
  • Blocked


You can now duplicate items on a document.


You can now duplicate pages within the current document with duplicatePages in editorclient and duplicate in pageproxy.


You can now add an optional expandCallback parameter when registering unfurl link handlers. This callback is called when the expand button on an unfurl link is clicked, but before attempting to expand the iframe. The expand button appears when the expandCallback is defined or an iframe was set on the unfurl block.

const client = new EditorClient(); private async performUnfurl(url: string): Promise<UnfurlDetails | undefined> { //... } public async performExpandCallback(blockProxy: LinkUnfurlBlockProxy, url: string) { //... } client.registerUnfurlHandler('', { unfurlCallback: async (url: string) => performUnfurl(url), expandCallback: async (blockProxy: LinkUnfurlBlockProxy, url: string) => performExpandCallback(blockProxy, url), });


You can now create groups on a page


You can now use an array of strings in an `i18n`` string.


You can now add and remove columns and rows using the TableBlockProxy class.


There are new, more specific functions for adding menu items. Extensions can add menus to the top level menus, context menu, and left side content dock in Lucid Spark. Instead of one entry point for all these there are now three different functions each with specific parameters: addDropdownMenu, addContextMenu, addContentDockMenu.


You can now specify Modal and Panel classes as opening a URL in the iframe rather than specifying the HTML directly. Additionally, you can now include static resources in a directory named public at the root of your extension package. Those static resources can be used as the URL for a modal or panel, and other resources in that directory can be accessed via relative URLs.


You can now extend CustomBlockProxy for custom shapes defined in your extension package, and that class will automatically be used instead of CustomBlockProxy whenever you get a reference to a block of that type.

For more details, see Identifying custom shapes.


You can now programmatically examine data changes made locally that have not yet been synced back to the original source.


i18n support was just added to extensions, with the recommendation being to store your strings in JSON files under resources/i18n inside your editor extensions. The default webpack.config.js previously had all files inside /resources/ using the raw-loader, which is incompatible with JSON.

If you want to add i18n to an editor extension created before this update, make sure you add exclude: /\.json$/ to the raw-loader entry in module.exports.module.rules in webpack.config.js, e.g.

module.exports = { entry: './src/extension.ts', module: { rules: [ { test: /\.tsx?$/, use: 'ts-loader', exclude: /node_modules/, }, { test: /[\\\/]resources[\\\/]/, use: 'raw-loader', exclude: /\.json$/, // <- Newly added }, ], }, resolve: { extensions: ['.ts', '.js'], }, output: { filename: 'bin/extension.js', path: __dirname, }, mode: 'development', };

You also need to make sure resources/resource.d.ts specifies the type of your i18n files:

declare module '*.json' { const content: Record<string, string>; export default content; }


You can now check which Lucid product is currently loaded with EditorClient.getProduct.


Custom panels can now be placed into the toolbox area on the left of Lucidchart by specifying PanelLocation.ContentDock.


If a data URL is provided as an image fill style for a block, that image will be uploaded to Lucid's image service on the current user's account to optimize performance.


Extensions can now read, set, or clear simple static icon data graphics on shapes using BlockProxy.getSimpleStaticDataGraphic and BlockProxy.setSimpleStaticDataGraphic. For example:

const client = new EditorClient(); const viewport = new Viewport(client); client.registerAction('toggleIcon', () => { const selection = viewport.getSelectedItems(); for (const block of selection) { if (block instanceof BlockProxy) { const icon = block.getSimpleStaticDataGraphic(); if (icon) { block.setSimpleStaticDataGraphic(undefined); } else { block.setSimpleStaticDataGraphic({ icon: { set: DataGraphicIconSets.STATUS_ICONS, index: 0, }, position: { horizontalPos: HorizontalBadgePos.RIGHT, verticalPos: VerticalBadgePos.TOP, layer: BadgeLayerPos.EDGE, responsive: BadgeResponsiveness.STACK, }, color: '#ff0000', }); } } } });


Extensions can now easily read, set, or clear drop shadows on shapes using BlockProxy.getShadow and BlockProxy.setShadow.


Custom panels can now allow users to drag and drop blocks from the custom panel onto the current page. See the new section Drag and drop blocks from a panel for details.


Extension can now mark themselves as required for a document. See the new section Marking an extension as required for a document for details.


Menu items can now prompt the user to select files to be read by your extension. See the new section Custom file upload in the Developer Guide.


The EditorClient class now has new alert and confirm methods. See the new section on standard modals in the Developer Guide.


The Viewport class added the ability to hook the selection changing.


The Viewport class added the ability to hook text editing. See the new section Hook Text Editing in the Developer Guide.


The DocumentProxy class added methods to hook creating new items (blocks, lines, and groups) on the document.

The BlockProxy class added a linkText method that connects a field value on one of the block's reference keys to one of the block's text areas, so that editing that text area also writes the change back to the linked data record.


The LineProxy class added methods to add text areas as well as read and write the position of existing text areas on the line. See the new section on Managing text in the Developer Guide.


The Panel class was added, allowing the creation of custom UI in the right dock area of Lucidchart. See the new section on Custom UI in the Developer Guide. In order to use this new functionality you need to add the CUSTOM_UI scope to your editor extension definition in manifest.json.


The EditorClient class now has a new method, oauthXhr, for making OAuth-protected API calls. See the new section on Using OAuth APIs in the Developer Guide.


npm package


Lucid-package now contains more npm run scripts like npm start. There's also more colors to the commandline output.


You can now create packages with an interactive commandline form with templates for commonly used frameworks.


Added support for default values for package settings.


Added support for serving content from a public directory at the root of your extension package.


When using lucid-package bundle, you may now provide an environment param lucid-package bundle --env=staging. Your package will now be bundled with the overrides defined in manifest.staging.json and generate a


When using lucid-package test-editor-extension, you may now provide a manifest.local.json alongside your existing manifest.json that overrides values in the manifest.

For example, if you want to use a different callbackBaseUrl in your first dataConnector while debugging locally, your manifest.local.json file would look like this:

{ "dataConnectors": [ { "callbackBaseUrl": "https://debug-data-connector.localhost/connector-name/" } ] }

This allows you to always keep production configuration values in your manifest.json file while still allowing you the flexibility to override those values during development.


The OAuth 2.0 client credentials grant type is now supported.


OAuth providers can now be used in development mode without a need to bundle and install the package. Add a JSON file named &lt;provider name&gt;.credentials.local at the root of your package, containing a JSON object with clientId and clientSecret keys. This file will be used to allow OAuth requests to be made whenever your extension is being served with lucid-package test-editor-extension.


New packages created with the CLI now include a .gitignore file by default.


Custom shape libraries may now include multiple entries in the library manifest referring to the same shape definition file. Shape entries in the shape library manifest may now optionally include a "key" to be used to differentiate between them when calling EditorClient.getCustomShapeDefinition.


Dev server is more fault-tolerant while starting up.


Bugfix: Dev server now sends the correct package ID and version for shape libraries.


Editor extension manifests may now specify the CUSTOM_UI scope, which allows for the creation of custom panels in the right dock in Lucidchart.


Package manifests may now specify product in each entry of editorExtensions. This can currently be either "chart" or "spark", and defaults to "chart" if omitted.


Package manifests may now include two additional fields, id and oauthProviders.

If specified, id must match the ID of the extension on the Developer Portal, or uploading a new version of the package will fail. This serves as protection against developers accidentally uploading a package to the wrong project.

oauthProviders is configuration for OAuth providers that editor extensions may make API calls to. See the new section on Using OAuth APIs in the Developer Guide.

For specifying bootstrap data on documents created via the Lucid OAuth API, you must now use the package id and editor extension name specified in your manifest even during development, rather than specifying __local__.

When doing local development of an editor extension, if you already have another version of that extension installed, the local code will be loaded instead of the installed version.