# Notch Metrics: custom apps and widgets > Everything needed to write a Notch Metrics custom app. A custom app is a > local connector: one directory holding a manifest and a JavaScript module, > which runs on the user's own Mac against their own API key and is never > uploaded. Each number it declares becomes a widget in the MacBook notch. Canonical URL: https://notchmetrics.com/docs/llms.txt Human documentation: https://notchmetrics.com/docs ## What this file contains 1. Every documentation page, in reading order, in full. 2. The JSON Schema the manifest is validated against. 3. The TypeScript interfaces the runner supplies and expects. 4. A complete worked example. ## Rules that are not negotiable - All network access goes through `ctx.http`. A bare `fetch` bypasses the host allowlist, the redirect re-check and the response cap. - Every host the connector contacts is listed in `allowedHosts`. Nothing else is reachable through `ctx.http`. - A connector is **contained, not sandboxed**. The allowlist bounds where it can send data. It still runs as the user, with their file system. - `entry` is always `connector.mjs`. - The module has one default export, built by `createApp`. - Each metric is a `createMetric` object carrying its own `fetch`. Metrics are not declared in the manifest. - `manifest.json` is GENERATED by `npx @notchmetrics/core build`. Never hand write it, and never ask the user to. - `verify` is optional, and only worth writing where there is a credential. - Money is an integer in minor units with an ISO 4217 code. Never a float. ## Debugging a connector that is already installed Every app writes a log. Read it before guessing: ``` ~/Library/Application Support/NotchMetrics/logs/custom-apps/.log ``` Each run appends a block naming the verb, the metrics asked for and the duration, then one line per outcome, one per HTTP call with its status, and one per `ctx.log` message. ``` === 2026-10-26 16:04:11 fetch downloads 154ms ok downloads value=42444710 points=7 http GET api.npmjs.org 200 1051ms log asked for react ``` What each line tells you: - `failed [runtime] ` is the error the code threw. - `[policy]` with `http ... blocked` means the host is missing from `allowedHosts`. That is the one failure the tile cannot explain. - `[timeout]` means the whole run passed `timeoutMs`, which every metric in that run shares. - `[bad_result]` means `fetch` returned a shape the app refused. The line names the field. --- # Getting started ## Quickstart URL: https://notchmetrics.com/docs/quickstart A custom app is one folder holding two files. This builds one that reads a public API, so there is no key to set up on the way. ## 1. Make the folder ```bash mkdir -p ~/notch-apps/npm-downloads cd ~/notch-apps/npm-downloads ``` The folder name has to match the `id` in the manifest. ## 2. Write the app `connector.mjs` is the whole app. There is no manifest to write. ```js import { createApp, createMetric } from "@notchmetrics/core"; const downloads = createMetric({ id: "downloads", label: "Downloads", unit: "count", series: { granularities: ["day"] }, methodology: "Downloads reported by the npm registry for the window.", params: [{ key: "package", label: "Package", type: "string" }], async fetch(ctx, request) { const name = request.params.package ?? "react"; const response = await ctx.http( `https://api.npmjs.org/downloads/range/${request.from}:${request.to}/${name}`, ); if (!response.ok) throw new Error(`The registry returned ${response.status}.`); const body = await response.json(); const points = body.downloads.map((day) => ({ t: day.day, v: day.downloads })); return { value: points.reduce((total, point) => total + point.v, 0), points }; }, }); export default createApp({ id: "npm-downloads", name: "npm downloads", description: "Download counts for a package on the npm registry.", icon: { type: "symbol", name: "shippingbox.fill" }, allowedHosts: ["api.npmjs.org"], refreshIntervalSeconds: 3600, metrics: [downloads], }); ``` `params` puts a Package field on the widget, so one app serves a tile per package. Network access goes through `ctx.http`. A bare `fetch` bypasses the host allowlist, so it is refused. There is no `verify` here. This API needs no credential, so there is nothing to prove. ## 3. Build the manifest ```bash npx @notchmetrics/core build ``` ``` Wrote /Users/you/notch-apps/npm-downloads/manifest.json 1 metric: downloads ``` That reads your code and writes `manifest.json` beside it. Run it again after any change to the app or its metrics. The file exists so Notch Metrics can read what an app asks for without running it. Your code stays the only place you edit. ## 4. Load it Open Settings, then Custom Apps, then Load app, and pick the folder. Notch Metrics copies it into its own folder and validates the manifest. A manifest that fails is refused with the reason. Enable the app with the switch. ## 5. Add the widget Open the notch, add a widget, and find npm downloads under Custom. Type a package name in the Package field. The tile fills on the next refresh. ## What to read next [The manifest](/docs/connector-manifest) covers every field. [The code](/docs/connector-code) covers the context object and what to return for each kind of number. If you want your editor to check the shapes as you type, [development setup](/docs/development-setup) covers the types package. ## Development setup URL: https://notchmetrics.com/docs/development-setup A custom app is plain JavaScript and needs no build step. The setup below is what makes writing one comfortable. ## What you need Notch Metrics on Apple silicon, macOS 14 or later. Node 20 or later on your Mac. Notch Metrics runs your connector with the Node you already have, so an app that works at your terminal works in the notch. That is the whole requirement. No bundler, no package manager, no dependencies. ## Finding Node A menu bar app does not inherit your shell's `PATH`, so Notch Metrics looks for Node at absolute paths. It checks Homebrew on Apple silicon and Intel, then `/usr/bin`, then every version manager install side by side: nvm, fnm and Volta. Where a version manager holds several, Notch Metrics takes the newest. Set `NOTCH_NODE_PATH` to override that with an exact binary. It wins over everything else and is never second guessed. ## The package ```bash npm install --save-dev @notchmetrics/core ``` It gives you two things: `createApp` and `createMetric`, which your editor checks as you type, and the `build` command that writes `manifest.json` from your code. Nothing is imported at run time. The runner supplies the context object, so the file you ship runs with no `node_modules` beside it. Writing TypeScript is fine. Build it to a single `connector.mjs` with the default export and ship that. ## The loop Edit the file in your own folder. ```bash npx @notchmetrics/core build ``` Then open Settings, then Custom Apps, and pick Reload from the app's three dot menu. Run the build after any change to the app or its metrics. A change to the body of a `fetch` needs no rebuild, because the manifest does not describe it. The build is also the check. It validates every field against the schema the Mac app uses, reports all the problems at once, and writes nothing until they are fixed. Notch Metrics runs a copy it owns, so an edit in your project folder takes effect when you reload it. That is what stops a build in a folder you are still editing from silently changing an app you already enabled. ## Seeing what went wrong A metric that throws puts its message on the tile. Throw with a sentence somebody can act on, because `The API returned 429.` is useful and `undefined is not an object` is not. A run that passes `timeoutMs` is killed and the tile says so. Every metric in one refresh shares that budget, so measure a real run before raising it. ## The log Every app writes its own log. Open it from the app's three dot menu, under Open logs, which shows the file in Finder. ``` ~/Library/Application Support/NotchMetrics/logs/custom-apps/.log ``` Each run appends a block. ``` === 2026-10-26 16:04:11 fetch downloads 154ms ok downloads value=42444710 points=7 http GET api.npmjs.org 200 1051ms log asked for react ``` It records what was asked for, what each metric answered, every host called with its status, and anything passed to `ctx.log`. A failure carries the message your code threw. ``` === 2026-10-26 16:09:02 fetch stars 88ms failed stars [runtime] The API returned 429. http GET api.github.com 429 81ms ``` A host your manifest does not allow shows as blocked, which is the one mistake the tile cannot explain on its own. ``` http GET evil.example.com blocked 0ms ``` The file keeps the last two thousand lines. Paste the relevant block into your agent to get a connector fixed. ## Authoring with an assistant The [introduction](/docs) has a prompt that points a model at one file carrying the manifest schema, the runtime contract and a worked example. --- # Widgets ## What a widget is URL: https://notchmetrics.com/docs/what-is-a-widget A widget is one tile in the notch. Hover the camera housing and the notch opens. Move away and it looks untouched. Every widget carries four things. Change any one of them and the tile changes. ## The metric The number itself, and the definition behind it. Stripe revenue is a different metric from Stripe MRR, and both come from the same connection. Each metric declares how it is calculated. You can read that sentence on the [sources pages](/sources) before you add the tile. ## The window How far back the number looks. Notch Metrics offers five windows: 24 hours, 7 days, 30 days, 90 days and 12 months. Not every number uses one. A gauge showing your current plan usage is a reading taken now, so the window does nothing and the tile does not show one. A tile whose number depends on the window prints it in the title, next to the name. ## The shape How the tile draws the number. A sparkline under a figure, a filled arc, a divided bar, a ring. There are seven shapes and the next page covers all of them. The shape is yours to pick. Notch Metrics suggests one when you add a widget, based on what the metric answers, and you can change it afterwards. ## The size How much of the grid the tile takes. The notch is six columns wide and opens up to four rows deep. A tile spans anything that fits. Size is not only room. A one by one tile shows a mark and a number. Give the same widget three columns and it reads left to right, with the name on one side and the figure and the chart on the other. ## Where the numbers come from Sixteen services are connected server side. Your provider API keys are held encrypted and never reach the Mac. For anything else you write a custom app. That is one directory on your Mac holding a manifest and a JavaScript file, running against your own key. The second half of these docs covers how to build one. ## The widget types URL: https://notchmetrics.com/docs/widget-types Seven shapes. Which ones a widget offers depends on what its metric answers, so a tile never offers a shape it has no data for. ## Plain The figure on its own, with the change beneath it. Right for a number with no history worth drawing. A gauge needs a ceiling and a sparkline needs points. A plain tile needs neither, so it is the shape that always works. ## Sparkline A line under the figure, one point per bucket across the window. This is the default for a metric that answers a series. The line carries the direction of travel, which the figure on its own cannot. ## Bars One bar per bucket, over the same points a sparkline would use. Suits a count that lands in discrete lumps. Deploys per day read as bars. A balance over time reads as a line. ## Gauge The figure inside a filled arc, drawn as a share of its maximum. Only for a ratio, where the ceiling is 1.0 by definition. An arc on a count would be filled to an arbitrary fraction of nothing. Cache hit rate and plan usage are gauges. A gauge needs no history, so it draws on a metric with no series at all. ## Split One bar divided into coloured segments, with a key underneath. For a breakdown that adds up to a whole. Spend by model, revenue by plan. The key names as many segments as the tile has room for, with a dot in each segment's colour. What does not fit is still in the bar. ## Ranked A bar per category, largest first, each with its name above it. The same data a split bar carries, read as a list. Pick this when the names matter more than the proportions. Taller tiles show more rows. ## Ring A ring with the total in the hole, one arc per category. A ring carries the breakdown and the total in one shape. Slices too thin to draw at their true width are widened to the smallest readable arc, so a category that exists is always visible. ## Which shapes a metric offers A metric declares one of four answers, and that decides the shapes. | The metric answers | Shapes offered | | ------------------------ | ---------------------- | | One number for now | Plain, gauge | | A number per bucket | Sparkline, bars, plain | | One total for the window | Plain, gauge | | Rows of categories | Split, ranked, ring | Notch Metrics picks the suggested shape when you add the widget. A ratio starts as a gauge, a list starts as ranked bars, anything with points starts as a sparkline. Change it in the widget's settings whenever you like. ## Sizes and windows URL: https://notchmetrics.com/docs/sizes-and-windows ## The grid The notch is six columns wide. It opens two rows deep and drags to four. Six columns, so three tiles sit across the notch at two columns each. That is the width a headline figure like $55,678 fits in. A widget spans anything from one by one up to six by four. Drag the handle on a tile's edge to resize it. Neighbours move along to make room. ## The presets Four sizes are one click away in the widget's settings. | Size | What it holds | | ---- | ---------------------------------------------- | | 1x1 | A mark and a number | | 2x1 | A name and a number | | 2x2 | A name, a number and a chart | | 6x2 | The full width, for a list or a long breakdown | Every span in between is reachable by dragging. ## How size changes the layout Size decides more than room. A tile one row tall and three or more columns wide reads left to right. The name sits on the left, the figure and the chart on the right. Stacked, that tile left its right half empty. Two columns stays stacked at one row tall. Side by side, a 2x1 truncates every field to an ellipsis. Distributions budget their rows against the tile height. A ranked tile two rows tall shows three bars, and three rows shows six. What does not fit is dropped from the tail, which is the part of a distribution nobody needs. ## The windows Five windows: 24 hours, 7 days, 30 days, 90 days and 12 months. Pick one per widget. Two tiles on the same metric can carry different windows, so revenue over 7 days and revenue over 12 months sit side by side. ## When a window does nothing Not every number depends on one. A metric that answers a reading taken now ignores the window entirely. Your current plan usage is the same number whichever window the widget carries, so the tile does not print one. A tile whose number does depend on the window prints it in the title, after the name. Revenue over thirty days reads as "Revenue, 30d". That is how you tell at a glance which of two tiles is the longer view. ## Granularity How the window is bucketed: hour, day, week or month. Notch Metrics picks the bucket that suits the window, so 24 hours buckets by hour and 12 months buckets by month. A metric declares which buckets it can answer, and the picker offers only those. --- # Custom apps ## How custom apps work URL: https://notchmetrics.com/docs/custom-apps A custom app puts a number Notch Metrics has no connector for into your notch. A figure from your own admin API, downloads of a package you publish, rows from a service nobody else uses. It is one folder holding two files. ``` my-app/ manifest.json what the app is, and every number it offers connector.mjs two exported functions ``` The folder lives on your Mac. Nothing in it is uploaded. ## One app, many widgets A custom app is one piece of code holding one credential. Every number it declares is a metric of that app, and each becomes a widget you can add. One API key, one app, as many widgets as you want from it. ## Where it runs On your Mac, with the Node you already have, as a separate process per refresh. Every metric in one refresh runs in the same process. The whole batch shares the manifest's `timeoutMs`, and a run that passes it is killed. ## What it can reach The manifest lists every host the app may contact. Nothing else is reachable through `ctx.http`. Requests go out over https. Private and loopback addresses are refused. Redirects are followed three deep and the host is checked again on every hop, so a server that redirects to a host outside your list never receives your credential. A response is capped at 4 MB. ## Contained, not sandboxed The host allowlist bounds where a custom app can send data. The code still runs on your Mac as you, with your file system. Read a custom app before you enable it, exactly as you would read any script you are about to run. ## Credentials A key you type into an app's settings row is stored in your Keychain and passed to the app at run time. Only the keys the manifest declares are passed, so a credential the app never asked for is not readable by it. An app can also borrow a sign in already on your Mac, where the token rotates too often to paste. It receives one only by naming it in the manifest, and the settings row says which, so you can see what an app will be given before you enable it. ## What enabling approves Enabling an app approves three things: the hosts it may reach, the credentials it asks you for, and any sign in it borrows. Edit the code and it keeps running. Widen any of those three and the row asks again, naming what is newly wanted. A version bump or a new icon is not a question anybody can answer, so Notch Metrics does not ask one. ## The log Every app writes its own, at `~/Library/Application Support/NotchMetrics/logs/custom-apps/.log`. Open it from the three dot menu, under Open logs. Each run records what was asked for, what each metric answered, every host called with its status, and anything the app passed to `ctx.log`. See [development setup](/docs/development-setup) for what a block looks like. ## Reloading and removing The three dot menu on each row reloads the app from disk, opens its log, or removes it. Notch Metrics runs a copy it owns, so reload is what picks up an edit. Removing deletes that copy and every widget built on it, and leaves the folder you loaded it from untouched. ## The app and its manifest URL: https://notchmetrics.com/docs/connector-manifest `createApp` declares what the app is, what it may reach, and which metrics it offers. Everything in this page is a field on that call. ```js export default createApp({ id: "my-app", name: "My app", description: "What this app reads, in a sentence.", icon: { type: "symbol", name: "shippingbox.fill" }, allowedHosts: ["api.example.com"], auth: [{ key: "api_key", label: "API key", type: "secret" }], metrics: [activeUsers, revenue], }); ``` ## Why there is still a manifest `npx @notchmetrics/core build` writes `manifest.json` beside your code. Notch Metrics reads that file to learn what an app asks for **before** it runs a line of the app. That is what lets the settings row show you the hosts and the credentials, and what makes enabling the app a decision you can actually make. Working it out by running the app would be the wrong way round. So the code is the source and the file is generated. You edit one place. Run the build again after changing the app or any of its metrics. A manifest that has drifted from the code is the thing this command exists to prevent. ## What the build checks Every field is validated against the same schema the Mac app holds the manifest to. A build that passes produces a file the app will load. Mistakes are reported together, named by the metric they are in. ``` Metric "Bad Id With Spaces" is not valid: id: A metric id is lower-case letters, digits and underscores, starting with a letter. shortLabel: Too big: expected string to have <=12 characters methodology: Too small: expected string to have >=1 characters params.0.type: Invalid option: expected one of "string"|"secret"|"select"|"url"|"boolean" ``` Each metric is checked as it is created, so the error names the metric you wrote rather than a position in an array. Nothing is written until every check passes. ## The app | Field | What it does | | ------------- | ----------------------------------------------------------------- | | `id` | Lower case letters, numbers and hyphens. Matches the folder name. | | `name` | What the Custom Apps list shows. | | `description` | One sentence. | | `version` | Yours to display. Notch Metrics never upgrades a custom app. | | `author` | Optional. | ## The icon ```js icon: { type: "symbol", name: "shippingbox.fill", tint: "#D97757" } ``` Three forms. `symbol` names an SF Symbol and suits most apps, because it is vector and needs no file. `emoji` takes one character. `file` takes the name of an image inside the app's folder, never a path and never a URL. An unknown symbol name draws the generic mark, so a typo costs an icon and nothing else. ## Hosts ```js allowedHosts: ["api.example.com"]; ``` Every host the app may contact. Nothing else is reachable through `ctx.http`. A host, not a URL. `https://api.example.com/v1` is refused, because this list is the containment boundary and a typo in it should fail loudly. ## Credentials ```js auth: [{ key: "api_key", label: "API key", type: "secret" }]; ``` Each entry becomes a field in the app's settings row. A `secret` is stored in your Keychain and shown masked. The other types are `string`, `select`, `url` and `boolean`. Omit `auth` for an app that needs nothing. There is no mode to set: an app with fields asks for them, and an app with none does not. ## Borrowed sign ins ```js hostCredentials: ["claude_code_oauth"]; ``` The narrow exception to typing your own key: a token already on your Mac that rotates too often to paste. Notch Metrics reads it and hands it to the app. An app receives one only by naming it here, and the settings row says which, so you can see what an app will be given before you enable it. ## Timing ```js refreshIntervalSeconds: 300, timeoutMs: 8000 ``` `refreshIntervalSeconds` runs from 60 to a day, and defaults to 300. Notch Metrics floors it regardless, because an app that runs every second gets your API key rate limited and flattens a battery doing it. `timeoutMs` runs from 1000 to 20000 and defaults to 8000. Every metric in one refresh shares the budget, so measure a real run before raising it. ## The numbers `metrics` takes the objects `createMetric` returns. Each one carries its own `fetch`, so a metric's name, its unit and the code that answers it sit together. See [the code](/docs/connector-code). ## The code URL: https://notchmetrics.com/docs/connector-code `connector.mjs` has one default export. Each metric carries its own `fetch`, so there is no dispatch to write. ```js import { createApp, createMetric } from "@notchmetrics/core"; const downloads = createMetric({ id: "downloads", label: "Downloads", unit: "count", series: { granularities: ["day"] }, methodology: "Downloads reported by the registry for the window.", params: [{ key: "package", label: "Package", type: "string" }], async fetch(ctx, request) { const name = request.params.package ?? "react"; const response = await ctx.http( `https://api.npmjs.org/downloads/range/${request.from}:${request.to}/${name}`, ); if (!response.ok) throw new Error(`The registry returned ${response.status}.`); const body = await response.json(); const points = body.downloads.map((day) => ({ t: day.day, v: day.downloads })); return { value: points.reduce((total, point) => total + point.v, 0), points }; }, }); export default createApp({ id: "npm-downloads", name: "npm downloads", description: "Download counts for a package on the npm registry.", allowedHosts: ["api.npmjs.org"], metrics: [downloads], }); ``` Everything about a metric sits in one object: what it is called, what it means, what the widget asks for, and the code that answers it. Adding one is adding an object to that array. The import is types only. Nothing is loaded at run time, so a connector with no `node_modules` beside it runs exactly the same. ## createMetric | Field | What it does | | ------------- | ------------------------------------------------------------ | | `id` | Names the metric. Used to route a request, so it is stable. | | `label` | What the widget gallery shows. | | `shortLabel` | What fits in a tile, twelve characters. Defaults to `label`. | | `unit` | `currency`, `count`, `ratio`, `duration` or `bytes`. | | `methodology` | How this number is derived, in one sentence. Required. | | `params` | Fields the widget supplies, like a repository name. | | `fetch` | Answers the metric for one window. | ### The kind Set exactly one. It decides which shapes the widget offers and whether the window means anything. ```js scalar: true; // one number for now series: { granularities: ["day"]; } // a number per bucket total: { granularities: ["day"]; } // one number for the window list: { limit: 6; } // rows of categories ``` A `series` and a `total` compare against the previous window by default. Pass `compare: false` where the API cannot answer that cheaply. ### Money `unit: "currency"` takes an integer in minor units and an ISO 4217 code. Never a float. ```js return { value: 125_00, currency: "GBP" }; ``` ## The context `fetch` and `verify` both receive `ctx`. | Field | What it is | | ----------------- | --------------------------------------------------------- | | `ctx.credentials` | Only the keys your manifest declares, frozen | | `ctx.http` | The only network access | | `ctx.timeRange` | The window, as `from`, `to`, `granularity` and `timeZone` | | `ctx.signal` | Aborted when the run exceeds `timeoutMs` | | `ctx.log` | Lands in the run log, never on the tile | `credentials` is filtered before your code runs, so a key the app never asked for is not readable by it. ### Use ctx.http ```js const response = await ctx.http("https://api.example.com/v1/users", { headers: { authorization: `Bearer ${ctx.credentials.api_key}` }, }); ``` A bare `fetch` bypasses the host allowlist, the https requirement, the redirect re-check and the 4 MB cap. Use `ctx.http`. It returns `ok`, `status`, `headers`, `json()` and `text()`. ## The request | Field | What it is | | -------------------------------------- | ------------------------------------------------------- | | `request.params` | Whatever the widget supplied for your declared `params` | | `request.from`, `request.to` | The window, as ISO timestamps | | `request.range`, `request.granularity` | The window as the user chose it | | `request.comparePrevious` | Whether the previous window is wanted | | `request.id` | Which widget asked. Opaque, and echoed back | `request.id` is what keeps two tiles on one metric apart. A tile for `react` and a tile for `vue` are identical in every other field. ## What fetch returns `value` is required. Everything else is optional. | Field | For | | --------------- | ------------------------------------------- | | `previousValue` | The delta against the previous equal window | | `points` | A series, as `{ t, v }` | | `items` | Rows, for a list metric | | `currency` | An ISO 4217 code, for a currency metric | | `note` | One line the tile shows under the figure | ## verify Optional. An app against a public API has nothing to prove. Where there is a credential, `verify` runs before the app is usable. ```js export default createApp({ // the app's own fields, then: async verify(ctx) { const response = await ctx.http("https://api.example.com/v1/account", { headers: { authorization: `Bearer ${ctx.credentials.api_key}` }, }); if (!response.ok) throw new Error(`The API key was rejected (${response.status}).`); const account = await response.json(); return { accountLabel: account.name, currency: account.currency }; }, metrics: [downloads], }); ``` Throw to reject. A key that does not work must not become an app that quietly shows nothing. Return `unavailableMetrics` to name the numbers this credential cannot reach. A read only token that sees usage but not billing says so once, and those widgets explain themselves. ## Errors Throw with a sentence somebody can act on. The message reaches the tile, so `The API returned 429.` is useful and `undefined is not an object` is not. One metric that throws does not affect the others. A run that passes `timeoutMs` is killed, and every metric in that run shares the budget. ## The older shape A connector that exports `verify` and `fetchMetric` as named functions still runs. That shape declared its metrics in a hand-written manifest and dispatched on `request.metric` inside one function. Nothing forces a rewrite. New connectors should use `createApp`, because an id spelled two ways is a class of bug the newer shape cannot have. --- # Manifest JSON Schema Generated from `packages/core`. A manifest that does not validate against this is refused when the app is loaded. ```json { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "schemaVersion": { "type": "number", "const": 1 }, "id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]{1,47}$" }, "name": { "type": "string", "minLength": 1, "maxLength": 60 }, "description": { "type": "string", "maxLength": 400 }, "appId": { "default": "custom", "type": "string", "minLength": 1, "maxLength": 40, "pattern": "^[a-z0-9][a-z0-9-]*$" }, "author": { "type": "string" }, "version": { "default": "1.0.0", "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" }, "icon": { "oneOf": [ { "type": "object", "properties": { "type": { "type": "string", "const": "symbol" }, "name": { "type": "string", "minLength": 1, "maxLength": 64, "pattern": "^[a-z0-9]+(\\.[a-z0-9]+)*$" }, "tint": { "type": "string", "pattern": "^#?[0-9a-fA-F]{6}$" } }, "required": [ "type", "name" ], "additionalProperties": false }, { "type": "object", "properties": { "type": { "type": "string", "const": "emoji" }, "value": { "type": "string", "minLength": 1, "maxLength": 8 } }, "required": [ "type", "value" ], "additionalProperties": false }, { "type": "object", "properties": { "type": { "type": "string", "const": "app" }, "appId": { "type": "string", "minLength": 1, "maxLength": 40, "pattern": "^[a-z0-9][a-z0-9-]*$" } }, "required": [ "type", "appId" ], "additionalProperties": false }, { "type": "object", "properties": { "type": { "type": "string", "const": "file" }, "path": { "type": "string", "pattern": "^[a-z0-9][a-z0-9._-]{0,39}\\.(png|svg)$" } }, "required": [ "type", "path" ], "additionalProperties": false } ] }, "authMode": { "type": "string", "enum": [ "none", "api_key" ] }, "hostCredentials": { "default": [], "type": "array", "items": { "type": "string", "enum": [ "claude_code_oauth" ] } }, "auth": { "default": [], "type": "array", "items": { "type": "object", "properties": { "key": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, "label": { "type": "string" }, "type": { "type": "string", "enum": [ "string", "secret", "select", "url", "boolean" ] }, "required": { "default": true, "type": "boolean" }, "placeholder": { "type": "string" }, "help": { "type": "string" }, "pattern": { "type": "string" }, "options": { "type": "array", "items": { "type": "object", "properties": { "value": { "type": "string" }, "label": { "type": "string" } }, "required": [ "value", "label" ], "additionalProperties": false } }, "default": { "type": "string" } }, "required": [ "key", "label", "type", "required" ], "additionalProperties": false } }, "allowedHosts": { "minItems": 1, "type": "array", "items": { "type": "string", "pattern": "^(\\*\\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$" } }, "metrics": { "minItems": 1, "type": "array", "items": { "type": "object", "properties": { "id": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, "label": { "type": "string", "minLength": 1, "maxLength": 60 }, "shortLabel": { "type": "string", "maxLength": 12 }, "unit": { "type": "string", "enum": [ "currency", "count", "ratio", "duration", "bytes" ] }, "currency": { "type": "string", "minLength": 3, "maxLength": 3 }, "format": { "type": "object", "properties": { "decimals": { "default": 0, "type": "integer", "minimum": 0, "maximum": 4 }, "compact": { "default": true, "type": "boolean" }, "prefix": { "default": "", "type": "string", "maxLength": 3 }, "suffix": { "default": "", "type": "string", "maxLength": 4 } }, "additionalProperties": false }, "shape": { "oneOf": [ { "type": "object", "properties": { "kind": { "type": "string", "const": "scalar" } }, "required": [ "kind" ], "additionalProperties": false }, { "type": "object", "properties": { "kind": { "type": "string", "const": "series" }, "granularities": { "minItems": 1, "type": "array", "items": { "type": "string", "enum": [ "hour", "day", "week", "month" ] } }, "compare": { "default": true, "type": "boolean" } }, "required": [ "kind", "granularities", "compare" ], "additionalProperties": false }, { "type": "object", "properties": { "kind": { "type": "string", "const": "total" }, "granularities": { "minItems": 1, "type": "array", "items": { "type": "string", "enum": [ "hour", "day", "week", "month" ] } }, "compare": { "default": true, "type": "boolean" } }, "required": [ "kind", "granularities", "compare" ], "additionalProperties": false }, { "type": "object", "properties": { "kind": { "type": "string", "const": "list" }, "limit": { "default": 10, "type": "integer", "minimum": 1, "maximum": 50 } }, "required": [ "kind", "limit" ], "additionalProperties": false } ] }, "methodology": { "type": "string", "minLength": 1 }, "params": { "type": "array", "items": { "type": "object", "properties": { "key": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" }, "label": { "type": "string" }, "type": { "type": "string", "enum": [ "string", "secret", "select", "url", "boolean" ] }, "required": { "default": true, "type": "boolean" }, "placeholder": { "type": "string" }, "help": { "type": "string" }, "pattern": { "type": "string" }, "options": { "type": "array", "items": { "type": "object", "properties": { "value": { "type": "string" }, "label": { "type": "string" } }, "required": [ "value", "label" ], "additionalProperties": false } }, "default": { "type": "string" } }, "required": [ "key", "label", "type", "required" ], "additionalProperties": false } }, "freshnessSeconds": { "type": "integer", "minimum": 60, "maximum": 86400 }, "linkTemplate": { "type": "string", "pattern": "^https:\\/\\/" } }, "required": [ "id", "label", "shortLabel", "unit", "shape", "methodology" ], "additionalProperties": false } }, "refreshIntervalSeconds": { "default": 300, "type": "integer", "minimum": 60, "maximum": 86400 }, "timeoutMs": { "default": 8000, "type": "integer", "minimum": 1000, "maximum": 20000 }, "entry": { "type": "string", "const": "connector.mjs" }, "createdAt": { "type": "string" }, "updatedAt": { "type": "string" } }, "required": [ "schemaVersion", "id", "name", "description", "appId", "version", "hostCredentials", "auth", "allowedHosts", "metrics", "refreshIntervalSeconds", "timeoutMs", "entry", "createdAt", "updatedAt" ], "additionalProperties": false } ``` --- # Authoring API `createApp` and `createMetric`, with the types an editor checks against. This is the source of the package an author imports. ```ts import { type AuthField, type Granularity, LocalConnectorManifest, LocalMetricDescriptor, type MetricFormat, } from "./connector.ts"; import type { ConnectorContext, MetricRequest } from "./runtime.ts"; import { assertValid } from "./validate.ts"; /** * The authoring API: a metric is its description *and* the code that answers * it, in one object. * * What this replaces was two halves of one idea held apart. Every metric was * declared in `manifest.json` and answered inside a single `fetchMetric` * that opened with a chain of `if (request.metric === "...")`. Adding a * metric meant editing JSON, editing a branch, and keeping an id spelled the * same in both. Deleting one usually left the branch behind. * * ```js * export const downloads = createMetric({ * id: "downloads", * label: "Downloads", * unit: "count", * series: { granularities: ["day"] }, * methodology: "Downloads reported by the registry for the window.", * async fetch(ctx, request) { * const response = await ctx.http(url(request)); * return { value: (await response.json()).total }; * }, * }); * ``` * * ## What stays in the manifest, and why * * Not everything moved. `allowedHosts`, `auth` and `hostCredentials` are * **grants**: the hosts an app may reach, the secrets it asks you for, the * sign in it borrows. The app shows you those before it runs a line of the * connector's code, and enabling the app is what approves them. Reading them * out of the module would mean executing the module to find out what it * wants, which is the wrong order for a consent decision. * * Metric descriptors carry no such weight. They are labels, units and * shapes, so they belong next to the function that produces them. */ /** The window a metric is answered for, plus whatever the widget supplied. */ export type MetricFetch = (ctx: ConnectorContext, request: MetricRequest) => Promise; /** What a metric's `fetch` returns. The shape the tile is drawn from. */ export interface MetricAnswer { value: number; /** The previous equal-length window, for the delta. */ previousValue?: number | null; /** One point per bucket, for a metric that draws a line or bars. */ points?: { t: string; v: number }[]; /** Rows, for a metric drawn as a split bar, ranked bars or a ring. */ items?: { id: string; t: string; label: string; detail?: string | null; amount?: number | null; currency?: string | null; status?: string | null; state?: "succeeded" | "failed" | "running" | "neutral"; }[]; currency?: string | null; /** One line the tile shows under the figure. */ note?: string; asOf?: string; } /** * The four kinds of answer, as four optional keys. * * Spelled this way instead of `shape: { kind: "series", ... }` because the * tagged union is a wire format and this is a thing a person types. One of * these is set; `createMetric` turns whichever it is into the `shape` the * manifest and the app already speak. */ interface Kinds { /** One number for the present. No window, so no range and no comparison. */ scalar?: true; /** A number per bucket over the window. */ series?: { granularities: Granularity[]; compare?: boolean }; /** One number *for* the window, with a delta and no breakdown. */ total?: { granularities: Granularity[]; compare?: boolean }; /** Rows of categories. The same rows whatever the range says. */ list?: { limit?: number } | true; } export interface MetricDefinition extends Kinds { id: string; label: string; /** * What fits in a notch tile. Twelve characters. * * Optional here and not in the wire descriptor: most labels are already * short enough to use as they are, and making every author write the same * word twice is how `shortLabel` ends up as a truncation of `label`, which * is the one thing it must not be. */ shortLabel?: string; unit: LocalMetricDescriptor["unit"]; currency?: string; format?: Partial; /** How this number is derived, in one sentence. Required. */ methodology: string; /** Fields the *widget* supplies, rendered by the configure pane. */ params?: AuthField[]; freshnessSeconds?: number; linkTemplate?: string; /** Answers this metric for one window. */ fetch: MetricFetch; } /** A metric, ready to be handed to `defineConnector`. */ export interface Metric { descriptor: LocalMetricDescriptor; fetch: MetricFetch; } function shapeOf(definition: Kinds, id: string): LocalMetricDescriptor["shape"] { if (definition.series) { return { kind: "series", granularities: definition.series.granularities as [Granularity, ...Granularity[]], compare: definition.series.compare ?? true, }; } if (definition.total) { return { kind: "total", granularities: definition.total.granularities as [Granularity, ...Granularity[]], compare: definition.total.compare ?? true, }; } if (definition.list) { return { kind: "list", limit: definition.list === true ? 10 : (definition.list.limit ?? 10) }; } if (definition.scalar) return { kind: "scalar" }; // Thrown at module load, so an author sees it the first time they run the // connector rather than as a tile that never fills. throw new Error(`Metric "${id}" declares no kind. Set one of scalar, series, total or list.`); } export function createMetric(definition: MetricDefinition): Metric { if (typeof definition.fetch !== "function") { throw new Error(`Metric "${definition.id}" has no fetch function.`); } // Validated here, at the smallest thing that can be wrong, so the message // names this metric. Left to `createApp`, the same mistake surfaced as // `metrics.3.params.0.type`, which is a position in an array rather than // something an author can look up in their own file. const descriptor = assertValid( LocalMetricDescriptor, { id: definition.id, label: definition.label, // Truncating would produce exactly the label-with-the-end-cut-off that // `shortLabel` exists to avoid, so a long label is the author's to fix // and the validator's to refuse. shortLabel: definition.shortLabel ?? definition.label, unit: definition.unit, ...(definition.currency ? { currency: definition.currency } : {}), ...(definition.format ? { format: definition.format } : {}), shape: shapeOf(definition, definition.id), methodology: definition.methodology, ...(definition.params ? { params: definition.params } : {}), ...(definition.freshnessSeconds ? { freshnessSeconds: definition.freshnessSeconds } : {}), ...(definition.linkTemplate ? { linkTemplate: definition.linkTemplate } : {}), }, `Metric "${definition.id}"`, ); return { descriptor, fetch: definition.fetch }; } export interface AppDefinition { id: string; name: string; description: string; /** Shown in the Custom Apps list. Yours to display, never ours to compare. */ version?: string; author?: string; appId?: string; icon?: LocalConnectorManifest["icon"]; /** * Every host this app may contact. Nothing else is reachable. * * A **grant**, which is why it is here and not on a metric. The app shows * you this list before it runs a line of the connector's code, and * enabling the app is what approves it. */ allowedHosts: string[]; /** Fields the app asks you for. Omit for an app that needs nothing. */ auth?: AuthField[]; /** A sign in already on the Mac, which the app borrows. */ hostCredentials?: LocalConnectorManifest["hostCredentials"]; refreshIntervalSeconds?: number; timeoutMs?: number; /** * Proves the credential works and names the connection. * * Optional: an app against a public API has nothing to verify, and making * every one of those write an empty function was noise. */ verify?: (ctx: ConnectorContext) => Promise<{ accountLabel?: string | null; currency?: string | null; unavailableMetrics?: string[]; }>; metrics: Metric[]; } /** What a connector module default-exports. */ export interface App { /** * The manifest, derived from everything above. * * Emitted to `manifest.json` by `notch-metrics build`, which runs on the * author's machine. The file still exists on disk at install time, so the * Mac app reads an app's grants without executing any of its code. That * order matters: reading them out of the module would mean running the * module to find out what it wants, which is the wrong way round for a * consent decision. */ manifest: LocalConnectorManifest; verify?: AppDefinition["verify"]; /** Keyed by id, which is how the runner routes one request. */ metrics: Record; describe(): LocalMetricDescriptor[]; } export function createApp(definition: AppDefinition): App { const metrics: Record = {}; for (const metric of definition.metrics) { const { id } = metric.descriptor; // Two metrics under one id means one of them silently never runs, and // which one depends on array order. Caught at load, named. if (metrics[id]) throw new Error(`Two metrics share the id "${id}".`); metrics[id] = metric; } const descriptors = Object.values(metrics).map((metric) => metric.descriptor); if (descriptors.length === 0) throw new Error("An app needs at least one metric."); const auth = definition.auth ?? []; const manifest = assertValid( LocalConnectorManifest, { schemaVersion: 1, id: definition.id, name: definition.name, description: definition.description, version: definition.version ?? "1.0.0", ...(definition.author ? { author: definition.author } : {}), ...(definition.appId ? { appId: definition.appId } : {}), ...(definition.icon ? { icon: definition.icon } : {}), // Derived, never asked for. Declaring `none` while listing fields is a // contradiction the author should not be able to write. authMode: auth.length > 0 ? "api_key" : "none", auth, hostCredentials: definition.hostCredentials ?? [], allowedHosts: definition.allowedHosts, metrics: descriptors, refreshIntervalSeconds: definition.refreshIntervalSeconds ?? 300, timeoutMs: definition.timeoutMs ?? 8_000, entry: "connector.mjs", createdAt: new Date(0).toISOString(), updatedAt: new Date(0).toISOString(), }, `App "${definition.id}"`, ); return { manifest, ...(definition.verify ? { verify: definition.verify } : {}), metrics, describe: () => descriptors, }; } ``` --- # Runtime contract The object the runner hands to `verify` and to each metric's `fetch`. Nothing here is imported at run time, so a connector runs with no `node_modules` beside it. ```ts import type { Granularity } from "./connector.ts"; /** * What the runner hands a connector. * * Declared here so an author writing TypeScript gets the shape in their * editor. Nothing is imported at run time — the runner supplies this object — * so a connector can be plain JavaScript with no dependency on this package * at all. The types are a convenience, not a requirement. */ export interface ConnectorContext { /** * Only the keys the manifest's `auth` declares. * * Filtered and frozen before the connector's code runs, so a credential it * never asked for is not readable by it. */ credentials: Readonly>; /** The window this request is for. */ timeRange: { from: string; to: string; granularity: string; timeZone: string }; /** Aborted when the run exceeds the manifest's `timeoutMs`. */ signal: AbortSignal; /** Lands in the run's logs, not on the tile. */ log(message: string, fields?: Record): void; /** * The **only** network access. A bare `fetch` bypasses every guarantee here. * * Enforces the manifest's `allowedHosts`, requires https, refuses private * and loopback addresses, follows at most three redirects re-checking the * host on each hop, and caps a response at 4 MB. * * That redirect handling is the part worth knowing about: `fetch` re-sends * request headers across a same-protocol redirect, so a server that 302s to * a host outside the allowlist would otherwise be handed the credential by * the runtime, with none of the connector's code involved. */ http( url: string, init?: { method?: string; headers?: Record; body?: string }, ): Promise<{ ok: boolean; status: number; headers: Record; json(): Promise; text(): Promise; }>; } /** What one metric is being asked for. */ export interface MetricRequest { /** * Who asked. Opaque to the connector, echoed back on the outcome. * * One widget per request. Two widgets can ask for the **same metric with * different params** — one npm package per tile — and nothing else in a * request or an outcome tells them apart: `metric` is identical for both. * Without this the caller has to match answers by position, which is a * silent wrong number the day anything reorders. */ id: string; /** The `id` of one of the manifest's declared metrics. */ metric: string; /** Whatever the widget supplied for this metric's declared `params`. */ params: Record; range: string; granularity: Granularity | string; from: string; to: string; /** Whether the previous equal-length window is wanted for the delta. */ comparePrevious: boolean; } /** * The two functions a connector module exports. * * Both may be written as `export async function`, or exported from a bundle * as `export { verify, fetchMetric }` — a built project is expected and the * registration lint accepts either. */ export interface ConnectorModule { /** * Proves the credential works and names the connection. * * Runs before the connector is registered. Throw to reject: a key that does * not work must not become a connector that silently shows nothing. */ verify(ctx: ConnectorContext): Promise<{ accountLabel?: string | null; currency?: string | null; /** Metrics *this* credential cannot reach. Said as a negative. */ unavailableMetrics?: string[]; }>; /** * Answers one metric. Called once per requested metric, in one process — * do not assume a fresh process per metric. */ fetchMetric( ctx: ConnectorContext, request: MetricRequest, ): Promise<{ value: number; previousValue?: number | null; points?: { t: string; v: number }[]; items?: { id: string; t: string; label: string; detail?: string | null; amount?: number | null; currency?: string | null; status?: string | null; state?: "succeeded" | "failed" | "running" | "neutral"; }[]; currency?: string | null; note?: string; asOf?: string; }>; } ``` --- # Example manifest The fixture the Mac app's own contract tests decode. If a generated manifest differs structurally from this one, the generated one is wrong. ```json { "schemaVersion": 1, "id": "linear", "name": "Linear", "description": "Issues and cycle progress from a Linear workspace.", "appId": "custom", "author": "claude-code", "version": "1.2.0", "icon": { "type": "symbol", "name": "chart.bar.doc.horizontal", "tint": "#5E6AD2" }, "hostCredentials": [], "auth": [ { "key": "api_key", "label": "API key", "type": "secret", "required": true, "help": "A personal API key from Linear settings." } ], "allowedHosts": [ "api.linear.app" ], "metrics": [ { "id": "open_issues", "label": "Open issues", "shortLabel": "Open", "unit": "count", "shape": { "kind": "scalar" }, "methodology": "Issues in the team's backlog and started states." } ], "refreshIntervalSeconds": 900, "timeoutMs": 8000, "entry": "connector.mjs", "createdAt": "2026-10-05T12:00:00.000Z", "updatedAt": "2026-10-05T12:00:00.000Z" } ``` --- # Example connector.mjs ```js // connector.mjs import { createApp, createMetric } from "@notchmetrics/core"; // Everything about a metric in one object: what it is called, what it means, // what the widget asks for, and the code that answers it. const activeUsers = createMetric({ id: "active_users", label: "Active users", unit: "count", series: { granularities: ["day"] }, methodology: "Users with at least one session in the window.", async fetch(ctx, request) { const response = await ctx.http( `https://api.example.com/v1/users?from=${request.from}&to=${request.to}`, { headers: { authorization: `Bearer ${ctx.credentials.api_key}` } }, ); if (!response.ok) throw new Error(`The API returned ${response.status}.`); const body = await response.json(); return { value: body.total, previousValue: request.comparePrevious ? body.previousTotal : null, points: body.daily.map((day) => ({ t: day.date, v: day.count })), }; }, }); export default createApp({ id: "example-app", name: "Example", description: "What this app reads, in a sentence.", allowedHosts: ["api.example.com"], auth: [{ key: "api_key", label: "API key", type: "secret" }], // Optional. An app against a public API has nothing to prove. async verify(ctx) { const response = await ctx.http("https://api.example.com/v1/account", { headers: { authorization: `Bearer ${ctx.credentials.api_key}` }, }); if (!response.ok) throw new Error(`The API key was rejected (${response.status}).`); const account = await response.json(); return { accountLabel: account.name, currency: account.currency }; }, metrics: [activeUsers], }); ```