---
name: adanotch-extension
description: Write, change, test and debug Adanotch extensions — TypeScript programs that show a view beside the MacBook notch and in the Adanotch island card (AdanotchKit, manifest.json, adanotch dev/lint/build). Use when the folder has an adanotch/ directory or a manifest.json with "adanotchVersion", or when the user asks for an Adanotch extension, widget or island view.
---

# Adanotch extensions

An Adanotch extension is a TypeScript program bundled to one JavaScript file. Adanotch runs it in its own process on JavaScriptCore and draws the view it returns with native SwiftUI, beside the notch (`compact`) and in the open island card (`expanded`).

Full docs: https://www.adanotch.com/docs/en (Turkish: /docs/tr).

## Source of truth

- `adanotch/adanotch.d.ts` in the extension folder is the complete, current API. Read it before using an API you aren't sure of; don't invent methods, node types or options that aren't there.
- `adanotch/manifest.schema.json` is the manifest's schema.
- Never edit anything in `adanotch/`. `adanotch init --types` replaces it. If an API from the docs is missing or a type error looks wrong, the folder is out of date: run `adanotch init --types` (`adanotch lint` warns about this too).

## Folder

| Path | What |
| --- | --- |
| `manifest.json` | id, permissions, refresh, settings |
| `src/index.ts` | the extension |
| `test/*.test.ts` | tests on Node, with `adanotch/testing.js` |
| `dist/index.js` | the build; what Adanotch runs (`main` in the manifest) |

## Workflow

After every change, run these and fix what they report before saying you're done:

```sh
npm run typecheck   # tsc on src and test
npm test            # node --test, on Adanotch's own runtime
npm run build       # esbuild → dist/index.js
npm run lint        # adanotch lint: manifest, permissions, undeclared hosts, stale adanotch/
```

`adanotch dev` (with `npm run watch`) loads the folder into the running app and reloads on save; it needs Developer mode on and a human watching the island, so suggest it rather than relying on it. `npm run package` (`adanotch build`) makes a shareable `.adanotchext`.

## Shape of an extension

Everything is on the global `AdanotchKit`; there are no imports. Call `activity` once, at the top level.

```ts
const { activity, ui, events } = AdanotchKit;

interface Settings { city: string } // must match manifest.json "settings"

activity<Settings>({
  setup(ctx) {
    // once: subscribe to events, start timers
    events.on("command", ({ name }) => { if (name === "reset") ctx.refresh(); });
  },
  async update(ctx) {
    // returns the view; runs after setup, every `refresh`, after onAction / onSettingsChange, on ctx.refresh()
    return {
      compact: { leading: ui.icon("cloud.sun.fill"), trailing: ui.text("21°") },
      expanded: ui.stack([ui.text(ctx.settings.city, { style: "title" }), ui.button("Refresh", "refresh")]),
    };
  },
  onAction(action, ctx, value) {
    // a control was used; update() runs right after
  },
  onSettingsChange(ctx) {
    // ctx.settings already holds the new values; update() runs right after
  },
});
```

- Overlapping refreshes merge into one more run. Returning nothing from `update` keeps the last view.
- `ctx.render(view)` changes part of the view now: keys given replace, `null` removes, the rest stays.
- Module variables reset when the process restarts; `ctx.storage` (sync, JSON, 512 KB) persists.
- `manifest.json` `"refresh"`: `"5s"`, `"10m"`, `"1h"` or `"manual"` (event-driven extensions). Default 5 s.

## The view

- No HTML, DOM, CSS, raw colours or fonts. Only the `ui.*` nodes: `text`, `markdown`, `icon` (SF Symbols names), `image`, `badge`, `gauge`, `ring`, `progress`, `chart`, `timer`, `stack`, `grid`, `scroll`, `tabs`, `divider`, `spacer`, `button`, `toggle`, `slider`, `textField`, `picker`, `list`.
- Colour is a semantic `tone`: `default`, `secondary`, `accent`, `success`, `warning`, `danger`. Text `style`: `title`, `body`, `caption`, `mono`, `display`.
- `compact.leading` / `compact.trailing` take one small node each: `text`, `icon`, `badge`, `timer`, `ring`, `progress`, `gauge`. Keep compact text to a few characters.
- `stack` is vertical unless `{ axis: "h" }`; falsy children are skipped (`cond && ui.text(…)`).
- `ui.timer(date)` counts by itself; don't re-render every second to show a countdown.
- Controls carry an `action` string; `onAction(action, ctx, value)` gets the value (button `value`, toggle boolean, slider number, picker option value, list row `value`, text field text on Return). `textField(action, { changeAction })` also reports while the user types.
- `ui.image` only loads https URLs from `network:` hosts. Limits: 2000 nodes, 32 levels, 500 chart values.
- Layout control is limited (no sizes, padding or overlays). If the user wants something the nodes can't express, say so instead of faking it.

