Skip to main content

Custom Widget Developer Guide

  • April 2, 2026
  • 0 replies
  • 35 views

Nurul Ghafar

This guide walks you through creating a custom widget for the Appspace platform from scratch using plain HTML, CSS, and JavaScript — no frameworks or build tools required. By the end, you'll have a fully functional widget that integrates with the Appspace Console.

 

AI Assistance:

This page includes an attached ZIP file (custom-widget-ai-generation.zip) containing AI generation prompts. Download it and paste the contents of WIDGET_GENERATION_PROMPT.txt into any AI tool (ChatGPT, Claude, Cursor, etc.) along with a description of your widget to auto-generate all required files.

 

In this article:

 


 

Overview

 

How Widgets Work

 

Custom widgets run inside an iframe within the Appspace Console. The Widget API enables communication between your widget and the Console:

 

┌─────────────────────────────────────────────────────────────┐
│ APPSPACE CONSOLE │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Your Widget (iframe) │ │
│ │ │ │
│ │ ┌─────────────────┐ ┌────────────────────────────┐ │ │
│ │ │ Widget API │◄──►│ Your HTML/JS Code │ │ │
│ │ │ (auto-loaded) │ │ │ │ │
│ │ └─────────────────┘ └────────────────────────────┘ │ │
│ └────────────────────────────────────────────────────────┘ │
│ ▲ │
│ │ postMessage │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Console Handler (configuration, height, analytics) │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

 

The Widget API is loaded at runtime from the Console — it is NOT an npm dependency. Your widget communicates with the Console entirely via postMessage. No build tools are needed.

 

What You'll Build

 

A widget package is a ZIP file containing at minimum:

 

  • widget.html (required) — Entry point with embedded JS, loads Widget API

  • schema.json (required) — Widget configuration and metadata

  • widget.css — Your styles (optional, can be inline in widget.html)

  • images/ — Icon and other assets (optional)

  • scripts/package-widget.js — Packaging script (optional, for convenience)

 


 

Prerequisites

 

Before you begin, ensure you have:

  • A code editor (VS Code recommended)

  • A web browser

  • Node.js (v18 or later) — optional, only needed if you use the packaging script

 


 

Step 1: Set Up Your Project

 

1.1 Create the project structure

 

mkdir my-countdown-widget
cd my-countdown-widget

 

Create the following folder structure:

 

my-countdown-widget/
├── package.json
├── widget.html
├── widget.css
├── schema.json
├── images/
│ └── icon.svg
└── scripts/
└── package-widget.js

 

1.2 Create package.json

 

This is minimal — no React, no webpack, no babel:

 

{
"name": "my-countdown-widget",
"version": "1.0.0",
"description": "A countdown timer widget for Appspace",
"private": true,
"scripts": {
"package": "node scripts/package-widget.js"
},
"devDependencies": {
"archiver": "^6.0.1"
}
}

 

1.3 Install dependencies

 

npm install

 

1.4 Create .gitignore

 

node_modules/
*.zip
.DS_Store

 


 

Step 2: Create the Widget HTML

 

The widget.html file is the complete widget entry point. It contains the bootstrap script, HTML structure, and all widget logic inline.

 

Create widget.html:

 

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Countdown Widget</title>
<script>
(function() {
var appspace = window.appspace = window.appspace || {};

// Promise-based helper — available before widget-api.min.js loads.
// Uses the 'appspace:widgetapi:ready' event dispatched by the library.
appspace.waitForWidgetApi = function(timeout) {
if (appspace.widgetApi) return Promise.resolve(appspace.widgetApi);
return new Promise(function(resolve, reject) {
var timer = setTimeout(function() {
reject(new Error('Widget API did not load within ' + (timeout || 10000) + 'ms'));
}, timeout || 10000);
window.addEventListener('appspace:widgetapi:ready', function() {
clearTimeout(timer);
resolve(appspace.widgetApi);
}, { once: true });
});
};

var params = new URLSearchParams(window.location.search);
var consoleUrl = params.get('consoleUrl');
if (consoleUrl) {
var v = params.get('v');
var script = document.createElement('script');
script.src = consoleUrl + '/libs/widget-api.min.js' + (v ? '?v=' + v : '');
script.async = false;
document.currentScript.parentNode.insertBefore(script, document.currentScript.nextSibling);
}
})();
</script>
<link rel="stylesheet" href="widget.css">
</head>
<body>
<div id="widget-container" class="widget-container loading">
<div class="loading-spinner"></div>
</div>

