Building Custom Azure DevOps Extensions
A comprehensive guide to building custom Azure DevOps extensions, covering pipeline tasks, hub extensions, widget extensions, the extension SDK, packaging, publishing to the Marketplace, and testing strategies with complete working examples.
Building Custom Azure DevOps Extensions
Overview
Azure DevOps extensions let you add custom functionality directly into the platform — pipeline tasks, hub pages, dashboard widgets, work item form controls, and more. When the built-in features and marketplace extensions do not fit your workflow, building your own is the right move. I have built extensions that range from simple pipeline tasks that call internal APIs to full hub pages that replace entire workflow screens. The development experience is straightforward once you understand the extension model, and the marketplace lets you share extensions publicly or keep them private to your organization.
Prerequisites
- Node.js 16 or later
- Azure DevOps organization where you have permission to install extensions
- Visual Studio Marketplace publisher account (free to create)
tfx-cli— the TFS/Azure DevOps cross-platform command line tool- Basic TypeScript or JavaScript knowledge (extensions use web technologies)
- Familiarity with Azure Pipelines YAML for custom task extensions
Extension Architecture
Every Azure DevOps extension is a package of web assets (HTML, JavaScript, CSS), configuration manifests, and optionally Node.js/PowerShell scripts for pipeline tasks. The structure:
my-extension/
vss-extension.json # Extension manifest
overview.md # Marketplace listing description
images/
icon.png # Extension icon (128x128)
screenshots/ # Marketplace screenshots
src/
hub/ # Hub extension source
widget/ # Dashboard widget source
task/ # Pipeline task source
The Extension Manifest
The vss-extension.json manifest defines everything about your extension:
{
"manifestVersion": 1,
"id": "my-devops-tools",
"version": "1.0.0",
"name": "My DevOps Tools",
"description": "Custom tools for our Azure DevOps workflows",
"publisher": "your-publisher-id",
"categories": ["Azure Pipelines", "Azure Boards"],
"targets": [
{
"id": "Microsoft.VisualStudio.Services"
}
],
"icons": {
"default": "images/icon.png"
},
"content": {
"details": {
"path": "overview.md"
}
},
"files": [
{
"path": "dist",
"addressable": true
},
{
"path": "tasks/deploy-checker",
"addressable": true
}
],
"contributions": [],
"scopes": [
"vso.build",
"vso.work",
"vso.code"
]
}
The scopes field defines what permissions the extension requests when installed. Keep this minimal — only request what you actually need.
Building a Custom Pipeline Task
Pipeline tasks are the most common extension type. They appear in the task catalog and can be used in YAML or classic pipelines.
Task Structure
tasks/
deploy-checker/
task.json # Task definition
index.js # Task entry point
package.json # Node.js dependencies
node_modules/ # Bundled dependencies
icon.png # Task icon (32x32)
Task Definition (task.json)
{
"$schema": "https://raw.githubusercontent.com/Microsoft/azure-pipelines-task-lib/master/tasks.schema.json",
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"name": "DeployChecker",
"friendlyName": "Deployment Health Checker",
"description": "Verifies deployment health by checking endpoints and running smoke tests",
"helpMarkDown": "[Learn more](https://yoursite.com/docs/deploy-checker)",
"category": "Deploy",
"visibility": ["Build", "Release"],
"author": "Your Org",
"version": {
"Major": 1,
"Minor": 0,
"Patch": 0
},
"instanceNameFormat": "Check deployment health: $(endpoints)",
"inputs": [
{
"name": "endpoints",
"type": "multiLine",
"label": "Health Endpoints",
"required": true,
"helpMarkDown": "One URL per line. Each endpoint will be checked with a GET request."
},
{
"name": "expectedStatusCode",
"type": "string",
"label": "Expected Status Code",
"defaultValue": "200",
"required": false,
"helpMarkDown": "HTTP status code that indicates a healthy endpoint."
},
{
"name": "timeout",
"type": "string",
"label": "Timeout (seconds)",
"defaultValue": "30",
"required": false,
"helpMarkDown": "Maximum time to wait for each endpoint to respond."
},
{
"name": "retryCount",
"type": "string",
"label": "Retry Count",
"defaultValue": "3",
"required": false,
"helpMarkDown": "Number of times to retry a failed check before marking it as failed."
},
{
"name": "retryDelay",
"type": "string",
"label": "Retry Delay (seconds)",
"defaultValue": "10",
"required": false,
"helpMarkDown": "Seconds to wait between retries."
},
{
"name": "failOnError",
"type": "boolean",
"label": "Fail task on unhealthy endpoint",
"defaultValue": "true",
"required": false
}
],
"execution": {
"Node16": {
"target": "index.js"
}
}
}
Task Implementation
// tasks/deploy-checker/index.js
var tl = require("azure-pipelines-task-lib/task");
var https = require("https");
var http = require("http");
function checkEndpoint(url, expectedStatus, timeout) {
return new Promise(function (resolve, reject) {
var protocol = url.startsWith("https") ? https : http;
var startTime = Date.now();
var req = protocol.get(url, { timeout: timeout * 1000 }, function (res) {
var duration = Date.now() - startTime;
var body = "";
res.on("data", function (chunk) { body += chunk; });
res.on("end", function () {
resolve({
url: url,
statusCode: res.statusCode,
expected: expectedStatus,
healthy: res.statusCode === expectedStatus,
duration: duration,
bodyPreview: body.substring(0, 200)
});
});
});
req.on("error", function (err) {
resolve({
url: url,
statusCode: 0,
expected: expectedStatus,
healthy: false,
duration: Date.now() - startTime,
error: err.message
});
});
req.on("timeout", function () {
req.destroy();
resolve({
url: url,
statusCode: 0,
expected: expectedStatus,
healthy: false,
duration: timeout * 1000,
error: "Request timed out after " + timeout + "s"
});
});
});
}
function sleep(ms) {
return new Promise(function (resolve) { setTimeout(resolve, ms); });
}
function checkWithRetry(url, expectedStatus, timeout, retries, delay) {
var attempts = 0;
function attempt() {
attempts++;
return checkEndpoint(url, expectedStatus, timeout).then(function (result) {
if (result.healthy || attempts >= retries) {
result.attempts = attempts;
return result;
}
tl.debug("Endpoint " + url + " unhealthy (attempt " + attempts + "/" + retries + "), retrying in " + delay + "s...");
return sleep(delay * 1000).then(attempt);
});
}
return attempt();
}
function run() {
try {
var endpointsInput = tl.getInput("endpoints", true);
var expectedStatusCode = parseInt(tl.getInput("expectedStatusCode", false) || "200", 10);
var timeout = parseInt(tl.getInput("timeout", false) || "30", 10);
var retryCount = parseInt(tl.getInput("retryCount", false) || "3", 10);
var retryDelay = parseInt(tl.getInput("retryDelay", false) || "10", 10);
var failOnError = tl.getBoolInput("failOnError", false);
var endpoints = endpointsInput.split("\n")
.map(function (line) { return line.trim(); })
.filter(function (line) { return line.length > 0; });
if (endpoints.length === 0) {
tl.setResult(tl.TaskResult.Failed, "No endpoints specified");
return;
}
tl.debug("Checking " + endpoints.length + " endpoint(s)");
tl.debug("Expected status: " + expectedStatusCode);
tl.debug("Timeout: " + timeout + "s, Retries: " + retryCount);
var checks = endpoints.map(function (url) {
return checkWithRetry(url, expectedStatusCode, timeout, retryCount, retryDelay);
});
Promise.all(checks).then(function (results) {
var allHealthy = true;
console.log("\n=== Deployment Health Check Results ===\n");
results.forEach(function (result) {
var status = result.healthy ? "HEALTHY" : "UNHEALTHY";
var icon = result.healthy ? "✅" : "❌";
console.log(icon + " " + result.url);
console.log(" Status: " + result.statusCode + " (expected " + result.expected + ")");
console.log(" Duration: " + result.duration + "ms");
console.log(" Attempts: " + result.attempts);
if (result.error) {
console.log(" Error: " + result.error);
}
if (!result.healthy) {
allHealthy = false;
tl.warning("Endpoint unhealthy: " + result.url + " — " + (result.error || "Status " + result.statusCode));
}
console.log("");
});
// Set output variables
tl.setVariable("DeployChecker.AllHealthy", allHealthy.toString());
tl.setVariable("DeployChecker.CheckedCount", results.length.toString());
tl.setVariable("DeployChecker.HealthyCount",
results.filter(function (r) { return r.healthy; }).length.toString()
);
if (!allHealthy && failOnError) {
var unhealthy = results.filter(function (r) { return !r.healthy; });
tl.setResult(tl.TaskResult.Failed,
unhealthy.length + " of " + results.length + " endpoints are unhealthy"
);
} else {
tl.setResult(tl.TaskResult.Succeeded,
results.length + " endpoint(s) checked. All healthy: " + allHealthy
);
}
}).catch(function (err) {
tl.setResult(tl.TaskResult.Failed, "Unexpected error: " + err.message);
});
} catch (err) {
tl.setResult(tl.TaskResult.Failed, err.message);
}
}
run();
Install the task library:
cd tasks/deploy-checker
npm init -y
npm install azure-pipelines-task-lib
Building a Hub Extension
Hub extensions add new pages to Azure DevOps navigation. They appear as tabs in project settings, under Boards, Pipelines, or other hubs.
Hub Contribution
Add the hub contribution to vss-extension.json:
{
"contributions": [
{
"id": "deployment-dashboard",
"type": "ms.vss-web.hub",
"targets": [
"ms.vss-build-web.build-release-hub-group"
],
"properties": {
"name": "Deployment Dashboard",
"order": 99,
"uri": "dist/hub/index.html"
}
}
]
}
Hub Page HTML
<!-- dist/hub/index.html -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Deployment Dashboard</title>
<script src="../lib/VSS.SDK.min.js"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 0; padding: 20px; }
.dashboard { max-width: 1200px; margin: 0 auto; }
.card { border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; margin-bottom: 16px; }
.card-title { font-size: 16px; font-weight: 600; margin-bottom: 8px; }
.status-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; }
.healthy { border-left: 4px solid #28a745; }
.unhealthy { border-left: 4px solid #dc3545; }
.loading { text-align: center; padding: 40px; color: #586069; }
</style>
</head>
<body>
<div class="dashboard">
<h1>Deployment Dashboard</h1>
<div id="content" class="loading">Loading...</div>
</div>
<script>
VSS.init({
explicitNotifyLoaded: true,
usePlatformStyles: true
});
VSS.ready(function () {
var projectService = VSS.getService(VSS.ServiceIds.ProjectService);
projectService.then(function (service) {
return service.getProjectInfo();
}).then(function (project) {
loadDashboard(project.name);
});
VSS.notifyLoadSucceeded();
});
function loadDashboard(projectName) {
var accessToken;
VSS.getAccessToken().then(function (token) {
accessToken = token;
return fetchBuilds(projectName, token);
}).then(function (builds) {
renderDashboard(builds);
}).catch(function (err) {
document.getElementById("content").innerHTML =
"<p style='color: red'>Error: " + err.message + "</p>";
});
}
function fetchBuilds(project, token) {
var org = VSS.getWebContext().account.name;
var url = "https://dev.azure.com/" + org + "/" + project +
"/_apis/build/builds?$top=20&statusFilter=completed&api-version=7.1";
return fetch(url, {
headers: {
"Authorization": "Bearer " + token.token,
"Accept": "application/json"
}
}).then(function (res) { return res.json(); });
}
function renderDashboard(data) {
var html = '<div class="status-grid">';
data.value.forEach(function (build) {
var isHealthy = build.result === "succeeded";
var cssClass = isHealthy ? "healthy" : "unhealthy";
var icon = isHealthy ? "✅" : "❌";
html += '<div class="card ' + cssClass + '">';
html += '<div class="card-title">' + icon + ' ' + build.definition.name + '</div>';
html += '<div>Build #' + build.buildNumber + '</div>';
html += '<div>Branch: ' + (build.sourceBranch || "").replace("refs/heads/", "") + '</div>';
html += '<div>Result: ' + build.result + '</div>';
html += '<div><a href="' + build._links.web.href + '">View</a></div>';
html += '</div>';
});
html += '</div>';
document.getElementById("content").innerHTML = html;
}
</script>
</body>
</html>
Building a Dashboard Widget
Dashboard widgets appear on project dashboards and display metrics, charts, or status information.
Widget Contribution
{
"contributions": [
{
"id": "build-success-rate",
"type": "ms.vss-dashboards-web.widget",
"targets": [
"ms.vss-dashboards-web.widget-catalog"
],
"properties": {
"name": "Build Success Rate",
"description": "Shows build success rate over time",
"catalogIconUrl": "images/widget-icon.png",
"uri": "dist/widget/index.html",
"supportedSizes": [
{ "rowSpan": 1, "columnSpan": 2 },
{ "rowSpan": 2, "columnSpan": 2 }
],
"supportedScopes": ["project_team"]
}
}
]
}
Packaging and Publishing
Install tfx-cli
npm install -g tfx-cli
Package the Extension
# Create the .vsix package
tfx extension create --manifest-globs vss-extension.json --output-path ./dist
# Output: your-publisher.my-devops-tools-1.0.0.vsix
Publish to Marketplace
# Create a PAT on https://marketplace.visualstudio.com with Marketplace (Publish) scope
# Publish (first time)
tfx extension publish \
--manifest-globs vss-extension.json \
--token YOUR_MARKETPLACE_PAT
# Update existing extension
tfx extension publish \
--manifest-globs vss-extension.json \
--token YOUR_MARKETPLACE_PAT \
--override '{"public": false}'
For private extensions, share them with specific organizations:
tfx extension share \
--extension-id my-devops-tools \
--publisher your-publisher-id \
--share-with your-org-name \
--token YOUR_MARKETPLACE_PAT
Automated Publishing Pipeline
# azure-pipelines-extension.yml
trigger:
branches:
include:
- main
paths:
include:
- extensions/**
pool:
vmImage: "ubuntu-latest"
steps:
- task: NodeTool@0
inputs:
versionSpec: "18.x"
- script: |
npm install -g tfx-cli
cd extensions/my-extension
npm install
npm run build
displayName: "Build extension"
- script: |
cd extensions/my-extension
tfx extension create --manifest-globs vss-extension.json --output-path $(Build.ArtifactStagingDirectory)
displayName: "Package extension"
- task: PublishPipelineArtifact@1
inputs:
targetPath: "$(Build.ArtifactStagingDirectory)"
artifact: "extension-package"
- script: |
cd extensions/my-extension
tfx extension publish \
--manifest-globs vss-extension.json \
--token $(MARKETPLACE_PAT) \
--override '{"version": "1.0.$(Build.BuildId)"}'
displayName: "Publish to Marketplace"
condition: eq(variables['Build.SourceBranch'], 'refs/heads/main')
Complete Working Example: Custom Pipeline Task with Tests
Here is a complete, testable pipeline task extension with unit tests and a build pipeline:
// tasks/deploy-checker/test.js
var assert = require("assert");
// Mock azure-pipelines-task-lib for testing
var mockInputs = {};
var mockResults = [];
var mockVariables = {};
var mockTl = {
getInput: function (name, required) {
var value = mockInputs[name];
if (required && !value) { throw new Error("Input required: " + name); }
return value;
},
getBoolInput: function (name) {
return mockInputs[name] === "true" || mockInputs[name] === true;
},
debug: function (msg) { /* silent in tests */ },
warning: function (msg) { console.log(" WARNING: " + msg); },
setVariable: function (name, value) { mockVariables[name] = value; },
setResult: function (result, message) {
mockResults.push({ result: result, message: message });
},
TaskResult: { Succeeded: 0, Failed: 1 }
};
function resetMocks() {
mockInputs = {};
mockResults = [];
mockVariables = {};
}
// Test: Parse endpoints correctly
resetMocks();
mockInputs.endpoints = "https://api.example.com/health\nhttps://web.example.com/ping\n";
var endpoints = mockInputs.endpoints.split("\n")
.map(function (l) { return l.trim(); })
.filter(function (l) { return l.length > 0; });
assert.strictEqual(endpoints.length, 2, "Should parse 2 endpoints");
assert.strictEqual(endpoints[0], "https://api.example.com/health");
assert.strictEqual(endpoints[1], "https://web.example.com/ping");
console.log("PASS: Parse endpoints");
// Test: Default values
resetMocks();
mockInputs.expectedStatusCode = undefined;
var expectedStatus = parseInt(mockTl.getInput("expectedStatusCode", false) || "200", 10);
assert.strictEqual(expectedStatus, 200, "Default status should be 200");
console.log("PASS: Default status code");
// Test: Missing required input throws
resetMocks();
try {
mockTl.getInput("endpoints", true);
assert.fail("Should have thrown");
} catch (err) {
assert.strictEqual(err.message, "Input required: endpoints");
}
console.log("PASS: Required input validation");
// Test: Variable setting
resetMocks();
mockTl.setVariable("DeployChecker.AllHealthy", "true");
mockTl.setVariable("DeployChecker.CheckedCount", "3");
assert.strictEqual(mockVariables["DeployChecker.AllHealthy"], "true");
assert.strictEqual(mockVariables["DeployChecker.CheckedCount"], "3");
console.log("PASS: Variable setting");
console.log("\nAll tests passed.");
Run tests:
cd tasks/deploy-checker
node test.js
Common Issues and Troubleshooting
Extension install fails with "TF1590001: Contribution type not found"
Error: TF1590001: The contribution type 'ms.vss-web.hub' referenced by 'your-publisher.my-extension.my-hub' was not found.
Your vss-extension.json targets a contribution point that does not exist in the version of Azure DevOps you are targeting. Verify the target IDs match the Azure DevOps extension points documentation. On-premises Azure DevOps Server may not support all contribution types available in Azure DevOps Services.
Pipeline task not appearing in task catalog after installation
The task may be installed but not visible because of category filtering. Check that your task.json has the correct category and visibility fields. Also verify the task id is a valid GUID — reusing a GUID from another task causes silent conflicts.
SDK initialization fails with "VSS is not defined"
Uncaught ReferenceError: VSS is not defined
The VSS SDK script must load before your code executes. Verify the <script src> path to VSS.SDK.min.js is correct relative to your HTML file. Download the SDK from the vss-web-extension-sdk npm package and include it in your extension files.
Extension update does not reflect changes
Azure DevOps caches extension assets aggressively. After publishing an update, clear your browser cache or open the extension in an incognito window. For pipeline tasks, the agent also caches task versions — increment the version in task.json to force agents to download the new version.
Best Practices
Use semantic versioning for extensions and tasks. Bump the major version when you make breaking changes to task inputs. Pipeline definitions reference task versions, so a breaking change in a minor version silently breaks existing pipelines.
Bundle node_modules with pipeline tasks. Pipeline agents do not run
npm installfor your task. Thenode_modulesdirectory must be included in your extension package. Usenpm install --productionto minimize the bundle size.Test extensions locally with
tfx extension serve. The tfx-cli includes a local development server that proxies requests to Azure DevOps while serving your extension assets locally. This avoids constant publish-install cycles during development.Keep extension scopes minimal. Only request the OAuth scopes your extension actually needs. Extensions requesting broad scopes (
vso.profile,vso.work_write) face more scrutiny during marketplace review and reduce user trust.Add telemetry to custom tasks. Log task execution metrics (duration, success/failure, input values) to Application Insights or a custom endpoint. This helps you understand usage patterns and diagnose failures across installations.
Write overview.md for the marketplace listing. A clear description with screenshots, configuration examples, and known limitations dramatically increases adoption. Treat your marketplace page like a product landing page.