diff --git a/.claude/skills/build-plugin/SKILL.md b/.claude/skills/build-plugin/SKILL.md
new file mode 100644
index 0000000..94e6e47
--- /dev/null
+++ b/.claude/skills/build-plugin/SKILL.md
@@ -0,0 +1,206 @@
+---
+name: build-plugin
+description: Guides building a SquaredUp low-code plugin for HTTP/REST APIs, from API exploration through deployment. Use when the user wants to integrate a service with SquaredUp, add a new data source, connect to a third-party tool, "pull data from", or "monitor" any service in SquaredUp.
+metadata:
+ author: SquaredUp
+ version: "0.0.3"
+---
+
+# Building a SquaredUp Low-Code Plugin
+
+> **Scope:** Web API-based plugins only. If the target tool has no usable REST API, PowerShell may be a better fit — suggest it and stop.
+
+**Announce at start:** "I'm using the build-plugin skill."
+
+---
+
+## Required user inputs
+
+| Input | When to ask | Why |
+| ------------------------------------------------- | ---------------------------------------- | -------------------------------------------------------------------------------------- |
+| **Author handle** (GitHub handle or display name) | Before writing `metadata.json` (Phase 4) | Goes into `author.name`. Guessing from git config frequently picks the wrong identity. |
+
+If the user has already volunteered the answer earlier in the conversation or you're updating a plugin, use that and skip the prompt. Otherwise, ask — even in autonomous mode.
+
+---
+
+## When to Use
+
+- Building a new plugin for an HTTP/REST API
+- Adding data streams or dashboards to an existing plugin
+- Any request to integrate a service, "pull data from", or "monitor" a service in SquaredUp
+- Adding a new data source or integration to a SquaredUp workspace
+
+---
+
+## Checklist
+
+Create a TaskCreate task for each phase:
+
+- [ ] **Phase 1** — Explore the API
+- [ ] **Phase 2** — Plan the plugin structure
+- [ ] **Phase 3** — Scaffold files (icon, file structure, `docs/README.md`)
+- [ ] **Phase 4** — Write `metadata.json` and `ui.json` → read [metadata.md](references/metadata.md) and [ui.md](references/ui.md)
+- [ ] **Phase 5** — Write import definitions → read [index-defs.md](references/index-defs.md)
+- [ ] **Phase 6** — Write data streams → read [data-streams.md](references/data-streams.md)
+- [ ] **Phase 7** — Write OOB default content → read [oob-content.md](references/oob-content.md)
+- [ ] **Phase 8** — Write `custom_types.json` → read [common-patterns.md](references/common-patterns.md)
+- [ ] **Phase 9** — Validate and deploy → invoke the `deploy-plugin` skill
+
+---
+
+## Phase 1: Explore the API
+
+Before writing a single file, understand the API. **Use `AskUserQuestion` to ask for API documentation URLs, OpenAPI/Swagger specs, Postman collections, or any other reference material.** You can also search online, but verify you're looking at docs for the exact product/version the user wants.
+
+1. **Find the docs** — Gather URLs or spec files from the user, then fetch and read them.
+2. **Identify the object model** — What are the core entities? (e.g. installations, devices, sites). These become the **indexed objects** in SquaredUp — available for drilldown, search, scoping dashboards, and use as variables.
+3. **Find the list endpoints** — Used to import objects. Prefer fetching **50–250 records per page** across multiple requests — SquaredUp has a per-page timeout but supports as many paged requests as needed.
+4. **Find the data endpoints** — These power data streams. Identify whether each is scoped to a single object, multiple objects, or global (no object context).
+5. **Understand pagination** — Cursor/next-token, or offset/limit? Separate concern from response transformation.
+6. **Note the auth pattern** — API key in header, Bearer token, OAuth2, Basic auth? Determine from the docs.
+
+---
+
+## Phase 2: Plan the Plugin Structure
+
+This phase produces a written plan and a user-approval gate before any files are written. Object types, import shape, and sourceId format are expensive to change once Phase 3+ commits them to JSON — Phase 2 is where scope errors are cheap to fix.
+
+### The plan must cover
+
+1. **Object types** — Every type that should appear in the SquaredUp graph. These go in `objectTypes` in `metadata.json` and as `sourceType` throughout.
+2. **Import steps** — Let the API shape dictate: one step returning many types, or separate steps per type.
+3. **Data streams** — For each object type, plan:
+ - A **summary/current state** stream (`"timeframes": false`, returns current values)
+ - A **history/metrics** stream (supports timeframes, returns time-series rows)
+ - Any **cross-object** streams scoped to a parent (e.g. alarms for an installation)
+ - **Prefer configurable streams** over hardcoded ones — use a UI parameter rather than multiple streams for the same endpoint with different values.
+4. **What's intentionally omitted** — API capabilities not being implemented, and why. Highest-value section for catching scope creep.
+5. **Authentication** — Auth mechanism and any UX concerns (token expiry, rate limits, hard-to-obtain credentials).
+6. **OOB dashboards** — A **top-level summary dashboard** plus **one perspective per object type** scoped via a dashboard variable.
+7. **sourceId format** — Use the raw API ID wherever possible.
+
+### Plan format
+
+Post the plan as markdown with one `###` heading per item above. Short example:
+
+```markdown
+## Plan
+
+### Object types
+- `My Installation` — sites being monitored
+- `My Device` — physical devices reporting telemetry
+
+### Import steps
+- `installations` — one step, returns both types
+
+### Data streams
+- `batterySummary` — per-device, current state
+- `batteryHistory` — per-device, time-series
+- `siteAlarms` — per-installation
+
+### What's intentionally omitted
+- Webhook ingestion (no v1 use case)
+
+### Authentication
+- API key in `X-API-Key` header
+
+### OOB dashboards
+- Overview, Installation perspective, Device perspective
+
+### sourceId format
+- Installation: raw API `id`
+- Device: composite `{installationId}-{deviceId}` (API has no global device ID)
+```
+
+### Approval gate
+
+**When to fire:** when `metadata.json` doesn't exist yet in the plugin folder, OR when the planned `objectTypes` differs from the current `metadata.json`. Otherwise skip — incremental work that doesn't introduce new entities doesn't need the gate.
+
+**How:** post the plan, then call `AskUserQuestion` **in the same turn** with three options:
+
+- `Approve as written` → proceed to Phase 3
+- `Trim scope — start with less` → user wants a smaller MVP; ask what to cut
+- `Adjust — different objects/streams/auth` → user wants changes; ask what specifically
+
+If the user picks anything other than approve (including "Other"), revise the plan and re-fire the gate with the updated plan. Loop until approval — a revised plan can introduce new wrong assumptions, so the second pass is doing real work, not theatre. If the user explicitly waives further gating ("just proceed", "looks fine, go", "stop asking"), honor that for the rest of this conversation.
+
+---
+
+## Phase 3: Scaffold Files
+
+**Icon:** Find the official brand/product logo (SVG or PNG). Never auto-generate a generic icon — ask the user to supply one if you can't find an official logo.
+
+**Post-process SVG icons if needed.** SquaredUp shows icons on dark/white backgrounds. Fix if the SVG lacks a background or is not square:
+
+1. Set `width="512" height="512" viewBox="0 0 512 512"`
+2. Insert `` as the first child
+3. Wrap paths in `` for ~10% padding: `S = min(409.6/w, 409.6/h)`, `X = (512−w*S)/2`, `Y = (512−h*S)/2`
+
+**File structure:**
+
+```
+my-plugin/
+ v1/
+ metadata.json
+ ui.json
+ icon.svg
+ custom_types.json
+ configValidation.json # preferred: validates config on setup
+ docs/
+ README.md # REQUIRED: shown in-product when users add the plugin
+ indexDefinitions/
+ default.json
+ dataStreams/
+ myStream.json
+ scripts/
+ myScript.js
+ defaultContent/
+ manifest.json
+ scopes.json
+ overviewDashboard.dash.json
+ deviceDashboard.dash.json # single perspective — no sub-folder needed
+ Installations/ # sub-folder only for multiple dashboards of the same type
+ manifest.json
+ dashboard1.dash.json
+```
+
+**docs/README.md (required)** — surfaced in-product when a user adds the plugin. Always create as part of scaffolding; the `documentation` link in `metadata.json` must point to it (e.g. `https://github.com/squaredup/plugins/blob/main/plugins/MyPlugin/v1/docs/README.md`).
+
+The README must cover:
+
+1. What the plugin monitors — objects imported, what dashboards show
+2. Prerequisites / getting credentials — step-by-step, include required scopes/permissions
+3. Configuration fields — table explaining every `ui.json` field: what it is, where to find the value, whether required
+4. What gets indexed — list object types and what they represent
+5. Known limitations — rate limits, permission requirements, API quirks
+
+Write as if the user has never seen the API. They're reading it inside SquaredUp, not on the vendor's site.
+
+**Other rules:**
+
+- `scopes.json`: only include scopes used by OOB dashboards. Don't add speculatively.
+- `configValidation.json`: optional but strongly preferred — see [common-patterns.md](references/common-patterns.md).
+- **Single-dashboard rule:** Only create a sub-folder under `defaultContent/` when you have **multiple dashboards** for the same type.
+
+---
+
+## Phases 4–8: Writing Files
+
+Read the corresponding reference file before writing each phase:
+
+| Phase | Files | Reference |
+| -------------------------- | --------------------------------------------------- | ---------------------------------------------------------------- |
+| 4 — Plugin identity & auth | `metadata.json`, `ui.json`, `configValidation.json` | [metadata.md](references/metadata.md), [ui.md](references/ui.md) |
+| 5 — Import definitions | `indexDefinitions/default.json` | [index-defs.md](references/index-defs.md) |
+| 6 — Data streams | `dataStreams/*.json`, `scripts/*.js` | [data-streams.md](references/data-streams.md) |
+| 7 — OOB default content | `defaultContent/`, `scopes.json` | [oob-content.md](references/oob-content.md) |
+| 8 — Custom types | `custom_types.json` | [common-patterns.md](references/common-patterns.md) |
+
+For reusable patterns (built-in properties stream, configValidation steps), read [common-patterns.md](references/common-patterns.md).
+
+---
+
+## Phase 9: Validate & Deploy
+
+Invoke the `deploy-plugin` skill.
diff --git a/.claude/skills/build-plugin/references/common-patterns.md b/.claude/skills/build-plugin/references/common-patterns.md
new file mode 100644
index 0000000..6377e43
--- /dev/null
+++ b/.claude/skills/build-plugin/references/common-patterns.md
@@ -0,0 +1,94 @@
+# Common Patterns and Custom Types
+
+> **Note:** `$schema` is not a valid property in any SquaredUp plugin JSON file.
+
+## Contents
+
+- [custom_types.json](#custom_typesjson)
+- [Built-in properties stream](#built-in-properties-stream)
+- [Config validation steps](#config-validation-steps)
+
+---
+
+## custom_types.json
+
+Adds friendly display names and FontAwesome icons per object type. The `sourceType` value must exactly match the type used in `objectMapping.type` in `indexDefinitions/default.json`.
+
+```json
+[
+ {
+ "name": "My Installation",
+ "sourceType": "My Installation",
+ "icon": "house",
+ "singular": "Installation",
+ "plural": "Installations"
+ },
+ {
+ "name": "My Device",
+ "sourceType": "My Device",
+ "icon": "microchip",
+ "singular": "Device",
+ "plural": "Devices"
+ }
+]
+```
+
+Use **FontAwesome** icon names (`fontawesome.com/icons`), lowercase kebab-case. Common icons: `house`, `bolt`, `sun`, `battery-full`, `plug`, `thermometer`, `factory`, `gear`, `globe`, `wind`, `microchip`, `rotate`, `car`, `droplet`, `atom`, `gas-pump`, `wifi`, `camera`, `display`, `building`, `key`.
+
+---
+
+## Built-in properties stream
+
+SquaredUp includes a built-in `datastream-properties` stream that automatically shows the indexed properties of any object. Use in OOB dashboards for a "Properties" or "Details" tile — no custom stream needed:
+
+```json
+"dataStream": {
+ "id": "datastream-properties"
+}
+```
+
+---
+
+## Config validation steps
+
+`configValidation.json` is optional but strongly preferred. Use a **lightweight endpoint** (e.g. `/me`, `/user`). No extra flag needed in `metadata.json` — the presence of the file is sufficient.
+
+```json
+{
+ "steps": [
+ {
+ "displayName": "Authenticate",
+ "dataStream": { "name": "currentUser" },
+ "required": true,
+ "error": "Could not authenticate. Check your API key has the required scopes.",
+ "success": "Connected successfully."
+ },
+ {
+ "displayName": "Check data access",
+ "dataStream": { "name": "installations" },
+ "required": false,
+ "error": "Authenticated but no installations found.",
+ "success": "Installations accessible."
+ }
+ ]
+}
+```
+
+`required: true` — a failing step blocks the user from completing setup. Write error messages that name what to check, not just that something failed.
+
+Steps can override stream parameters for validation-specific queries:
+
+```json
+{
+ "displayName": "Check warehouse access",
+ "dataStream": {
+ "name": "sqlQuery",
+ "config": { "query": "select 1", "errorOnEmptyResults": true }
+ },
+ "required": true,
+ "error": "No warehouse access.",
+ "success": "Warehouse accessible."
+}
+```
+
+`errorOnEmptyResults: true` causes the step to fail if the stream returns no rows — useful when empty means access was denied.
diff --git a/.claude/skills/build-plugin/references/data-streams.md b/.claude/skills/build-plugin/references/data-streams.md
new file mode 100644
index 0000000..12f12e0
--- /dev/null
+++ b/.claude/skills/build-plugin/references/data-streams.md
@@ -0,0 +1,637 @@
+# Data Streams Reference
+
+## Contents
+
+- [baseDataSourceName — request modes](#basedatasourcename)
+- [Stream-level properties](#stream-level-properties)
+- [Visibility](#visibility)
+- [matches — object selection](#matches)
+- [Expressions in config](#expressions)
+- [Column expressions (valueExpression, formatExpression)](#column-expressions)
+- [POST requests](#post-requests)
+- [expandInnerObjects](#expandinnerobjects)
+- [manualConfigApply](#manualconfigapply)
+- [Pagination (paging)](#pagination)
+- [errorHandling](#errorhandling)
+- [pathToData](#pathtodata)
+- [timeframes](#timeframes)
+- [defaultShaping](#defaultshaping)
+- [metadata — column definitions](#metadata-column-definitions)
+- [Post-request scripts](#post-request-scripts) — [Wiring a script](#wiring-a-script-to-a-stream), [When to use](#default-no-script), [Globals](#available-globals)
+
+---
+
+## baseDataSourceName
+
+**`httpRequestScopedSingle`** — one API request per selected object; results combined. Use when the API only accepts one object at a time.
+
+```json
+{
+ "name": "batterySummary",
+ "displayName": "Battery Summary",
+ "description": "Current battery state",
+ "tags": ["Energy", "Battery"],
+ "baseDataSourceName": "httpRequestScopedSingle",
+ "config": {
+ "httpMethod": "get",
+ "endpointPath": "installations/{{object.siteId}}/widgets/BatterySummary",
+ "getArgs": [{ "key": "instance", "value": "{{object.instance}}" }]
+ },
+ "matches": { "sourceType": { "type": "oneOf", "values": ["My Battery"] } },
+ "metadata": [...],
+ "timeframes": false
+}
+```
+
+**`httpRequestScoped`** — one API request regardless of how many objects are selected. All selected objects available via `{{objects}}`. Use when the API accepts multiple objects in a single call.
+
+```json
+{
+ "baseDataSourceName": "httpRequestScoped",
+ "config": {
+ "httpMethod": "get",
+ "endpointPath": "devices/status",
+ "getArgs": [
+ {
+ "key": "ids",
+ "value": "{{objects.map(o => o.deviceId).join(',')}}"
+ }
+ ]
+ },
+ "matches": { "sourceType": { "type": "oneOf", "values": ["My Device"] } }
+}
+```
+
+**`httpRequestUnscoped`** — no object selection. Single request with no object context. Use for global/account-level endpoints. Pair with `"matches": "none"`.
+
+```json
+{
+ "baseDataSourceName": "httpRequestUnscoped",
+ "config": { "httpMethod": "get", "endpointPath": "alerts" },
+ "matches": "none"
+}
+```
+
+---
+
+## Stream-level properties
+
+- `name` — internal identifier; derived by camelCasing the display name (e.g. `"CPU Usage"` → `cpuUsage`). **Renaming is a breaking change.**
+- `displayName` — shown in the UI.
+- `description` — one sentence, no full stop at end.
+- `tags` — required; title case (e.g. `"Battery"`, `"Energy"`). Keep to a small, meaningful set.
+
+---
+
+## Visibility
+
+Hide a stream from the tile editor when any of these apply:
+
+- **Feeds a tile-editor dropdown only** — another stream references it via `dataInputs[].data.dataStreamName` (e.g. a stream that lists spreadsheets so the user can pick one). Not meant for dashboarding.
+- **Powers indexing only** — referenced by `indexDefinitions/*.json` and the rows are awkward as a tile (raw IDs, internal fields). Users see the indexed objects via the built-in `datastream-properties` stream instead.
+- **Used only by `configValidation.json`** — sole purpose is testing credentials or access during setup.
+
+If a stream serves a real dashboarding purpose _and_ one of the above, leave it visible — the dashboard use case wins.
+
+```json
+"visibility": { "type": "hidden" }
+```
+
+---
+
+## matches
+
+Controls whether SquaredUp asks the user to select objects. Target a **single object type** — do not match multiple types in one stream.
+
+```json
+// User picks objects of a specific type
+"matches": { "sourceType": { "type": "oneOf", "values": ["My Device"] } }
+
+// User picks any object from any plugin
+"matches": "all"
+
+// No object selection — global stream
+"matches": "none"
+```
+
+Available operators on any property: `oneOf`, `notOneOf`, `contains`, `notContains`, `equals`, `notEquals`, `regex`, `notRegex`, `any`.
+
+---
+
+## Expressions
+
+**Any string value anywhere under `config` can contain `{{ ... }}` expressions.** The server walks every string leaf of the config object and substitutes placeholders before the request is sent — there is no allowlist of fields. Common cases are `endpointPath`, `getArgs[].value`, `headers[].value`, and `postBody`, but the same syntax works in any string under `config` (e.g. a paging path, an `errorHandling.path`).
+
+Expressions support **inline JavaScript** inside `{{ }}`:
+
+```
+{{objects.map(o => o.siteId).join(',')}} // comma-separated list
+{{paramName.split('/')[0]}} // first segment of a slash-delimited param
+{{object.name.toLowerCase()}} // transform a property value
+```
+
+| Expression | Resolves to |
+| --------------------------------------------------- | ------------------------------------------------------ |
+| `{{dataSource.fieldName}}` | Plugin top-level config field (`ui.json`) |
+| `{{paramName}}` | Data stream's own `ui` config (parameterised streams) |
+| `{{object.propName}}` | Property on matched object (`httpRequestScopedSingle`) |
+| `{{objects}}` | Array of selected objects (`httpRequestScoped`) |
+| `{{variable1}}` | Selected object(s) from a dashboard variable |
+| `{{timeframe.start}}` / `{{timeframe.end}}` | ISO 8601 strings |
+| `{{timeframe.unixStart}}` / `{{timeframe.unixEnd}}` | Unix epoch seconds |
+| `{{timeframe.interval}}` | Suggested data resolution, e.g. `PT1M`, `PT1H` |
+| `{{timeframe.enum}}` | Timeframe name, e.g. `last24hours` |
+
+**Parameterised stream** (one configurable stream instead of many hardcoded ones):
+
+```json
+{
+ "name": "deviceMetric",
+ "displayName": "Device Metric",
+ "ui": [{ "name": "metric", "label": "Metric Name", "type": "text" }],
+ "config": {
+ "endpointPath": "devices/{{object.deviceId}}/metrics",
+ "getArgs": [
+ { "key": "metric", "value": "{{metric}}" },
+ { "key": "start", "value": "{{timeframe.unixStart}}" },
+ { "key": "end", "value": "{{timeframe.unixEnd}}" }
+ ]
+ }
+}
+```
+
+In OOB dashboard tiles, set the stream parameter in the tile's `dataStream` config.
+
+---
+
+## POST requests
+
+```json
+"config": {
+ "httpMethod": "post",
+ "endpointPath": "queries/_search",
+ "postBody": "{{query}}"
+}
+```
+
+`postBody` can be a template string or a JSON object with expressions:
+
+```json
+"postBody": {
+ "statement": "{{query}}",
+ "database": "{{typeof database !== 'undefined' ? database : undefined}}"
+}
+```
+
+---
+
+## expandInnerObjects
+
+```json
+"config": { "expandInnerObjects": true, ... }
+```
+
+Flattens nested objects into dot-notation columns (e.g. `{ "patchManagement": { "patchesInstalled": 5 } }` → `patchManagement.patchesInstalled`). Avoids needing a post-request script for simple nested structures.
+
+---
+
+## manualConfigApply
+
+```json
+"manualConfigApply": true
+```
+
+Shows an **Apply** button instead of running on every config change. Use for expensive or slow queries (e.g. database queries, large search requests).
+
+---
+
+## Pagination
+
+The `paging` block in `config` controls how SquaredUp fetches multiple pages.
+
+**No paging:**
+
+```json
+"paging": { "mode": "none" }
+```
+
+**Next-URL** — API returns a URL for the next page in the response body or a header:
+
+```json
+"paging": {
+ "mode": "nextUrl",
+ "pageSize": { "realm": "queryArg", "path": "max", "value": "100" },
+ "in": { "realm": "payload", "path": "pageDetails.nextPageUrl" }
+}
+```
+
+**Token** — API returns a cursor/token to send with the next request:
+
+```json
+"paging": {
+ "mode": "token",
+ "pageSize": { "realm": "queryArg", "path": "limit", "value": "100" },
+ "in": { "realm": "payload", "path": "meta.next_cursor" },
+ "out": { "realm": "queryArg", "path": "cursor" }
+}
+```
+
+**Offset** — increments a page number or row offset:
+
+```json
+"paging": {
+ "mode": "offset",
+ "pageSize": { "realm": "queryArg", "path": "limit", "value": "100" },
+ "offset": {
+ "mode": "page",
+ "rowCountIn": { "realm": "payloadArraySize", "path": "items" },
+ "base": 1
+ },
+ "out": { "realm": "queryArg", "path": "page" }
+}
+```
+
+`realm` options: `"queryArg"`, `"header"`, `"body"` (POST only), `"payload"`, `"payloadArraySize"`.
+`offset.mode`: `"page"` (increments 1,2,3…) or `"row"` (increments by page size).
+
+---
+
+## errorHandling
+
+```json
+// Extract error message from a response field
+"errorHandling": { "type": "path", "realm": "payload", "path": "error.message" }
+
+// Custom script — access response (.status, .body) and data
+"errorHandling": { "type": "script", "script": "result = response.status + ': ' + data.error;" }
+```
+
+---
+
+## pathToData
+
+Selects a path within the response body; each element of the resolved array becomes one row:
+
+```json
+"config": { "httpMethod": "get", "endpointPath": "devices", "pathToData": "data.items" }
+```
+
+If the response body is already a root-level array, omit `pathToData` entirely — the plugin iterates the root array directly. No script needed.
+
+Works on primitives too — a string, number, or boolean at the path is returned as a single row with a `result` column.
+
+> `rowPath` is a legacy alternative — use `pathToData` for new streams.
+
+---
+
+## timeframes
+
+```json
+"timeframes": false // current state only — no timeframe picker
+"timeframes": true // all timeframes available (default)
+"timeframes": ["last24hours", "last7days"] // limit to specific options
+```
+
+JSON-only timeframe properties (not settable via the Save as data stream modal):
+
+```json
+"supportsNoneTimeframe": true // adds "None" as a valid option
+"defaultTimeframe": "none" // new tiles default to "None"
+"requiresParameterTimeframe": true // timeframe params always injected even without user selection
+```
+
+---
+
+## defaultShaping
+
+Sets default sort/group/aggregate behaviour when a tile is first added:
+
+```json
+"defaultShaping": { "sort": { "by": [["rank", "asc"]] } }
+```
+
+---
+
+## metadata — column definitions
+
+```json
+{ "name": "voltage", "displayName": "Voltage (V)", "shape": "number" }
+{ "name": "status", "displayName": "Status", "shape": "state" }
+{ "name": "label", "displayName": "Name", "shape": "string", "role": "label" }
+{ "name": "ts", "displayName": "Time", "shape": "string", "role": "timestamp" }
+{ "name": "id", "displayName": "ID", "shape": "string", "visible": false }
+{ "pattern": ".*" } // catch-all: include all columns, infer types from values
+```
+
+Always use `displayName` — column names in scripts are often terse API field names.
+
+**Column inclusion — choose one approach:**
+
+- **All columns**: include `{ "pattern": ".*" }` as the last entry.
+- **Explicit set only**: list each column; unlisted columns are hidden.
+- **Mix**: list specific columns with shapes/roles, then add the pattern for the rest.
+
+**Type inference**: SquaredUp infers column types from the JS primitive returned. Return the correct primitive type from your script — don't coerce to string. Declared `shape` overrides inference.
+
+### Shapes
+
+_Value_
+
+| Shape | Notes |
+| ----------- | ------------------------------------------------------------------------ |
+| `"string"` | Plain text |
+| `"number"` | Numeric. Options: `decimalPlaces` (0–10), `thousandsSeparator` (boolean) |
+| `"boolean"` | Boolean |
+
+_Time_
+
+| Shape | Notes |
+| ---------------- | ---------------------------------------------------------------------------------- |
+| `"date"` | Date/datetime. Options: `format` (e.g. `"dd/MM/yyyy"`), `timeZone`, `inputPattern` |
+| `"seconds"` | Duration in seconds. Options: `formatDuration`, `decimalPlaces` |
+| `"milliseconds"` | Duration in milliseconds |
+| `"minutes"` | Duration in minutes |
+
+_Math_
+
+| Shape | Notes |
+| ----------- | ------------------------------------------------------------------------- |
+| `"percent"` | Percentage 0–100. Options: `asZeroToOne` (multiply by 100 before display) |
+
+_Data size_ (auto-scale, 1024 factors): `"bytes"`, `"kilobytes"`, `"megabytes"`, `"gigabytes"`, `"terabytes"`, `"petabytes"`, `"exabytes"`, `"zettabytes"`, `"yottabytes"`
+
+_Data rates_:
+
+- Metric bit rates (×1000): `"bitspersecondmetric"`, `"kilobitspersecond"`, `"megabitspersecond"`, `"gigabitspersecond"`, `"terabitspersecond"`
+- Binary bit rates (×1024): `"bitspersecondbinary"`, `"kibibitspersecond"`, `"mebibitspersecond"`, `"gibibitspersecond"`, `"tebibitspersecond"`
+- Decimal byte rates (×1000): `"bytesperseconddecimal"`, `"kilobytespersecond"`, `"megabytespersecond"`, `"gigabytespersecond"`, `"terabytespersecond"`
+- Binary byte rates (×1024): `"bytespersecondbinary"`, `"kilobytespersecondbinary"`, `"megabytespersecondbinary"`, `"gigabytespersecondbinary"`, `"terabytespersecondbinary"`
+
+_Currency_: `"usd"`, `"eur"`, `"gbp"`, `"currency"` (options: `code` e.g. `"jpy"`)
+
+_Special_
+
+| Shape | Notes |
+| -------------- | ------------------------------------------------------------------------------------------- |
+| `"state"` | Health dot. Values: `success`, `warning`, `error`, `unknown`, `unmonitored`. Options: `map` |
+| `"url"` | Hyperlink. Options: `label` (static or template e.g. `"{{column.name}}"`) |
+| `"json"` | JSON display |
+| `"guid"` | GUID |
+| `"customunit"` | Custom unit label. Options: `prefix`, `separator` |
+
+**Array form** — use when you need formatting options:
+
+```json
+{ "name": "price", "shape": ["number", { "decimalPlaces": 2 }] }
+{ "name": "expiry", "shape": ["date", { "format": "dd/MM/yyyy" }] }
+{ "name": "updatedAt", "shape": ["date", { "format": "dd/MM/yyyy hh:mm", "timeZone": "Etc/UTC" }] }
+{ "name": "health", "shape": ["state", { "map": { "success": ["ok","active"], "error": ["failed","down"], "warning": ["degraded"] } }] }
+{ "name": "cost", "shape": ["currency", { "code": "jpy" }] }
+```
+
+### Roles
+
+| Role | Description |
+| ------------- | ---------------------------------------------------------------------------- |
+| `label` | Primary display name for the row |
+| `value` | Primary data value (used by scalar tiles) |
+| `timestamp` | Time axis column for line graphs |
+| `id` | Unique row identifier |
+| `sourceId` | Object identifier — enables drilldowns when paired with a fixed `sourceType` |
+| `link` | Hyperlink or navigation field |
+| `unitLabel` | Measurement unit identifier |
+| `comparison` | Enables comparative analysis |
+| `computed` | Derived or calculated field |
+| `description` | Supplementary explanatory content |
+| `none` | No specific role |
+
+### Column expressions
+
+Use `valueExpression` or `formatExpression` to transform per-row data without a post-request script. Both use `{{ ... }}` syntax; inside the expression, `$['columnName']` reads the current row's value for that column.
+
+> ⚠️ **`$['columnName']` only works for columns that are declared in `metadata`.** If the column you want to read is not listed as a metadata entry, the expression receives `undefined`. This applies to both `computed: true` and regular (non-computed) columns. If you need to derive a value from a response field that you don't want to show in the UI, declare the field in metadata with `"visible": false` so the expression can reference it.
+
+> ⚠️ **Don't use `computed: true` just to rename a column.** A `computed` entry whose entire expression is `{{ $['otherField'] }}` is redundant — declare `otherField` directly in metadata and use `displayName` to rename it. Only use `computed: true` when the column doesn't exist in the response at all and must be synthesised (e.g. a constant `sourceType`, or a value derived from two or more other columns).
+
+**`valueExpression`** — computes the column's **actual value**. Sorts, aggregations, shape inference, and downstream tile features all see the result.
+
+With `"computed": true`, the column doesn't have to exist in the response — empty rows are materialized and the expression fills them. Use this to derive a new column from other columns:
+
+```json
+{
+ "name": "complianceState",
+ "displayName": "Compliance State",
+ "computed": true,
+ "valueExpression": "{{ $['softwareStatus'] }}",
+ "shape": [
+ "state",
+ {
+ "map": {
+ "success": ["Compliant"],
+ "error": ["Not Compliant"]
+ }
+ }
+ ]
+}
+```
+
+If the column `softwareStatus` doesn't appear in metadata, `$['softwareStatus']` is `undefined` and the expression silently returns nothing. Add it explicitly if needed:
+
+```json
+{ "name": "softwareStatus", "visible": false }
+```
+
+Without `computed: true`, `valueExpression` overrides the value of a column that's already in the response — useful for building a derived link from raw fields:
+
+```json
+{
+ "name": "link",
+ "valueExpression": "{{ $['status'] !== 'success' ? `https://status.example.com/#${$['id']}` : '' }}",
+ "shape": ["url", { "label": "" }]
+}
+```
+
+**`formatExpression`** — changes only the **displayed string** for a column. The underlying raw value is untouched, so sorting, aggregations, and rollups still operate on the original value.
+
+Use it when the API returns a value in one unit but you want to display another, or to map enum codes to friendly labels for display only:
+
+```json
+{ "name": "download_kbps", "displayName": "Download Speed",
+ "formatExpression": "{{ $['download_kbps'] / 1000 }} Mbps", "shape": "number" }
+
+{ "name": "impact",
+ "formatExpression": "{{ ({ maintenance: 'Maintenance', degradedPerformance: 'Degraded Performance' })[$['impact']] || $['impact'] }}" }
+```
+
+> If the transformed value needs to participate in math, sort, or aggregation, use `valueExpression`. `formatExpression` is display-only and the raw value still flows downstream.
+
+### Drilldown metadata entry
+
+Links a column value to an object in the graph:
+
+```json
+{
+ "sourceId": "deviceId", // column whose value is the sourceId
+ "sourceType": "My Device", // MUST be a fixed string — cannot be dynamic
+ "name": "deviceName" // column to use as the display name
+}
+```
+
+> ⚠️ `sourceType` must be a hardcoded string. Dynamic per-row sourceType is not supported.
+
+> ⚠️ **Blocks tiles** also require `linkColumn` in the viz config set to the same column as `name` in the drilldown entry. Without it, blocks render but don't navigate.
+
+### Object property lookup
+
+Replace a raw ID column with a human-readable property from a related indexed object:
+
+```json
+{ "name": "AgentName", "sourceId": "AgentID", "sourceType": "my-agent", "objectPropertyPath": "name" }
+
+// Combine properties
+{ "name": "AgentLabel", "sourceId": "AgentID", "sourceType": "my-agent",
+ "objectPropertyPath": "name", "valueExpression": "{{ object.name }} ({{ object.company }})" }
+```
+
+---
+
+## Post-request scripts
+
+Scripts run after the HTTP response is received. Input is `data` (parsed JSON body). Set `result` to an array of row objects.
+
+### Wiring a script to a stream
+
+Set `postRequestScript` inside `config` to the script's filename — **the `.js` extension is required**. Name the file after the stream's `name` field and place it in `dataStreams/scripts/`.
+
+Stream JSON (`dataStreams/incidents.json`):
+
+```json
+{
+ "name": "incidents",
+ "displayName": "Incidents",
+ "description": "All open incidents grouped by severity",
+ "tags": ["Incidents"],
+ "baseDataSourceName": "httpRequestUnscoped",
+ "config": {
+ "httpMethod": "get",
+ "endpointPath": "incidents",
+ "postRequestScript": "incidents.js"
+ },
+ "matches": "none",
+ "metadata": [{ "pattern": ".*" }],
+ "timeframes": false
+}
+```
+
+Script file (`dataStreams/scripts/incidents.js`):
+
+```javascript
+// dataStreams/scripts/incidents.js
+result = (data.groups || []).flatMap((group) =>
+ group.items.map((item) => ({ severity: group.severity, ...item })),
+);
+```
+
+> ⚠️ `pathToData` is **ignored** when `postRequestScript` is set. The script receives the raw response body as `data` regardless of `pathToData`, so leaving both configured is dead config — pick one. If a script isn't actually needed, drop it and use `pathToData` alone.
+
+---
+
+> ⚠️ **Don't imitate existing plugins on this.** Many shipped plugins use scripts where they shouldn't — they predate `valueExpression` / `expandInnerObjects` or were never refactored. Evaluate against the checklist below, not against precedent.
+
+### Default: no script
+
+Most streams that look like they need one don't. Run through this checklist first — if every line of the script you were about to write resolves to a row in this table, delete it before you write it:
+
+| Need | Use instead |
+| --------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
+| Navigate to a nested array | `pathToData: "a.b.items"` |
+| Each row is a primitive (string/number) you need to parse | `pathToData` + `valueExpression` reading `$['result']` |
+| Flatten one level of nested object per row | `expandInnerObjects: true` (produces `nested.field` columns) |
+| Constant column value per row (e.g. fixed sourceType) | `{ "name": "sourceType", "computed": true, "valueExpression": "My Type" }` |
+| Derive one column from others on the same row | `valueExpression: "{{ $['a'] + $['b'] }}"` (add `"computed": true` if not in response) |
+| Coerce `"unknown"` / `"n/a"` / `""` to null for a numeric | `valueExpression: "{{ ['unknown','n/a',''].includes($['x']) ? null : Number($['x']) }}"` |
+| Count an array on the row | `valueExpression: "{{ ($['arr'] \|\| []).length }}"` |
+| Rename for display only | `displayName` in the column's metadata entry |
+| Map enum codes to friendly labels | `state` shape with `map` (or `formatExpression` for non-state) |
+
+If a script does nothing beyond items in this table, delete it.
+
+### When a script IS the right tool
+
+Use scripts ONLY for transformations that can't be expressed declaratively:
+
+- Flattening **deeply nested** (>1 level) or **array-into-rows** structures
+- Filtering rows based on cross-field logic
+- Deduplicating
+- Joining values across rows (rankings, running totals)
+- Anything that needs `_.groupBy` or similar reduce-style operations
+
+Renaming, flattening single-level nesting, value coercion, adding constant columns, and `data.items.map(...)` reshapes are **never** valid reasons.
+
+### Available globals
+
+Scripts have access to `data`, `context`, and **lodash** (`_`):
+
+```javascript
+// dataStreams/scripts/myStream.js
+_.groupBy(items, "type");
+_.uniqBy(items, "id");
+_.orderBy(items, ["name"], ["asc"]);
+```
+
+### The context object
+
+```javascript
+// dataStreams/scripts/myStream.js
+context.objects; // array of selected objects (their indexed properties)
+context.objects[0]; // first selected object — use with httpRequestScopedSingle
+context.timeframe; // { start, end, unixStart, unixEnd, interval, enum }
+context.config; // current stream parameters (values set by the user in the tile)
+```
+
+> ⚠️ **Properties you added via `objectMapping.properties` arrive on `context.objects[N]` as arrays.** The graph stores user-defined indexed properties as multi-valued, and the script context preserves that shape — so a scalar like `url` shows up as `["https://..."]`. Templates (`{{object.url}}`) auto-unwrap single-element arrays; the script context does not. Unwrap before comparing:
+>
+> ```javascript
+> const prop = (p) => (Array.isArray(p) ? p[0] : p);
+> result = (data || []).filter((row) =>
+> (row.relatedUrls || []).includes(prop(context.objects[0]?.url)),
+> );
+> ```
+>
+> Common failure mode: `(arr || []).includes(scalar)` silently returns nothing because the script is comparing array-to-string.
+>
+> **Always-scalar fields** — these come from the entity envelope, not the indexed property bag, and don't need unwrapping. Source: `saas/packages/@squaredup/graph/src/mapNodeToExpressionObject.ts`.
+>
+> | Field on `context.objects[N]` | Type | What it is |
+> | ----------------------------- | -------- | --------------------------------------------------- |
+> | `id` | `string` | Internal graph node id |
+> | `sourceId` | `string` | Source-side id (value from `objectMapping.id`) |
+> | `name` | `string` | Display name (alias of `displayName`) |
+> | `displayName` | `string` | Display name |
+> | `type` | `string` | The `sourceType` |
+> | `tenant` | `string` | Tenant id |
+> | `configId` | `string` | Plugin config instance id |
+> | `workspaceId` | `string` | Workspace id (absent on workspace nodes themselves) |
+>
+> Everything else — anything you added via `objectMapping.properties` in `indexDefinitions/` — needs the defensive unwrap above.
+
+### Type primitives
+
+Return actual JS number primitives for numeric columns — returning `"29.19"` (string) instead of `29.19` (number) causes the column to show as String type.
+
+### Deduplication pattern
+
+```javascript
+// dataStreams/scripts/myStream.js
+const seen = new Set();
+const devices = [];
+
+for (const record of data?.records || []) {
+ const key = `${record.type}-${record.instance}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ devices.push({ sourceId: key, name: record.name, sourceType: "My Device" });
+}
+
+result = devices;
+```
diff --git a/.claude/skills/build-plugin/references/index-defs.md b/.claude/skills/build-plugin/references/index-defs.md
new file mode 100644
index 0000000..7d6f93a
--- /dev/null
+++ b/.claude/skills/build-plugin/references/index-defs.md
@@ -0,0 +1,146 @@
+# indexDefinitions/default.json Reference
+
+Defines what gets imported into the SquaredUp graph.
+
+```json
+{
+ "steps": [
+ {
+ "name": "installations",
+ "dataStream": { "name": "installations" },
+ "timeframe": "none",
+ "objectMapping": {
+ "id": "uid",
+ "name": "name",
+ "type": "sourceType",
+ "properties": ["siteId", "timezone", "alarm"]
+ }
+ },
+ {
+ "name": "devices",
+ "dataStream": { "name": "deviceList" },
+ "timeframe": "none",
+ "objectMapping": {
+ "id": "uid",
+ "name": "name",
+ "type": "sourceType",
+ "properties": [
+ "siteId",
+ "instance",
+ { "deviceType": "dbusServiceType" }
+ ]
+ }
+ }
+ ]
+}
+```
+
+**Key rules:**
+
+- `id` maps to the column holding the unique stable ID. The stored `sourceId` is prefixed with `sourceType~` — e.g. if `id` returns `"123"` and type is `"My Device"`, the stored value is `"My Device~123"`. To use the raw ID in expressions use `{{object.rawId}}`.
+- `name` maps to the display name column.
+- `type` maps to the `sourceType` column. Can also be a fixed string: `{ "value": "My Device" }` — use when all rows are the same type.
+- `properties` are extra fields stored on the graph node, accessible in scripts as `object.propName`.
+- Use `{ "targetProp": "sourceProp" }` syntax when the column name differs from the desired property name.
+- The `sourceType` column value **must** match an entry in `objectTypes` in `metadata.json`.
+- `frequencyMinutes` — controls re-import interval. Defaults to `720` (12 hours).
+
+---
+
+## Import data stream pattern
+
+The stream called by an import step must return one flat row per object with at least `sourceId`, `name`, `sourceType`.
+
+### Prefer a script-less stream
+
+A typical paged list endpoint — response shape `{ items: [{ id, attributes: { name, ... } }, ...] }` — can be turned into an indexable stream with **no post-request script**:
+
+```json
+{
+ "name": "devices",
+ "displayName": "Devices",
+ "baseDataSourceName": "httpRequestUnscoped",
+ "config": {
+ "httpMethod": "get",
+ "endpointPath": "devices",
+ "pathToData": "items",
+ "expandInnerObjects": true,
+ "paging": {
+ "mode": "offset",
+ "pageSize": {
+ "realm": "queryArg",
+ "path": "limit",
+ "value": "100"
+ },
+ "offset": {
+ "mode": "page",
+ "rowCountIn": { "realm": "payloadArraySize", "path": "items" },
+ "base": 1
+ },
+ "out": { "realm": "queryArg", "path": "page" }
+ }
+ },
+ "matches": "none",
+ "metadata": [
+ {
+ "name": "id",
+ "visible": false
+ },
+ {
+ "name": "sourceType",
+ "computed": true,
+ "valueExpression": "My Device",
+ "visible": false
+ },
+ { "name": "attributes.name", "displayName": "Name", "role": "label" },
+ {
+ "name": "attributes.cpuCores",
+ "displayName": "CPU Cores",
+ "valueExpression": "{{ ['unknown','n/a',''].includes($['attributes.cpuCores']) ? null : Number($['attributes.cpuCores']) }}",
+ "shape": ["number", { "decimalPlaces": 0 }]
+ }
+ ],
+ "timeframes": false
+}
+```
+
+How this avoids a script:
+
+- `pathToData: "items"` walks into the paged array — no `data.items.map(...)` in JS.
+- `expandInnerObjects: true` flattens `attributes.*` into dot-notation columns (`attributes.name`, `attributes.cpuCores`).
+- `computed: true` materialises `sourceId`/`sourceType` from a constant string.
+- `valueExpression` coerces `"unknown"` / `"n/a"` to `null` for numeric columns.
+
+The index definition then references the dot-notation column names directly:
+
+```json
+"objectMapping": {
+ "id": "id",
+ "name": "attributes.name",
+ "type": "sourceType",
+ "properties": [
+ { "cpuCores": "attributes.cpuCores" }
+ ]
+}
+```
+
+### When a script is justified
+
+Use a post-request script only when the transformation can't be expressed declaratively — for example, an API that returns nested arrays you need to expand into rows:
+
+```javascript
+// scripts/installations.js — flattens a nested device array within each installation
+const installations = data?.records || [];
+
+result = installations.flatMap((inst) =>
+ (inst.devices || []).map((d) => ({
+ sourceId: `${inst.idSite}-${d.id}`,
+ sourceType: "My Device",
+ name: d.name,
+ siteId: String(inst.idSite),
+ timezone: inst.timezone,
+ })),
+);
+```
+
+See [data-streams.md § Post-request scripts](data-streams.md#post-request-scripts) for the full "do I need a script?" checklist.
diff --git a/.claude/skills/build-plugin/references/metadata.md b/.claude/skills/build-plugin/references/metadata.md
new file mode 100644
index 0000000..6ddd423
--- /dev/null
+++ b/.claude/skills/build-plugin/references/metadata.md
@@ -0,0 +1,153 @@
+# metadata.json Reference
+
+## Contents
+
+- [metadata.json template and field notes](#metadatajson)
+- [Auth patterns](#auth-patterns)
+
+---
+
+## metadata.json
+
+```json
+{
+ "name": "my-plugin",
+ "displayName": "My Plugin",
+ "version": "1.0.0",
+ "author": { "name": "@yourhandle", "type": "community" },
+ "description": "One sentence, max 300 chars.",
+ "category": "Monitoring",
+ "type": "hybrid",
+ "schemaVersion": "2.0",
+ "importNotSupported": false,
+ "restrictedToPlatforms": [],
+ "keywords": ["keyword1", "keyword2"],
+ "objectTypes": ["My Installation", "My Device"],
+ "links": [
+ {
+ "category": "documentation",
+ "url": "...",
+ "label": "Help adding this plugin"
+ },
+ { "category": "source", "url": "...", "label": "Repository" }
+ ],
+ "base": {
+ "plugin": "WebAPI",
+ "majorVersion": "1",
+ "config": {
+ "baseUrl": "https://api.example.com/v2",
+ "authMode": "none",
+ "headers": [
+ { "key": "Authorization", "value": "Bearer {{accessToken}}" }
+ ],
+ "queryArgs": []
+ }
+ }
+}
+```
+
+**Field notes:**
+
+- `name`: lowercase kebab-case (e.g. `my-plugin`). Folder uses PascalCase (e.g. `MyPlugin`) — these are separate things.
+- `author.type`: `"community"` for external contributors, `"labs"` for SquaredUp Labs plugins.
+- `type`: always `"hybrid"` for Web API plugins. Options: `"hybrid"` (cloud or on-prem agent), `"cloud"`, `"onprem"`.
+- `schemaVersion`: always `"2.0"`.
+- `category`: `"Monitoring"`, `"Database"`, `"Security"`, `"Network"`, `"Infrastructure"`, `"Cloud Platforms"`, `"APM"`, `"CI/CD Tools"`, `"Alert Management"`, `"Issue Tracking"`, `"Collaboration"`, `"Service Management"`, `"Analytics"`, `"CRM"`, `"Version Control"`, `"CDN"`, `"Utility"`, `"Fun"`. New categories can be added if no existing one fits closely enough.
+- `links` and `keywords` must be added manually — not populated by the export modal.
+- `documentation` link must point to in-repo `docs/README.md` (e.g. `https://github.com/squaredup/plugins/blob/main/plugins/MyPlugin/v1/docs/README.md`). Do not link to the vendor's own docs.
+
+---
+
+## Auth patterns
+
+**API key in header:**
+
+```json
+"authMode": "none",
+"headers": [{ "key": "X-API-Key", "value": "{{apiKey}}" }]
+```
+
+**Bearer token:**
+
+```json
+"authMode": "none",
+"headers": [{ "key": "Authorization", "value": "Bearer {{accessToken}}" }]
+```
+
+**API key as query parameter:**
+
+```json
+"authMode": "none",
+"queryArgs": [{ "key": "api_key", "value": "{{apiKey}}" }]
+```
+
+**Basic auth:**
+
+```json
+"authMode": "basic",
+"basicAuthUsername": "{{username}}",
+"basicAuthPassword": "{{password}}"
+```
+
+**Digest auth:**
+
+```json
+"authMode": "digest",
+"digestAuthUsername": "{{username}}",
+"digestAuthPassword": "{{password}}"
+```
+
+**OAuth2 client credentials:**
+
+```json
+"authMode": "oauth2",
+"oauth2GrantType": "clientCredentials",
+"oauth2TokenUrl": "https://api.example.com/oauth/token",
+"oauth2ClientId": "{{clientId}}",
+"oauth2ClientSecret": "{{clientSecret}}"
+```
+
+**OAuth2 authorization code** (user signs in via browser — e.g. Google Sheets, Snowflake):
+
+```json
+"authMode": "oauth2",
+"oauth2GrantType": "authCode",
+"oauth2AuthUrl": "https://accounts.example.com/oauth/authorize",
+"oauth2TokenUrl": "https://accounts.example.com/oauth/token",
+"oauth2ClientId": "{{oauth2ClientId}}",
+"oauth2Scope": "read:data offline_access",
+"oauth2AuthExtraArgs": [
+ { "key": "response_type", "value": "code" },
+ { "key": "access_type", "value": "offline" }
+]
+```
+
+Token refresh is handled automatically for all OAuth2 flows.
+
+**OAuth2 password grant:**
+
+```json
+"authMode": "oauth2",
+"oauth2GrantType": "password",
+"oauth2TokenUrl": "https://api.example.com/oauth/token",
+"oauth2ClientId": "{{clientId}}",
+"oauth2ClientSecret": "{{clientSecret}}",
+"oauth2PasswordGrantUserName": "{{username}}",
+"oauth2PasswordGrantPassword": "{{password}}"
+```
+
+**Advanced OAuth2 options** (provider-specific edge cases):
+
+```json
+"oauth2ClientSecretLocationDuringAuth": "body", // "query" (default), "body", or "header"
+"oauth2SendTokenInParameters": true, // send access token as query param instead of Bearer header
+"oauth2TokenExtraArgs": [{ "key": "k", "value": "v" }],
+"oauth2TokenExtraHeaders": [{ "key": "k", "value": "v" }]
+```
+
+OAuth URLs and scopes support `{{fieldName}}` expressions — useful when the auth URL includes a tenant ID from the user's config:
+
+```json
+"oauth2AuthUrl": "https://{{accountId}}.example.com/oauth/authorize",
+"oauth2Scope": "read {{role ? 'role:' + role : ''}}"
+```
diff --git a/.claude/skills/build-plugin/references/oob-content.md b/.claude/skills/build-plugin/references/oob-content.md
new file mode 100644
index 0000000..666e43d
--- /dev/null
+++ b/.claude/skills/build-plugin/references/oob-content.md
@@ -0,0 +1,314 @@
+# OOB Default Content Reference
+
+## Contents
+
+- [scopes.json](#scopesjson)
+- [manifest.json](#manifestjson)
+- [Dashboard layout](#dashboard-layout)
+- [Dashboard rules](#dashboard-rules)
+- [Visualisation types](#visualisation-types): table, line graph, bar chart, scalar, donut, blocks, gauge, embed
+- [Templating tokens](#templating-tokens)
+
+---
+
+## scopes.json
+
+One scope per object type — used to populate tile scope pickers in dashboards:
+
+```json
+[
+ {
+ "name": "Installations",
+ "matches": {
+ "sourceType": { "type": "oneOf", "values": ["My Installation"] }
+ },
+ "variable": {
+ "name": "Installation",
+ "allowMultipleSelection": false,
+ "default": "none",
+ "type": "object"
+ }
+ },
+ {
+ "name": "Devices",
+ "matches": {
+ "sourceType": { "type": "oneOf", "values": ["My Device"] }
+ },
+ "variable": {
+ "name": "Device",
+ "allowMultipleSelection": false,
+ "default": "none",
+ "type": "object"
+ }
+ }
+]
+```
+
+Only include scopes that are actually used by OOB dashboards or dashboard variables. Don't add scopes speculatively.
+
+---
+
+## manifest.json
+
+```json
+{
+ "items": [
+ { "name": "installationOverview", "type": "dashboard" },
+ { "name": "deviceDashboard", "type": "dashboard" },
+ { "name": "Installations", "type": "folder" }
+ ]
+}
+```
+
+Single `.dash.json` files reference as `"type": "dashboard"`. Folders map to sub-directories, each with its own `manifest.json`. Only create a folder when there are multiple dashboards to group for the same object type.
+
+---
+
+## Dashboard layout
+
+```json
+{
+ "name": "My Dashboard",
+ "schemaVersion": "1.4",
+ "timeframe": "last24hours",
+ "variables": ["{{variables.[Installation]}}"],
+ "dashboard": {
+ "_type": "layout/grid",
+ "columns": 4,
+ "version": 1,
+ "contents": [
+ {
+ "i": "unique-uuid-here",
+ "x": 0,
+ "y": 0,
+ "w": 2,
+ "h": 4,
+ "moved": false,
+ "static": false,
+ "z": 0,
+ "config": {
+ "_type": "tile/data-stream",
+ "title": "My Tile",
+ "description": "",
+ "activePluginConfigIds": ["{{configId}}"],
+ "dataStream": {
+ "id": "{{dataStreams.[myStream]}}",
+ "name": "myStream",
+ "pluginConfigId": "{{configId}}"
+ },
+ "scope": {
+ "scope": "{{scopes.[Installations]}}",
+ "workspace": "{{workspaceId}}",
+ "variable": "{{variables.[Installation]}}"
+ },
+ "variables": ["{{variables.[Installation]}}"],
+ "visualisation": {
+ "type": "data-stream-table",
+ "config": { "data-stream-table": { "transpose": true } }
+ }
+ }
+ }
+ ]
+ }
+}
+```
+
+---
+
+## Dashboard rules
+
+- **Do not repeat the plugin name in dashboard names.** The name appears beneath the plugin name in the UI — "MyPlugin / Overview" is correct; "MyPlugin / MyPlugin Overview" is redundant.
+- **Give each dashboard a distinct name.** Perspective tabs sit next to each other — identical names are indistinguishable.
+- `"variables"` array supports **only one variable** per dashboard. Design each dashboard around a single object type.
+- Omit `"timeframe"` on tiles to inherit the dashboard timeframe — do not hardcode it on individual tiles.
+- All tile IDs (`"i"`) must be **genuinely random UUIDs** — generate with `node ".claude/skills/build-plugin/scripts/gen-uuids.js" [N]`. Never invent patterned UUIDs.
+
+**Grid layout:**
+
+- `w` + `x` must not exceed the column count.
+- `h=2` works well for most tiles; use consistent heights for side-by-side tiles.
+- **Match heights for side-by-side tiles.** Tiles at the same `y` must have the same `h` — mismatched heights leave a visible gap.
+- Side-by-side pairing example: attributes table `w=1, x=0` + chart `w=3, x=1` at the same `y`.
+
+---
+
+## Visualisation types
+
+### Table
+
+Use `transpose: true` for key-value single-row data:
+
+```json
+{
+ "type": "data-stream-table",
+ "config": {
+ "data-stream-table": {
+ "transpose": false,
+ "columnOrder": ["name", "status", "value"],
+ "hiddenColumns": ["id", "internalKey"],
+ "columnDisplayNames": { "ts": "Timestamp" },
+ "resizedColumns": { "columnWidths": { "name": 250 } }
+ }
+ }
+}
+```
+
+### Line graph
+
+```json
+{
+ "type": "data-stream-line-graph",
+ "config": {
+ "data-stream-line-graph": {
+ "xAxisColumn": "timestamp",
+ "yAxisColumns": ["value", "baseline"],
+ "seriesColumn": "none",
+ "showLegend": true,
+ "legendPosition": "bottom",
+ "yAxisLabel": "Response time (ms)",
+ "showYAxisLabel": true,
+ "showTrendLine": false
+ }
+ }
+}
+```
+
+### Bar chart
+
+```json
+{
+ "type": "data-stream-bar-chart",
+ "config": {
+ "data-stream-bar-chart": {
+ "xAxisData": "name",
+ "yAxisData": ["count"],
+ "xAxisGroup": "none",
+ "xAxisLabel": "",
+ "yAxisLabel": "",
+ "showXAxisLabel": true,
+ "showYAxisLabel": true,
+ "showLegend": false,
+ "legendPosition": "bottom",
+ "showGrid": true,
+ "horizontalLayout": "vertical",
+ "displayMode": "actual",
+ "showTotals": false,
+ "showValue": false,
+ "grouping": false,
+ "range": { "type": "auto" }
+ }
+ }
+}
+```
+
+### Scalar
+
+Single value/KPI:
+
+```json
+{
+ "type": "data-stream-scalar",
+ "config": {
+ "data-stream-scalar": {
+ "value": "columnName",
+ "comparisonColumn": "none",
+ "label": "Custom Label",
+ "manualSize": 50,
+ "formatted": false
+ }
+ }
+}
+```
+
+### Donut chart
+
+```json
+{
+ "type": "data-stream-donut-chart",
+ "config": {
+ "data-stream-donut-chart": {
+ "valueColumn": "count",
+ "labelColumn": "category",
+ "hideCenterValue": false,
+ "showValuesAsPercentage": true,
+ "legendPosition": "auto",
+ "legendMode": "table"
+ }
+ }
+}
+```
+
+### Blocks
+
+Health/status grid:
+
+```json
+{
+ "type": "data-stream-blocks",
+ "config": {
+ "data-stream-blocks": {
+ "labelColumn": "name",
+ "stateColumn": "state",
+ "sublabel": "status",
+ "linkColumn": "none",
+ "columns": 4
+ }
+ }
+}
+```
+
+Use `"stateColumn": "none"` when data has no state — blocks render without health colour. To enable drilldowns, set `"linkColumn"` to the column named in the drilldown metadata entry:
+
+```json
+{
+ "labelColumn": "name",
+ "stateColumn": "none",
+ "linkColumn": "name",
+ "columns": 4
+}
+```
+
+### Gauge
+
+```json
+{
+ "type": "data-stream-gauge",
+ "config": {
+ "data-stream-gauge": {
+ "value": { "type": "arr", "columns": ["columnName"] },
+ "label": "Optional label",
+ "minimum": 0,
+ "maximum": 100,
+ "minimumColumn": "minCol",
+ "maximumColumn": "maxCol"
+ }
+ }
+}
+```
+
+`value` options: `{ "type": "arr", "columns": ["col"] }`, `{ "type": "count" }`, `{ "type": "sum", "columns": ["col"] }`, `{ "type": "mean", "columns": ["col"] }`.
+
+### Embed
+
+Image or iframe:
+
+```json
+{
+ "type": "tile/embed",
+ "config": {
+ "tile/embed": { "src": "https://example.com/embed", "title": "" }
+ }
+}
+```
+
+---
+
+## Templating tokens
+
+| Token | Resolves to |
+| ------------------------------ | ---------------------------------- |
+| `{{configId}}` | The plugin config instance ID |
+| `{{workspaceId}}` | Current workspace |
+| `{{scopes.[ScopeName]}}` | A scope by name from `scopes.json` |
+| `{{dataStreams.[streamName]}}` | The data stream's ID |
+| `{{variables.[VariableName]}}` | The variable defined on a scope |
diff --git a/.claude/skills/build-plugin/references/ui.md b/.claude/skills/build-plugin/references/ui.md
new file mode 100644
index 0000000..0d5d964
--- /dev/null
+++ b/.claude/skills/build-plugin/references/ui.md
@@ -0,0 +1,250 @@
+# ui.json Field Type Reference
+
+## Contents
+
+- [Overview and common properties](#overview)
+- [Text inputs](#text-inputs): text, url, password, textarea, number
+- [Selection inputs](#selection-inputs): checkbox, toggle, radio, switch, choiceChips, autocomplete
+- [Advanced inputs](#advanced-inputs): key-value, expression, json, code, script
+- [Layout](#layout): markdown, fieldGroup
+- [OAuth2](#oauth2): oAuth2
+
+---
+
+## Overview
+
+Defines the config form shown when a user adds the plugin. One entry per config field.
+
+**Common properties** on all field types:
+
+- `name` — field key, referenced as `{{fieldName}}` in expressions
+- `label` — displayed in the form
+- `defaultValue` — pre-populated value
+- `validation` — e.g. `{ "required": true }`
+- `help` — tooltip shown as a (?) icon; **supports markdown**
+
+> ⚠️ Do **not** set a `title` attribute on fields. It is unused and should be omitted.
+
+**`tileEditorStep`** — controls which tile editor step the field appears in. Defaults to `["Parameters"]`. Set to `["Timeframe"]` to place on the Timeframe step. **JSON-only** — cannot be set via the Save as data stream modal.
+
+**Conditional visibility** — any field or fieldGroup can use `visible`:
+
+```json
+// Show when another field equals a specific value
+{ "type": "fieldGroup", "visible": { "authMode": "basic" }, "fields": [...] }
+
+// Show when a field matches one of several values
+{ "type": "fieldGroup", "visible": { "authMode": { "type": "oneOf", "values": ["basic", "digest"] } }, "fields": [...] }
+```
+
+**`ignoreCertificateErrors`** — add to any plugin that may connect to on-prem instances with self-signed certificates:
+
+```json
+{
+ "type": "checkbox",
+ "name": "ignoreCertificateErrors",
+ "label": "Ignore certificate errors",
+ "help": "Enable when connecting to an instance with a self-signed certificate."
+}
+```
+
+---
+
+## Text inputs
+
+**`text` / `url`** — single-line text:
+
+```json
+{
+ "type": "text",
+ "name": "hostname",
+ "label": "Hostname",
+ "placeholder": "api.example.com"
+}
+```
+
+**`password`** — masked text; **use for any API key, token, secret, or password field**:
+
+```json
+{ "type": "password", "name": "apiKey", "label": "API Key" }
+```
+
+**`textarea`** — multiline text:
+
+```json
+{ "type": "textarea", "name": "query", "label": "Query", "rows": 5 }
+```
+
+**`number`** — numeric input:
+
+```json
+{ "type": "number", "name": "port", "label": "Port", "defaultValue": 443 }
+```
+
+---
+
+## Selection inputs
+
+**`checkbox`** — single boolean:
+
+```json
+{
+ "type": "checkbox",
+ "name": "enabled",
+ "label": "Enable feature",
+ "defaultValue": true
+}
+```
+
+**`toggle`** — boolean toggle:
+
+```json
+{
+ "type": "toggle",
+ "name": "advancedMode",
+ "label": "Advanced Mode",
+ "defaultValue": false
+}
+```
+
+**`radio`** — radio button group:
+
+```json
+{
+ "type": "radio",
+ "name": "environment",
+ "label": "Environment",
+ "options": [
+ { "value": "prod", "label": "Production" },
+ { "value": "dev", "label": "Development" }
+ ]
+}
+```
+
+**`switch`** — segmented button group (like radio, different visual style):
+
+```json
+{
+ "type": "switch",
+ "name": "view",
+ "label": "View",
+ "options": [
+ { "value": "table", "label": "Table" },
+ { "value": "chart", "label": "Chart" }
+ ]
+}
+```
+
+**`choiceChips`** — chip-style selection (supports `isMulti: true`):
+
+```json
+{
+ "type": "choiceChips",
+ "name": "tags",
+ "label": "Tags",
+ "options": [
+ { "value": "a", "label": "Option A" },
+ { "value": "b", "label": "Option B" }
+ ]
+}
+```
+
+**`autocomplete`** — searchable dropdown; fixed list or data stream–driven; supports `allowCustomValues`, `isMulti`, `isClearable`:
+
+```json
+// Fixed list
+{ "type": "autocomplete", "name": "region", "label": "Region", "allowCustomValues": true,
+ "data": { "source": "fixed", "values": [
+ { "value": "us-east-1", "label": "US East (N. Virginia)" },
+ { "value": "eu-west-1", "label": "EU West (Ireland)" }
+ ]}
+}
+
+// Driven by a data stream
+{ "type": "autocomplete", "name": "instance", "label": "Instance",
+ "data": { "source": "dataStream", "dataStreamName": "myPlugin-listInstances",
+ "dataSourceConfig": { "dataSourceName": "datasourceName" } }
+}
+```
+
+> ⚠️ When using a data stream as the autocomplete source, the backing stream must return rows with `label` and `value` columns, and those columns must have `"role": "label"` and `"role": "value"` declared in the stream's metadata.
+
+---
+
+## Advanced inputs
+
+**`key-value`** — list of key/value pairs (useful for custom headers, tags).
+
+```json
+{
+ "type": "key-value",
+ "name": "headers",
+ "label": "Headers"
+}
+```
+
+**`expression`** — expression/template input:
+
+```json
+{ "type": "expression", "name": "filter", "label": "Filter Expression" }
+```
+
+**`json`** — JSON editor:
+
+```json
+{ "type": "json", "name": "config", "label": "Configuration" }
+```
+
+**`code`** — code editor with syntax highlighting:
+
+```json
+{ "type": "code", "name": "body", "label": "Request Body", "language": "json" }
+```
+
+**`script`** — inline JavaScript editor:
+
+```json
+{
+ "type": "script",
+ "name": "postRequestScript",
+ "label": "Script",
+ "placeholder": "result = data;"
+}
+```
+
+---
+
+## Layout
+
+**`markdown`** — informational text block (not an input — use for instructions or notes):
+
+```json
+{
+ "type": "markdown",
+ "name": "info",
+ "content": "**Note:** Replace the placeholder values below."
+}
+```
+
+**`fieldGroup`** — groups related fields under a shared label:
+
+```json
+{ "type": "fieldGroup", "label": "Advanced Options", "fields": [ ...field definitions... ] }
+```
+
+Add `"displayAs": "fieldGroupToggle"` to make the group collapsible:
+
+```json
+{ "type": "fieldGroup", "name": "advanced", "label": "Advanced Options", "displayAs": "fieldGroupToggle",
+ "fields": [ ...field definitions... ] }
+```
+
+---
+
+## OAuth2
+
+**`oAuth2`** — renders the OAuth2 sign-in button; used alongside `authCode` grant type in `metadata.json`:
+
+```json
+{ "type": "oAuth2", "name": "oauth2", "label": "Sign in" }
+```
diff --git a/.claude/skills/build-plugin/scripts/gen-uuids.js b/.claude/skills/build-plugin/scripts/gen-uuids.js
new file mode 100644
index 0000000..cfc8450
--- /dev/null
+++ b/.claude/skills/build-plugin/scripts/gen-uuids.js
@@ -0,0 +1,8 @@
+#!/usr/bin/env node
+const n = parseInt(process.argv[2] ?? "1", 10);
+if (isNaN(n) || n < 1) {
+ console.error("Usage: gen-uuids.js [count]");
+ console.error("count must be a positive integer");
+ process.exit(1);
+}
+for (let i = 0; i < n; i++) console.log(crypto.randomUUID());
diff --git a/.claude/skills/deploy-plugin/SKILL.md b/.claude/skills/deploy-plugin/SKILL.md
new file mode 100644
index 0000000..daa6dfc
--- /dev/null
+++ b/.claude/skills/deploy-plugin/SKILL.md
@@ -0,0 +1,66 @@
+---
+name: deploy-plugin
+description: Validates and deploys a SquaredUp plugin using the squaredup CLI. Use when validating plugin files, deploying to a SquaredUp tenant, or determining the correct version bump for a plugin change.
+---
+
+# Deploying a SquaredUp Plugin
+
+**Announce at start:** "I'm using the deploy-plugin skill."
+
+**Prerequisites:** Node.js 22 or later. Run from the versioned plugin directory (e.g. `my-plugin/v1/`).
+
+---
+
+## Commands
+
+```bash
+# Login (interactive)
+squaredup login
+
+# Login (non-interactive, for CI)
+squaredup login --apiKey --region eu # regions: us, eu, dev
+
+# Check login status
+squaredup status
+
+# Validate (always run before deploy)
+squaredup validate # validate current directory
+squaredup validate --watch # re-validate on every file change
+squaredup validate --json # JSON output — use this flag when running as Claude/AI agent
+
+# Deploy
+squaredup deploy --force # overwrite without confirmation prompt
+squaredup deploy --watch # re-deploy automatically on file changes
+
+# List and delete deployed plugins
+squaredup list # list all plugins deployed to your tenant
+squaredup delete # interactively select and delete a deployed plugin
+
+# Global flags
+squaredup --debug # verbose output
+squaredup --silent # suppress output
+```
+
+Always validate before deploying. The validator catches: missing required fields, unknown keys, invalid matches syntax, broken dashboard references.
+
+---
+
+## Versioning
+
+New plugins start at `1.0.0`. Use semver:
+
+| Change type | Bump |
+|---|---|
+| Bug fix, docs, icon, metadata tweak | PATCH (`1.0.x`) |
+| New stream, new optional config field, new default content | MINOR (`1.x.0`) |
+| Deleted/renamed stream, breaking config change | MAJOR (`x.0.0`) |
+
+Every PR that modifies plugin files must include a version bump in `metadata.json`.
+
+**Breaking (MAJOR) changes — do not create a new major version without asking the user first.** It is often possible to avoid the break entirely. If a major version is genuinely needed:
+- Create a new versioned folder (e.g. `v2/`) rather than modifying `v1/`
+- Mark the removed/changed stream `deprecated` in one release, then remove it in a follow-up major bump
+
+```json
+"visibility": { "type": "deprecated", "reason": "Use newStreamName instead" }
+```
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..524403d
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,20 @@
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{md,json,js,jsx,ts,tsx}]
+indent_style = space
+indent_size = 4
+
+[*.json]
+insert_final_newline = false
+
+[*.md]
+trim_trailing_whitespace = false
+
+[*.{js,jsx,ts,tsx}]
+end_of_line = crlf