<script>
(function() {
var container = document.getElementById('widget-container');
var debounceTimer;
var hasCalledReady = false;
var hasRaisedCompleteEvent = false;
var countdownInterval = null;

// --- Height management — reports container height to Console ---
function reportHeight() {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(function() {
var height = container.scrollHeight;
if (height > 0 && window.appspace && window.appspace.widgetApi) {
window.appspace.widgetApi.setHeight(height);
}
}, 100);
}

new ResizeObserver(reportHeight).observe(container);
new MutationObserver(reportHeight).observe(container, { childList: true, subtree: true });

// --- Countdown calculation ---
function calculateTimeLeft(targetDate) {
var target = new Date(targetDate).getTime();
var now = Date.now();
var difference = target - now;

if (difference <= 0) {
return { days: 0, hours: 0, minutes: 0, seconds: 0, complete: true };
}

return {
days: Math.floor(difference / (1000 * 60 * 60 * 24)),
hours: Math.floor((difference / (1000 * 60 * 60)) % 24),
minutes: Math.floor((difference / (1000 * 60)) % 60),
seconds: Math.floor((difference / 1000) % 60),
complete: false
};
}

// --- Render the countdown display ---
function renderCountdown(config) {
var timeLeft = calculateTimeLeft(config.targetDate);

var html = '';

if (config.title) {
html += '<h2 class="widget-title">' + escapeHtml(config.title) + '</h2>';
}

if (timeLeft.complete) {
html += '<div class="countdown-complete">';
html += '<span class="complete-icon">&#127881;</span>';
html += '<p>Countdown Complete!</p>';
html += '</div>';

// Raise analytics event when countdown completes
if (!hasRaisedCompleteEvent && window.appspace && window.appspace.widgetApi) {
hasRaisedCompleteEvent = true;
window.appspace.widgetApi.raiseAnalyticsEvent('countdownComplete', {
targetDate: config.targetDate
}).catch(function(err) {
console.error('Failed to raise event:', err);
});
}

// Stop the interval — no need to keep ticking
if (countdownInterval) {
clearInterval(countdownInterval);
countdownInterval = null;
}
} else {
html += '<div class="countdown-display">';
html += renderTimeUnit(timeLeft.days, 'Days');
html += renderTimeUnit(timeLeft.hours, 'Hours');
html += renderTimeUnit(timeLeft.minutes, 'Minutes');
if (config.showSeconds) {
html += renderTimeUnit(timeLeft.seconds, 'Seconds');
}
html += '</div>';
}

container.innerHTML = html;
}

function renderTimeUnit(value, label) {
return '<div class="time-unit">' +
'<span class="time-value">' + value + '</span>' +
'<span class="time-label">' + label + '</span>' +
'</div>';
}

function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}

// --- Initialize the widget ---
window.appspace.waitForWidgetApi()
.then(function(widgetApi) {
return widgetApi.getConfiguration();
})
.then(function(widgetConfig) {
console.log('[CountdownWidget] Configuration received:', widgetConfig);

var configData = (widgetConfig && widgetConfig.data && widgetConfig.data.configuration) || {};

var config = {
targetDate: (configData.targetDate && configData.targetDate.value) || '2025-12-31',
title: (configData.title && configData.title.value) || 'Countdown',
showSeconds: configData.showSeconds ? configData.showSeconds.value : true
};

// Remove loading state and render
container.classList.remove('loading');
renderCountdown(config);

// Start the countdown interval
countdownInterval = setInterval(function() {
renderCountdown(config);
}, 1000);

// Report height after initial render
reportHeight();

// Signal ready AFTER content is rendered and height is set
if (!hasCalledReady && window.appspace.widgetApi) {
hasCalledReady = true;
window.appspace.widgetApi.onReady().then(function() {
console.log('[CountdownWidget] Widget ready');
}).catch(function(err) {
console.error('[CountdownWidget] Failed to signal ready:', err);
});
}
})
.catch(function(error) {
console.error('[CountdownWidget] Failed to initialize:', error);
container.classList.remove('loading');
container.innerHTML = '<div style="padding: 20px; color: #c33;">Widget failed to load. Please refresh.</div>';

if (window.appspace && window.appspace.widgetApi) {
window.appspace.widgetApi.onError();
}
});
})();
</script>
</body>
</html>

 

