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">🎉</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:
-
Console loads your widget in an iframe with URL parameters
-
The bootstrap creates
window.appspace.waitForWidgetApi()and loads the Widget API -
The Widget API self-initializes to
window.appspace.widgetApiand dispatchesappspace:widgetapi:ready -
Your widget code calls
waitForWidgetApi()to safely access the API -
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) |
|
|
|
| -- |
|
|
|
|
| -- |
|
|
|
|
| -- | -- |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| -- |
|
|
|
|
| -- |
|
|
|
|
| -- |
|
|
|
|
|
|
|
|
|
|
|
| -- |
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
-
Log in to the Appspace Console
-
Navigate to Library > Widgets
-
Click Upload Widget
-
Select your
.zipfile -
Configure the widget settings
-
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 |
|---|---|
|
| Packaging dependency |
|
| Entry point with Widget API bootstrap and widget code |
|
| Widget styles |
|
| Widget metadata and config fields |
|
| Widget icon |
|
| 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'
});
Navigation
// 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
-
Create
passport/my-connector.jsonin your widget project with OAuth configuration -
Add
passport.filePathtoschema.jsonpointing to the file -
Add
authenticationsentry withpassportProvider: "custom:my-connector" -
Define
authenticatedApisthat reference the auth key -
Update
scripts/package-widget.jsto include thepassport/directory in the ZIP (already handled by the packaging script above) -
Build and upload — the backend creates the passport automatically
-
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:
-
Ensure
widget.htmlincludes the script loader -
Check browser console for errors
-
Verify the widget is loaded in an Appspace Console iframe
Configuration not received
Symptom: getConfiguration() returns empty or undefined
Solutions:
-
Ensure schema.json field names match your code
-
Check that field names use correct casing
-
Access values via
config?.data?.configuration?.fieldName?.value
Widget height not updating
Symptom: Content is cut off or has extra space
Solutions:
-
Check browser console for JavaScript errors
-
Verify
onReady()is called after initialization -
Ensure schema.json is valid JSON
-
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.
