Extensions
A declarative SPI for packaging watchers, a navigable entry type, dashboard pages, and server-side data providers into one installable unit — the fixed UI renders the spec, the extension ships no React.
A watcher captures one kind of activity (see Capture & correlation). An extension is the bigger unit: it bundles watchers and the dashboard around them — a navigable entry type, declarative dashboard pages, and the server-side queries those pages read — into one object you register with forRoot. It's how a package like @dudousxd/nestjs-durable-telescope adds a whole "Workflows" surface to your Telescope without shipping a line of UI code.
import { TelescopeModule } from '@dudousxd/nestjs-telescope';
import { durableTelescopeExtension } from '@dudousxd/nestjs-durable-telescope';
TelescopeModule.forRoot({
extensions: [durableTelescopeExtension()],
});extensions is additive and backward compatible: a watcher contributed by an extension is merged into the same watchers list the built-ins use, so the two options coexist.
The contract
An extension is a plain object — author it with defineTelescopeExtension for inference. It has a name (used in collision errors) and four optional hooks, each handed an ExtensionContext:
interface TelescopeExtension {
name: string;
watchers?(ctx: ExtensionContext): Watcher[];
entryTypes?(ctx: ExtensionContext): { id: string; label: string; dot: string }[];
dashboards?(ctx: ExtensionContext): DashboardSpec[];
dataProviders?(ctx: ExtensionContext): DataProvider[];
}
interface ExtensionContext {
readonly moduleRef: ModuleRef; // resolve host services
readonly config: ResolvedCoreConfig;
}The hooks are multi-hooks: every registered extension runs, and the results accumulate. They run once, eagerly, at module init — so a misconfiguration fails at boot, not on first request.
watchers— contribute watchers. SameWatcherSPI as everything else; they land in the mergedwatcherslist.entryTypes— contribute navigable entry types. Each is{ id, label, dot }, whereidis the backendtypefilter (e.g.'durable'),labelis the nav label, anddotis a Tailwindbg-*class for the nav dot. This is what makes the dashboard nav dynamic instead of hard-coded.dashboards— contribute declarative dashboard pages (the panel IR below).dataProviders— named server-side queries that panels bind to.
Resolve host services through ctx.moduleRef — e.g. a durable engine, a store token, or TELESCOPE_STORAGE. The extension never imports the host app; it asks the Nest container for what it needs (use { strict: false } so it finds providers in any module).
The panel IR
A dashboard page is a DashboardSpec, and its panels are a small, closed set of declarative shapes. The extension emits this spec; the fixed UI renders it — there is no React in an extension. That's the whole trick: the surface is data, not code.
interface DashboardSpec {
id: string; // globally unique, "<extName>.<page>"
label: string;
navGroup?: string; // optional nav grouping header
panels: Panel[];
}A Panel is one of four kinds. Each binds its data to a provider via a DataBinding = { provider, query? }:
| Kind | Shape | Provider returns |
|---|---|---|
stat | { kind:'stat', title, data, format?: 'number'|'percent'|'duration', accent? } | { value: number } |
timeseries | { kind:'timeseries', title, data, series: string[], style?: 'area'|'stacked' } | { rows: ({ label } & Record<string, number>)[] } |
topN | { kind:'topN', title, data, limit? } | { items: { label, value, id? }[] } |
table | { kind:'table', title, data, columns: { key, label, link? }[] } | { rows: Record<string, unknown>[] } |
A table column can deep-link out with link.href — a URL template with {key} placeholders filled from the row (set external: true to open in a new tab):
{ key: 'runId', label: 'Run', link: { href: '/durable/runs/{runId}' } }The dashboard-id convention
A DashboardSpec.id must be globally unique and follows "<extName>.<page>" — e.g. durable.workflows. This isn't cosmetic: the UI derives the owning extension from the id prefix to know which extension's providers to resolve a page's panels against. Name your providers the same way (durable.timeseries, durable.recentFailures) so the mapping is obvious.
Data providers and the request flow
A DataProvider is a named, server-side query a panel reads:
interface DataProvider {
name: string;
resolve(query: Record<string, unknown> | undefined, ctx: ExtensionContext): Promise<unknown>;
}When the UI renders a panel, it fetches that panel's binding from a single endpoint:
GET <path>/api/ext/:ext/data/:provider?<query>The server looks up the provider by name, builds an ExtensionContext, and calls resolve(query, ctx). Query params arrive as strings and are passed through verbatim. An unknown provider is a 404; a provider that throws surfaces a 502 with its message (so a panel author can see why a panel is empty).
The read gate applies
The data endpoint sits behind the same read authorizer as the rest of the dashboard API — which denies in production by default until you configure one. Extension data is never a side door: if the dashboard is gated, the panels are gated too. See The gate.
Collisions
Because the hooks accumulate across every extension, the registry guards the shared namespaces. A duplicate entry-type id, dashboard id, or provider name across two extensions throws at boot, naming both extensions:
Telescope data provider "durable.timeseries" is contributed by both
"durable" and "other". Provider names must be unique.This is why the <extName>. prefix matters — it keeps your ids from colliding with another installed extension's.
Single-slot hooks are reserved. Today every hook is multi (all extensions contribute, results merge). A hook that only one extension may own — overriding a piece of the host UI — is intentionally not part of the 0.x contract. The registry is shaped to add one when a real consumer needs it.
A minimal worked example
A complete extension: one entry type, a one-panel dashboard, and the provider that feeds it.
import { defineTelescopeExtension } from '@dudousxd/nestjs-telescope';
export function jobsTelescopeExtension() {
return defineTelescopeExtension({
name: 'jobs',
entryTypes: () => [
{ id: 'job', label: 'Jobs', dot: 'bg-sky-400' },
],
dataProviders: () => [
{
name: 'jobs.pending',
async resolve(_query, ctx) {
const store = ctx.moduleRef.get('TELESCOPE_STORAGE', { strict: false });
const pending = await store.countByTag('status:pending');
return { value: pending }; // stat → { value: number }
},
},
],
dashboards: () => [
{
id: 'jobs.overview', // "<extName>.<page>"
label: 'Jobs',
panels: [
{
kind: 'stat',
title: 'Pending jobs',
format: 'number',
data: { provider: 'jobs.pending' },
},
],
},
],
});
}TelescopeModule.forRoot({
extensions: [jobsTelescopeExtension()],
});That's it: registering the extension adds a Jobs nav entry, a Jobs dashboard page, and a stat panel that fetches GET <path>/api/ext/jobs/data/jobs.pending and renders the number — no UI code shipped.
The canonical real-world extension is @dudousxd/nestjs-durable-telescope's durableTelescopeExtension(): it registers a durable entry type plus a durable.workflows dashboard (success-rate, failed, and dead-now panels) backed by durable.state / durable.timeseries / durable.recentFailures providers.
For a step-by-step build, see Building an extension.
MCP server
An optional Model Context Protocol server at /telescope/api/mcp — stateless JSON-RPC over streamable HTTP so coding agents (Claude Code, Cursor, …) can debug straight from the captured data.
Dashboard tour
The optional dashboard — overview and pulse health, entries per type, traces, the Horizon-style live queue console with default-deny mutations, and schedules.