How it works:

 

  1. Console loads your widget in an iframe with URL parameters

  2. The bootstrap creates window.appspace.waitForWidgetApi() and loads the Widget API

  3. The Widget API self-initializes to window.appspace.widgetApi and dispatches appspace:widgetapi:ready

  4. Your widget code calls waitForWidgetApi() to safely access the API

  5. Configuration is retrieved, DOM is built with vanilla JS, and onReady() is called

 


 

Step 3: Create the Widget Schema

 

The schema.json file defines your widget's metadata and configurable options.

Create schema.json:

 

{
"templateKey": "countdown-timer-widget",
"name": "Countdown Timer",
"version": "1.0.0",
"description": "Displays a countdown to a target date",
"icon": "images/icon.svg",
"author": {
"name": "Your Name",
"company": "Your Company",
"email": "you@example.com"
},
"widgetType": "custom",
"supportedSpaceTypes": ["homepage"],
"configuration": {
"fields": [
{
"name": "targetDate",
"type": "text",
"label": "Target Date",
"description": "The date to count down to (YYYY-MM-DD format)",
"groupName": "Settings",
"groupKey": "settings",
"default": "2025-12-31",
"validation": {
"required": true
}
},
{
"name": "title",
"type": "text",
"label": "Display Title",
"description": "Title shown above the countdown",
"groupName": "Settings",
"groupKey": "settings",
"default": "Countdown",
"validation": {
"required": false,
"maxLength": 100
}
},
{
"name": "showSeconds",
"type": "boolean",
"label": "Show Seconds",
"description": "Display seconds in the countdown",
"groupName": "Display",
"groupKey": "display",
"default": true
},
{
"name": "secondsStyle",
"type": "dropdown",
"label": "Seconds Display Style",
"description": "How to display the seconds counter",
"groupName": "Display",
"groupKey": "display",
"options": [
{ "value": "numeric", "label": "Numeric" },
{ "value": "animated", "label": "Animated" }
],
"default": "numeric",
"visibleWhen": { "showSeconds": "true" }
}
]
},
"ui": {
"defaultHeight": 200,
"skeletonType": "custom"
},
"analyticsEvents": [
{
"name": "countdownComplete",
"metadataKeys": ["targetDate"]
}
]
}

 

Schema Field Types

 

Type Value Type Default Extra Config Validation
text string "" -- required, isUrl, min/max (length)

textarea

string

""

--

required, min/max (length)

number

number

0

--

required, min/max (value)

boolean

boolean

false

--

--

dropdown

string

""

options

required

multiselect

string[]

[]

options

required

radio

string

""

options

required

password

string

""

--

required

code

string

""

--

required, min/max (length)

colorPicker

string

""

--

required

iconPicker

string

""

iconOptions

required

sections

object[]

[]

sectionConfig

--

 

 

Context Targeting

 

Use supportedSpaceTypes at the schema root to declare which space types the widget can appear in:

 

"supportedSpaceTypes": ["homepage", "community", "topic", "channel"]

 

Translation Keys

 

Fields and options support an optional translationKey for i18n. When present, translate(translationKey) is used instead of label.

 

{ "name": "title", "type": "text", "label": "Title", "translationKey": "WIDGETS.SPACE.MY_WIDGET.FIELDS.TITLE" }

 