## Permissions

Ask for exactly what the code uses in `manifest.json` `"permissions"`; the user approves each one and can turn it off later.

| Permission | API |
| --- | --- |
| `system.stats` | `system.stats()` |
| `battery.read` | `battery.read()`, `battery.change` |
| `notifications` | `notify.island({ text, icon, tone, duration })` |
| `media.read` / `media.control` | `media.nowPlaying()`, `media.change` / `media.play()`, `pause()`, `toggle()`, `next()`, `previous()`, `seek(s)` |
| `calendar.read` | `calendar.events({ days })`, `calendar.change` |
| `reminders.read` | `reminders.today()`, `reminders.change` |
| `clipboard.read` / `clipboard.write` | `clipboard.readText()`, `clipboard.change` / `clipboard.writeText()` |
| `network:<domain>` | `fetch` (and `ui.image`) to that domain and its subdomains, https only |

Without a permission the call throws `PermissionError`; handle it where a user might turn one off. Media needs Adanotch's Music module on; calendar and reminders need macOS access for Adanotch.

## Network and secrets

- `fetch` works like the browser's but: https only, `network:` hosts only (redirects too), no cookies or cache, `timeout` option (default 15 s), 6 at once, 120 a minute, 5 MB responses. 4xx/5xx don't throw; check `res.ok`. Errors: `NetworkError`, `TimeoutError`, `PermissionError`.
- `ctx.network.fetchJSON<T>(url, init)` is fetch + `.json()`. `body`: a string as is, `URLSearchParams` as a form, anything else as JSON.
- API keys and passwords: a `{ "type": "secret" }` setting in the manifest (no `default`), read with `await AdanotchKit.secrets.get(key)` (null until set). Never hard-code them, store them in `storage`, or log them.
- Keep the last good response in `storage` and show it when offline.

## Hard limits

- One synchronous turn may take 200 ms; past it the process is killed. Await I/O, and split heavy loops with `await new Promise((r) => setTimeout(r, 0))`.
- 64 MB memory, 50% CPU averaged over 10 s. Three strikes in a row (or three `update` errors in a row) turn the extension off; a `setup` error stops it at once.
- No `window`, `document`, `localStorage`, `require`, `process`, `fs`, `Buffer`, `WebSocket`, `crypto.subtle`. npm packages that need them won't work; pure JS ones (date-fns, zod) do. Bundle ≤ 4 MB.
- `open(url)` (https/mailto) only works within 10 s of the user using one of the extension's controls.

## Testing

`loadExtension` bundles `src/index.ts` and runs it on the real runtime; mock what Adanotch would answer.

```ts
import assert from "node:assert/strict";
import { test } from "node:test";
import { loadExtension } from "../adanotch/testing.js";

test("shows the temperature", async () => {
  const ext = await loadExtension({
    settings: { city: "Ankara" },
    secrets: { token: "t" },
    mocks: { fetch: () => ({ body: { temp: 18 } }) },
  });
  assert.ok(ext.texts().includes("18°"));
  await ext.action("refresh");
  await ext.advance(60_000); // fake clock: fires due timers
});
```

Other helpers: `ext.view`, `ext.find(type, where?)`, `ext.emit(event, payload)`, `ext.command(name, data)`, `ext.setSettings(values)`, `ext.settle()`, `ext.storage`, `ext.requests`, `ext.notifications`, `ext.opened`, `ext.errors`. A mock that throws an error whose `name` is `NetworkError` / `TimeoutError` / `PermissionError` reaches the extension as that class. `permissions: []` tests the no-permission path. Write a test for each behaviour you add.

## Commands from outside

`adanotch ext <id> <name> [data]` or the URL `adanotch://ext/<id>/<name>?data=…` (Shortcuts) arrives as `events.on("command", ({ name, data }, ctx) => …)`; `data` is parsed as JSON when it is JSON.