{ "name": "title", "type": "text", "label": "Title", "translationKey": "WIDGETS.SPACE.MY_WIDGET.FIELDS.TITLE" }

 

Conditional Field Visibility

 

Fields can be shown or hidden based on other fields using visibleWhen and visibleWhenEmpty.

Important: Controlling fields must appear before dependent fields in the fields array.

Simple condition (AND between entries):

 

{ "visibleWhen": { "showSeconds": "true" } }

 

Array value (OR within a field):

 

{ "visibleWhen": { "contentType": ["Feed", "FeaturedPosts"] } }

 

Empty-value condition — visible only when referenced fields are empty:

 

{ "visibleWhenEmpty": ["feedIds"] }

 

When both visibleWhen and visibleWhenEmpty are present, both must pass (AND).

 


 

Step 4: Style Your Widget

 

Create widget.css:

 

* {
margin: 0;
padding: 0;
box-sizing: border-box;
}

body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}

.widget-container {
padding: 24px;
text-align: center;
}

.widget-container.loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 150px;
}

.loading-spinner {
width: 40px;
height: 40px;
border: 3px solid #e0e0e0;
border-top-color: #1976d2;
border-radius: 50%;
animation: spin 1s linear infinite;
}

@keyframes spin {
to { transform: rotate(360deg); }
}

.widget-title {
font-size: 1.25rem;
font-weight: 600;
color: #333;
margin-bottom: 20px;
}

.countdown-display {
display: flex;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
}

.time-unit {
display: flex;
flex-direction: column;
align-items: center;
min-width: 70px;
padding: 12px;
background: #f5f5f5;
border-radius: 8px;
}

.time-value {
font-size: 2rem;
font-weight: 700;
color: #1976d2;
line-height: 1;
}

.time-label {
font-size: 0.75rem;
color: #666;
text-transform: uppercase;
margin-top: 4px;
}

.countdown-complete {
padding: 20px;
}

.complete-icon {
font-size: 3rem;
display: block;
margin-bottom: 12px;
}

.countdown-complete p {
font-size: 1.25rem;
color: #333;
}

 


 

Step 6: Package Your Widget

 

To upload a widget to the Appspace Console, you need a ZIP file containing your widget files. The only mandatory files are widget.html and schema.json — everything else is optional.

You can create the ZIP manually (right-click > Compress, or zip -r my-widget.zip widget.html widget.css schema.json images/) or use the packaging script below for convenience.

 

6.1 Create the packaging script (optional)

 

Create scripts/package-widget.js:

 

const archiver = require('archiver');
const fs = require('fs');
const path = require('path');

const packageJson = require('../package.json');
const { version, name } = packageJson;

const outputPath = path.join(__dirname, '..', `widget-${name}-${version}.zip`);

const output = fs.createWriteStream(outputPath);
const archive = archiver('zip', { zlib: { level: 9 } });

output.on('close', () => {
console.log('
Widget package created successfully!');
console.log(`Size: ${(archive.pointer() / 1024).toFixed(2)} KB`);
console.log(`Location: ${outputPath}
`);
});

archive.on('error', (err) => { throw err; });
archive.pipe(output);

// Add widget files
archive.file('widget.html', { name: 'widget.html' });
archive.file('widget.css', { name: 'widget.css' });
archive.file('schema.json', { name: 'schema.json' });

// Add images directory
if (fs.existsSync('images')) {
archive.directory('images/', 'images');
}

// Add passport directory if present
if (fs.existsSync('passport')) {
archive.directory('passport/', 'passport');
}

archive.finalize();

 

6.2 Build command

 

npm run package

 

That's it — no build step needed! After running, you'll find widget-my-countdown-widget-1.0.0.zip in the project root.

 

Alternatively, you can skip the packaging script entirely and just zip the files manually. As long as the ZIP contains widget.html and schema.json, the Appspace Console will accept it. Any additional files (widget.css, images/, passport/) are included if present.

 


 

Step 7: Upload to Appspace

  1. Log in to the Appspace Console

  2. Navigate to Library > Widgets

  3. Click Upload Widget

  4. Select your .zip file

  5. Configure the widget settings

  6. Add the widget to a page or channel

 


 

Complete Example: Countdown Timer Widget

 

The complete countdown timer widget code is provided throughout this guide. Here's a summary of all files:

 

File Purpose

package.json

Packaging dependency

widget.html

Entry point with Widget API bootstrap and widget code

widget.css

Widget styles

schema.json

Widget metadata and config fields

images/icon.svg

Widget icon

scripts/package-widget.js

Creates the ZIP package

 

 


 

Widget API Reference

 

Lifecycle Methods

 

// Signal widget is ready to display
await window.appspace.widgetApi.onReady();

// Signal widget is loading
await window.appspace.widgetApi.onLoading();

// Signal widget finished loading
await window.appspace.widgetApi.onLoaded();

// Signal an error occurred
await window.appspace.widgetApi.onError();

 

Configuration

 

// Get widget configuration from Console
const config = await window.appspace.widgetApi.getConfiguration();

// Access configuration values
const targetDate = config?.data?.configuration?.targetDate?.value;

 

Height Management

 

// Set the widget iframe height (in pixels)
await window.appspace.widgetApi.setHeight(300);

 

Widget Info (v1.2.0+)

 

Get information about the Widget API library. This is synchronous — no postMessage required:

 

const info = window.appspace.widgetApi.getInfo();
console.log('Version:', info.version); // e.g. "1.2.0"
console.log('Library URL:', info.libraryUrl); // e.g. "https://console.appspace.com/libs/widget-api.min.js"

 

Useful for debugging and verifying which version of the Widget API is loaded.

 

User Info (v1.2.0+)

 

Retrieve information about the currently logged-in user. This communicates with the Console via postMessage:

 

const user = await window.appspace.widgetApi.getUserInfo();
console.log('Name:', user.displayName); // 'Jane Smith'
console.log('Email:', user.email); // 'jane@example.com'
console.log('Language:', user.language); // 'en'
console.log('Account:', user.accountName); // 'Acme Corp'
console.log('Instance:', user.instanceUrl); // 'https://acme.appspace.com'

 

The returned WidgetUserInfo object includes: id, displayName, email, language, accountId, accountName, networkId, networkName, homeNetworkId, homeNetworkName, instanceId, and instanceUrl.

 

Useful for personalizing widget content, applying locale-specific formatting, or scoping data queries to the user's account/network.

 

View Modes (v1.1.0+)

 

Widgets can request modal presentation at runtime:

 

// Open as a large modal overlay
await window.appspace.widgetApi.setViewMode('modalLarge');

// Return to normal inline display
await window.appspace.widgetApi.setViewMode('default');

 

Available modes:

 

  • 'default' — Normal inline rendering (original position)

  • 'modal' — Default centered modal (560 px / 35 rem wide)

  • 'modalSmall' — Small centered modal (384 px / 24 rem wide)

  • 'modalLarge' — Large centered modal (800 px / 50 rem wide)

  • 'modalFullScreen' — Full-viewport modal

 

The host displays a backdrop overlay and transitions the iframe into the modal position. Height is capped to the available viewport. The widget controls when to return to default — clicking the backdrop does not close the modal.

 

 

Analytics

 

Important: Only events declared in the analyticsEvents array of schema.json are accepted at runtime. Any event whose name is not listed in analyticsEvents will be rejected by the Console. You must define all analytics events your widget intends to fire in the schema.

 

// Event name MUST match an entry in schema.json analyticsEvents[].name
await window.appspace.widgetApi.raiseAnalyticsEvent('eventName', {
key1: 'value1',
key2: 'value2'
});

 

 

// Navigate to a URL
await window.appspace.widgetApi.navigate('https://example.com', '_blank');

// Download a file
await window.appspace.widgetApi.downloadFile('https://example.com/file.pdf');

 


 

Passport Configuration

 

If your widget needs to authenticate with a third-party API, you can bundle a passport configuration JSON file inside the widget ZIP. The backend automatically creates the passport on template upload. OAuth authentication is supported.

 

End-to-End Flow

 

  1. Create passport/my-connector.json in your widget project with OAuth configuration

  2. Add passport.filePath to schema.json pointing to the file

  3. Add authentications entry with passportProvider: "custom:my-connector"

  4. Define authenticatedApis that reference the auth key

  5. Update scripts/package-widget.js to include the passport/ directory in the ZIP (already handled by the packaging script above)

  6. Build and upload — the backend creates the passport automatically

  7. Widget configurator shows the passport pre-selected

 

OAuth Example

 

passport/my-connector.json:

 

{
"name": "My API Connector",
"applicationName": "custom",
"authenticationType": "OAuth",
"isCustomApplication": true,
"customApplicationInfo": {
"name": "my-connector",
"displayName": "My API Connector",
"description": "OAuth connector for My API",
"categoryType": "Custom",
"authenticationType": "OAuth",
"parentName": "custom",
"metadata": {
"clientId": "<your-client-id>",
"clientSecret": "<your-client-secret>",
"authenticationUrl": "https://api.example.com/oauth/authorize",
"tokenUrl": "https://api.example.com/oauth/token",
"profileUrl": "https://api.example.com/api/me",
"profileAccountEmailPath": "$.email",
"profileAccountIdPath": "$.id",
"profileAccountNamePath": "$.name",
"scope": "read write"
}
},
"metadata": {}
}

 

In schema.json:

 

{
"passport": {
"filePath": "passport/my-connector.json"
},
"authentications": [
{
"label": "My API Authentication",
"authenticationType": "passport",
"authKey": "myApiAuth",
"metadata": {
"passportProvider": "custom:my-connector"
}
}
],
"authenticatedApis": [
{
"label": "Get Data",
"name": "getData",
"url": "https://api.example.com/data",
"method": "GET",
"authenticationKey": "myApiAuth",
"headers": {
"Authorization": "Bearer {authorization.myApiAuth.token}"
}
}
]
}

 

In your widget code:

 

const response = await window.appspace.widgetApi.callAuthenticatedAPI('getData', {
params: { userId: '12345' }
});

 

See the Widget API reference documentation for full passport JSON structure details.

 


 

Troubleshooting

 

Verifying Widget API version

 

Use getInfo() (v1.2.0+) to confirm the loaded version and library URL:

 

const { version, libraryUrl } = window.appspace.widgetApi.getInfo();
console.log(`Widget API v${version} loaded from ${libraryUrl}`);

 

If version is "unknown", the library was loaded from a pre-1.2.0 Console build.

 

Widget API not loading

 

Symptom: window.appspace.widgetApi is undefined

 

Solutions:

  1. Ensure widget.html includes the script loader

  2. Check browser console for errors

  3. Verify the widget is loaded in an Appspace Console iframe

 

Configuration not received

 

Symptom: getConfiguration() returns empty or undefined

 

Solutions:

  1. Ensure schema.json field names match your code

  2. Check that field names use correct casing

  3. Access values via config?.data?.configuration?.fieldName?.value

 

Widget height not updating

 

Symptom: Content is cut off or has extra space

 

Solutions:

  1. Check browser console for JavaScript errors

  2. Verify onReady() is called after initialization

  3. Ensure schema.json is valid JSON

  4. Check that all required files are in the ZIP

 


 

Next Steps

  • Add authenticated API calls for fetching external data (see advanced guide).

  • Bundle a passport configuration for automatic OAuth setup (see Custom Widgets Developer Guide: Advanced).

  • Implement multi-language support using schema variations.

  • Add custom styling based on configuration options.

  • Explore the Event Structure Documentation for advanced postMessage details.

  • See the AI generation documentation attached to this page to create new widgets with AI assistance.

Note

The Custom Widget AI generation prompts are provided in the attached custom-widget-ai-generation.zip file.

Extract the .zip file, then open WIDGET_GENERATION_PROMPT.txt and copy the contents into your preferred AI tool to generate a custom widget automatically.

 

This topic has been closed for replies.