# Building a Tintero plugin

Instructions for a coding agent. Everything here is read out of the Tintero source, so it
describes the plugin system as it behaves now rather than as somebody once wrote it down.

**This is the short version.** It covers what you need to build a working plugin and leaves
out the internals. When you need more detail than this file has, the full reference
is at https://developer.tintero.app, and the authoritative types are in `tintero-plugin-sdk.d.ts`
(https://developer.tintero.app/tintero-plugin-sdk.d.ts). Download that file into the project you are working
in: it describes the whole API and the manifest, so a mistake becomes a compile error rather
than something that breaks in somebody else's hands.

## What a plugin is

A folder with two required files, zipped:

```text
my-plugin/
  plugin.json    who you are, where you appear, what you may touch
  plugin.js      your code
  plugin.css     optional, loaded for you when main ends in .js
  icon.svg       optional, SVG only
```

Your code runs inside a sealed iframe. It cannot reach the app's own page, cannot use
cookies or localStorage, and cannot make network requests itself. Everything goes through a
global called `tintero`, which sends messages to the app, and the app checks your
permissions on every single call.

## Rules that are easy to get wrong

These are the mistakes that give you a plugin which installs and then does not work. Read
them before writing anything.

1. **`project.write.fileContent` does not let you create files.** It covers rewriting an
   existing file or document. Creating one needs `project.write.files` (for `addFile()`)
   or `project.write.docs` (for `addDoc()`).
2. **A plugin with `"type": "app"` does not need the `ui.sidebar` permission** to appear in
   the sidebar menu. Only the `sidebar` surface requires it, and there the installer refuses
   the manifest without it. The one other thing that permission covers is `ui.render()`.
3. **Icons must be SVG.** Only .js, .html, .json, .css, .svg files survive
   installation. A `.png` icon is extracted from your zip and
   then never written. The install succeeds, the app console says what it skipped, and your
   plugin shows the default icon.
4. **Your stylesheet is only loaded when `main` ends in `.js`.** Name your entry point
   anything else and `plugin.css` never loads, with no error to tell you.
5. **Document content is ProseMirror JSON handed to you as a string**, not HTML and not
   plain text. `updateFileContent()` wants a string back, so `JSON.stringify()` whatever
   you built.
6. **`net.fetch` does not follow redirects** (`redirect: 'error'`). The
   list of allowed hosts is only checked against the URL you ask for, so a redirect is an
   error rather than a hop. Ask for final URLs.
7. **The dialog protocol is raw `postMessage`.** `dialog-close`, `dialog-init`,
   `dialog-ready` and `dialog-opened` have no wrapper on `tintero` and are not in the type
   definitions. Use the literal message shapes shown below.
8. **Call `registerPlugin()` exactly once**, at the end of your script. A second call logs
   a warning and is ignored.
9. **Every path you write stays inside your plugin folder.** Absolute paths, `..` segments
   and drive letters are rejected at install time.

## A working plugin, start to finish

`plugin.json`:

```json
{
  "id": "com.example.word-count",
  "name": "Word Count",
  "version": "1.0.0",
  "description": "A live word count for the document you are writing",
  "author": { "name": "Your Name" },
  "license": "MIT",
  "type": "sidebar-panel",
  "surfaces": ["sidebar"],
  "main": "plugin.js",
  "scopes": ["ui.sidebar", "editor.read"],
  "ui": { "sidebar": { "label": "Words", "tooltip": "Live word count", "width": 260 } }
}
```

`plugin.js`:

```javascript
(function () {
  const plugin = new TinteroPlugin();

  plugin.onActivate = async function () {
    document.body.innerHTML =
      '<div style="padding:10px">' +
      '<div id="count" style="font-size:28px;color:var(--accent-color,#c98a48)">0</div>' +
      '<div style="color:var(--text-secondary,#a98e6b);font-size:12px">words</div>' +
      '</div>';

    const refresh = async () => {
      const words = await tintero.editor.getWordCount();
      document.getElementById('count').textContent = words;
    };

    await refresh();
    tintero.events.on('editor.activeDocumentChanged', refresh);
  };

  registerPlugin(plugin);
})();
```

Package it:

```bash
cd my-plugin && zip -FSr ../my-plugin.zip .
```

The user installs it with **Settings → Plugins → Load local plugin (dev)**.

### Lifecycle

`registerPlugin(plugin)` triggers `onActivate()`. After that `onProjectChange()` fires
whenever project data changes, and `onDeactivate()` runs shortly before the frame goes away.
Those three are the only hooks the SDK calls. Save anything worth keeping in
`onDeactivate()` with `tintero.storage`, because your variables do not survive.

## Manifest reference

Required. The installer rejects the plugin if any are missing.

| Field | Type |
|-------|------|
| `id` | `string` |
| `name` | `string` |
| `version` | `string` |
| `description` | `string` |
| `author` | `PluginManifestAuthor` |
| `type` | `PluginType` |
| `main` | `string` |
| `scopes` | `PluginScopeString[]` |

Optional.

| Field | Type |
|-------|------|
| `license` | `string` |
| `minTinteroVersion` | `string` |
| `surfaces` | `PluginSurface[]` |
| `capabilities` | `PluginCapability[]` |
| `icon` | `string` |
| `shortcut` | `string` |
| `ui` | `PluginManifestUI` |
| `settings` | `{ schema: Record<string, PluginSettingDefinition>; }` |
| `import` | `PluginManifestImport` |
| `export` | `PluginManifestExport` |
| `network` | `PluginManifestNetwork` |

`id` must match `/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/`: lowercase letters, digits, dots and
hyphens, starting and ending with a letter or digit. It is also your plugin's directory
name and its storage bucket.

Nested shapes:

- **PluginManifestAuthor**: `name: string`, `url?: string`
- **PluginManifestSidebarUI**: `panel?: string`, `label: string`, `tooltip?: string`, `width?: number`
- **PluginManifestUI**: `sidebar?: PluginManifestSidebarUI`, `settings?: string`, `dialog?: string`
- **PluginManifestImport**: `extensions: string[]`, `formatName: string`, `formatDescription?: string`
- **PluginManifestExport**: `formatName: string`, `extension: string`, `mimeType?: string`, `formatDescription?: string`
- **PluginManifestNetwork**: `domains: string[]`
- **PluginSettingDefinition**: `type: PluginSettingType`, `label: string`, `description?: string`, `required?: boolean`, `secret?: boolean`, `default?: any`, `min?: number`, `max?: number`, `options?: string[]`

Setting types: `string`, `number`, `boolean`, `select`.

Plugin types: `sidebar-panel`, `app`, `file-importer`, `file-exporter`, `project-importer`, `project-exporter`, `book-exporter`, `tool`.
Surfaces: `sidebar`, `app`, `background`, `dialog`.
Capabilities: `file-import`, `file-export`, `project-import`, `project-export`, `book-export`.

The installer rejects a manifest with any of these messages:

- Missing or invalid "id"
- "id" must contain only lowercase letters, numbers, dots and hyphens, and start and end with a letter or number
- Missing or invalid "name"
- Missing or invalid "version"
- Missing or invalid "description"
- Missing or invalid "author" (requires at least "name")
- Missing or invalid "main" entry point
- Invalid "type": "…". Must be one of: …
- Missing or invalid "scopes" array
- Invalid scopes: …
- Plugins with a "sidebar" surface must include the "ui.sidebar" scope
- Importer plugins must include "import" configuration

And warns about:

- No license specified
- No icon specified; a default icon will be used
- Exporter plugins should include "export" configuration

## Permissions

Listed in `scopes`, approved once at install, checked again on every call. Ask for the
minimum: the install dialog shows people exactly what you asked for, grouped by the risk
levels below.

**low risk** (27): `app.settings.read`, `convert.format`, `debug.console`, `editor.read`, `fs.platform`, `project.read`, `project.read.analytics`, `project.read.cardboards`, `project.read.characters`, `project.read.collections`, `project.read.docContent`, `project.read.docs`, `project.read.fileContent`, `project.read.files`, `project.read.flowmaps`, `project.read.images`, `project.read.notes`, `project.read.plotgrid`, `project.read.scenes`, `project.read.snapshots`, `project.read.tags`, `project.read.templates`, `project.read.timelines`, `project.read.worldbuilding`, `settings`, `storage`, `ui.notification`

**medium risk** (10): `backup.create`, `backup.list`, `export.file`, `fs.read`, `import.file`, `media.control`, `ui.contextMenu`, `ui.dialog`, `ui.sidebar`, `ui.window`

**high risk** (18): `app.settings.write`, `backup.restore`, `editor.write`, `export.book`, `export.project`, `fs.write`, `import.project`, `net.fetch`, `project.write.cardboards`, `project.write.characters`, `project.write.docs`, `project.write.fileContent`, `project.write.files`, `project.write.images`, `project.write.notes`, `project.write.plotgrid`, `project.write.tags`, `project.write.worldbuilding`

Some permissions bring others with them, so asking for both only makes your dialog longer:

| Declaring | also grants |
|-----------|-------------|
| `export.file` | `project.read.fileContent` |
| `import.file` | `project.write.fileContent` |
| `app.settings.write` | `app.settings.read` |
| `editor.write` | `editor.read` |
| `export.book` | `project.read.fileContent`, `project.read.files` |
| `export.project` | `project.read`, `project.read.characters`, `project.read.worldbuilding`, `project.read.files`, `project.read.fileContent`, `project.read.docs`, `project.read.docContent`, `project.read.notes`, `project.read.plotgrid`, `project.read.cardboards`, `project.read.images`, `project.read.collections`, `project.read.tags`, `project.read.analytics`, `project.read.timelines`, `project.read.flowmaps`, `project.read.templates`, `project.read.snapshots`, `project.read.scenes` |
| `import.project` | `project.write.fileContent`, `project.write.files` |

Valid in a manifest, but they unlock nothing today: `ui.contextMenu`, `project.write.cardboards`, `project.write.plotgrid`.

## Complete API

97 methods. Every one is asynchronous and returns a promise. A call your
approved scopes do not cover rejects with `SCOPE_DENIED` rather than failing silently.

No permission required (handled inside the SDK, never crosses the bridge): `events.on(event: PluginEvent, callback: EventCallback)`, `events.off(event: PluginEvent, callback: EventCallback)`.

### tintero.app

| Method | Returns | Requires |
|--------|---------|----------|
| `getSettings()` | `Promise<AppSettings>` | `app.settings.read` |
| `getSettingsField(path: string)` | `Promise<any \| null>` | `app.settings.read` |
| `updateSettings(changes: Partial<Pick<AppSettings, 'generalSettings' \| 'trophySettings' \| 'hideSettings' \| 'editorSettings' \| 'editorToolbarSettings'>>)` | `Promise<void>` | `app.settings.write` |

### tintero.backup

| Method | Returns | Requires |
|--------|---------|----------|
| `create(name?: string, observations?: string)` | `Promise<string>` | `backup.create` |
| `getById(id: string)` | `Promise<BackupEntry \| null>` | `backup.list` |
| `list()` | `Promise<BackupEntry[]>` | `backup.list` |
| `restore(id: string)` | `Promise<void>` | `backup.restore` |

### tintero.convert

| Method | Returns | Requires |
|--------|---------|----------|
| `fromHtml(html: string)` | `Promise<ProseMirrorDocument>` | `convert.format` |
| `fromMarkdown(markdown: string)` | `Promise<ProseMirrorDocument>` | `convert.format` |
| `fromText(text: string)` | `Promise<ProseMirrorDocument>` | `convert.format` |
| `toHtml(json: ProseMirrorDocument \| string)` | `Promise<string>` | `convert.format` |
| `toMarkdown(json: ProseMirrorDocument \| string)` | `Promise<string>` | `convert.format` |
| `toText(json: ProseMirrorDocument \| string)` | `Promise<string>` | `convert.format` |

### tintero.debug

| Method | Returns | Requires |
|--------|---------|----------|
| `clear()` | `Promise<void>` | `debug.console` |
| `getLogs()` | `Promise<ConsoleEntry[]>` | `debug.console` |

### tintero.editor

| Method | Returns | Requires |
|--------|---------|----------|
| `getActiveDocument()` | `Promise<OpenDocument \| null>` | `editor.read` |
| `getOpenDocuments()` | `Promise<OpenDocument[]>` | `editor.read` |
| `getSelection()` | `Promise<EditorSelection \| null>` | `editor.read` |
| `getWordCount()` | `Promise<number>` | `editor.read` |
| `insertAt(position: number, html: string)` | `Promise<void>` | `editor.write` |
| `replaceRange(from: number, to: number, html: string)` | `Promise<void>` | `editor.write` |
| `replaceSelection(html: string)` | `Promise<void>` | `editor.write` |

### tintero.export

| Method | Returns | Requires |
|--------|---------|----------|
| `exportFile(fileName: string, content: string, mimeType?: string)` | `Promise<void>` | `export.file` |
| `registerBookExporter(config: BookExporterConfig)` | `Promise<void>` | `export.book` |
| `registerExporter(config: FileExporterConfig)` | `Promise<void>` | `export.file` |
| `registerProjectExporter(config: ProjectExporterConfig)` | `Promise<void>` | `export.project` |

### tintero.fs

| Method | Returns | Requires |
|--------|---------|----------|
| `deleteProjectFile(location: string)` | `Promise<void>` | `fs.write` |
| `getPlatform()` | `Promise<string>` | `fs.platform` |
| `readProjectFile(location: string)` | `Promise<string \| null>` | `fs.read` |
| `saveProject()` | `Promise<void>` | `fs.write` |
| `writeProjectFile(location: string, content: string)` | `Promise<void>` | `fs.write` |

### tintero.import

| Method | Returns | Requires |
|--------|---------|----------|
| `registerImporter(config: FileImporterConfig)` | `Promise<void>` | `import.file` |
| `registerProjectImporter(config: ProjectImporterConfig)` | `Promise<void>` | `import.project` |

### tintero.media

| Method | Returns | Requires |
|--------|---------|----------|
| `getNowPlaying()` | `Promise<MediaNowPlaying \| null>` | `media.control` |
| `getState()` | `Promise<'idle' \| 'playing' \| 'paused' \| 'buffering' \| 'ended'>` | `media.control` |
| `next()` | `Promise<void>` | `media.control` |
| `pause()` | `Promise<void>` | `media.control` |
| `play(source: string)` | `Promise<void>` | `media.control` |
| `previous()` | `Promise<void>` | `media.control` |
| `resume()` | `Promise<void>` | `media.control` |
| `setVolume(volume: number)` | `Promise<void>` | `media.control` |
| `stop()` | `Promise<void>` | `media.control` |

### tintero.net

| Method | Returns | Requires |
|--------|---------|----------|
| `fetch(url: string, options?: NetFetchOptions)` | `Promise<NetFetchResponse>` | `net.fetch` |

### tintero.project

| Method | Returns | Requires |
|--------|---------|----------|
| `addCharacter(data: CharacterInput)` | `Promise<Character>` | `project.write.characters` |
| `addDoc(data: DocInput)` | `Promise<DocMetadata>` | `project.write.docs` |
| `addFile(data: FileInput)` | `Promise<FileMetadata>` | `project.write.files` |
| `addImage(data: string, fileName?: string)` | `Promise<string>` | `project.write.images` |
| `addNote(data: NoteInput)` | `Promise<Note>` | `project.write.notes` |
| `addWorldbuildingElement(data: WorldbuildingInput)` | `Promise<WorldbuildingElement>` | `project.write.worldbuilding` |
| `getCardboards()` | `Promise<CardboardGrid[]>` | `project.read.cardboards` |
| `getCharacterById(id: string)` | `Promise<Character \| null>` | `project.read.characters` |
| `getCharacters()` | `Promise<Character[]>` | `project.read.characters` |
| `getCollections()` | `Promise<Collection[]>` | `project.read.collections` |
| `getCustomWorldbuildingTemplates()` | `Promise<CustomWorldbuildingTemplate[]>` | `project.read.templates` |
| `getDocContent(docId: string)` | `Promise<string \| null>` | `project.read.docContent` |
| `getDocs()` | `Promise<DocMetadata[]>` | `project.read.docs` |
| `getFileContent(fileId: string)` | `Promise<string \| null>` | `project.read.fileContent` |
| `getFileSnapshots(fileId: string)` | `Promise<FileSnapshot[]>` | `project.read.snapshots` |
| `getFiles()` | `Promise<FileMetadata[]>` | `project.read.files` |
| `getFlowMaps()` | `Promise<FlowMap[]>` | `project.read.flowmaps` |
| `getFolders()` | `Promise<FolderInfo[]>` | `project.read.files` |
| `getImageData(imageRef: string)` | `Promise<string \| null>` | `project.read.images` |
| `getImages()` | `Promise<ImageInfo[]>` | `project.read.images` |
| `getMetadata()` | `Promise<ProjectMetadata>` | `project.read` |
| `getNotes()` | `Promise<Note[]>` | `project.read.notes` |
| `getPlotGrids()` | `Promise<PlotGrid[]>` | `project.read.plotgrid` |
| `getScenes(fileId: string)` | `Promise<Scene[]>` | `project.read.scenes` |
| `getTags()` | `Promise<string[]>` | `project.read.tags` |
| `getTimelines()` | `Promise<Timeline[]>` | `project.read.timelines` |
| `getWordCountLog()` | `Promise<WordCountLog[]>` | `project.read.analytics` |
| `getWorldbuilding()` | `Promise<WorldbuildingElement[]>` | `project.read.worldbuilding` |
| `getWorldbuildingByType(type: string)` | `Promise<WorldbuildingElement[]>` | `project.read.worldbuilding` |
| `getWritingGoals()` | `Promise<WritingGoal[]>` | `project.read.analytics` |
| `getWritingMinutesLog()` | `Promise<WritingMinutesLog[]>` | `project.read.analytics` |
| `removeWorldbuildingElement(id: string)` | `Promise<void>` | `project.write.worldbuilding` |
| `updateCharacter(id: string, data: Partial<CharacterInput>)` | `Promise<void>` | `project.write.characters` |
| `updateDocContent(docId: string, jsonContent: string)` | `Promise<void>` | `project.write.fileContent` |
| `updateDocMeta(id: string, data: DocMetaUpdate)` | `Promise<void>` | `project.write.docs` |
| `updateFileContent(fileId: string, jsonContent: string)` | `Promise<void>` | `project.write.fileContent` |
| `updateFileMeta(id: string, data: FileMetaUpdate)` | `Promise<void>` | `project.write.files` |
| `updateTags(tags: string[])` | `Promise<void>` | `project.write.tags` |
| `updateWorldbuildingElement(id: string, data: Partial<WorldbuildingInput>)` | `Promise<void>` | `project.write.worldbuilding` |

### tintero.settings

| Method | Returns | Requires |
|--------|---------|----------|
| `get()` | `Promise<Record<string, any>>` | `settings` |
| `getField(key: string)` | `Promise<any \| null>` | `settings` |

### tintero.storage

| Method | Returns | Requires |
|--------|---------|----------|
| `get(key: string)` | `Promise<any \| null>` | `storage` |
| `getAll()` | `Promise<Record<string, any>>` | `storage` |
| `remove(key: string)` | `Promise<void>` | `storage` |
| `set(key: string, value: any)` | `Promise<void>` | `storage` |

### tintero.ui

| Method | Returns | Requires |
|--------|---------|----------|
| `closeDialog()` | `Promise<void>` | `ui.dialog` |
| `hideSidebar()` | `Promise<void>` | `ui.window` |
| `isFullscreen()` | `Promise<boolean>` | `ui.window` |
| `openDialog(options?: DialogOptions)` | `Promise<void>` | `ui.dialog` |
| `render(html: string)` | `Promise<void>` | `ui.sidebar` |
| `showNotification(message: string, type?: NotificationType, durationMs?: number)` | `Promise<void>` | `ui.notification` |
| `showSidebar()` | `Promise<void>` | `ui.window` |
| `toggleFullscreen()` | `Promise<void>` | `ui.window` |
| `toggleSidebar()` | `Promise<void>` | `ui.window` |

### Error codes

- `SCOPE_DENIED`
- `RATE_LIMITED`
- `PAYLOAD_TOO_LARGE`
- `NETWORK_DENIED`
- `UNKNOWN`

The code appears in the error message, so `error.message.includes('SCOPE_DENIED')` works.

## Events

Subscribe with `tintero.events.on(event, callback)`. No permission is needed. Handlers do
not survive a reload, so subscribe inside `onActivate()` every time, and guard against
subscribing twice if `onActivate` can run more than once.

| Event | What arrives |
|-------|--------------|
| `project.loaded` | `projectId`, `projectName` |
| `project.saved` | `projectId` |
| `project.changed` | nothing |
| `file.opened` | `fileId`, `fileName` |
| `file.saved` | `fileId`, `fileName` |
| `file.closed` | `fileId` |
| `character.added` | `characterId`, `characterName` |
| `character.updated` | `characterId`, `characterName` |
| `character.deleted` | `characterId` |
| `worldbuilding.added` | `elementId`, `elementType` |
| `worldbuilding.updated` | `elementId`, `elementType` |
| `worldbuilding.deleted` | `elementId` |
| `editor.selectionChanged` | `documentId: string`, `from: number`, `to: number`, `empty: boolean` |
| `editor.activeDocumentChanged` | `documentId: string`, `name: string \| null` |
| `storage.changed` | `key?: string` |
| `debug.log` | an object, see the note below |

`debug.log` is the one that hands you a whole object rather than named fields: your callback
gets a single `ConsoleEntry`, the same shape `tintero.debug.getLogs()` returns a list of.

`storage.changed` fires on your plugin's *other* surfaces, never on the one that did the
writing. That is what makes it usable for keeping two surfaces in step without looping.

Declared in the type definitions but fired by nothing in the app: `plugin.activated`, `plugin.deactivated`. Subscribing to one of these does nothing, so do not build on it.

## Working with document content

File and document content is ProseMirror JSON, delivered as a string.

```javascript
const raw = await tintero.project.getFileContent(fileId);   // string, or null
const markdown = await tintero.convert.toMarkdown(raw);     // also toText, toHtml

const document = await tintero.convert.fromMarkdown('# New\n\nHello.');
await tintero.project.updateFileContent(fileId, JSON.stringify(document));
```

The `to*` converters take the raw string or an object you already parsed. The `from*`
converters hand back an object, and the write methods want a string, which is why the
`JSON.stringify()` is there. Forgetting it is the most common mistake in this API.

Content is checked before anything is written, so a bad write fails and leaves the file
exactly as it was.

The editor calls are not the project calls: `tintero.project` reads what is saved,
`tintero.editor` reads what is on screen right now including unsaved changes. The editor
write methods take **HTML**, not ProseMirror JSON, because they work on the live editor.

## Surfaces

A surface is a place your plugin can appear. List them in `surfaces`, and also set `type`,
which is the older single-slot version of the same thing and is still required.

- `sidebar`: the narrow panel. Requires the `ui.sidebar` permission.
- `app`: the full main view. Your frame fills the area and gets no padding.
- `background`: no interface, starts with the app. What importers, exporters and quiet
  background tools use.
- `dialog`: a window your plugin opens with `ui.openDialog()`.

If `surfaces` is missing, Tintero works it out from `type`: `sidebar-panel` becomes `sidebar`; `app` becomes `app`; `tool` becomes `background`; `file-importer` becomes `background`; `file-exporter` becomes `background`; `project-importer` becomes `background`; `project-exporter` becomes `background`; `book-exporter` becomes `background`.

A plugin can list several surfaces. Each gets its own frame running its own copy of your
code, so they share no variables. Branch on `tintero.surface`, and use `tintero.storage`
plus the `storage.changed` event to keep them in step.

Tintero gives you an empty `plugin-root` element to mount into. Frameworks should render
into it, creating it if it is missing so the same bundle still runs outside the app.

Despite its name, `ui.sidebar.panel` is loaded on every surface your plugin runs on, the
full app view included. That is where a built single-page app points at its `index.html`.

## Dialogs

Requires `ui.dialog`. Two modes:

**Shell dialog.** Declare nothing extra, open the window, then push markup into it.

```javascript
await tintero.ui.openDialog({ title: 'Pick', width: 520, height: 380 });
await tintero.ui.render('<div>hello</div>');
```

Note where `ui.render()` ends up: while a dialog is open it fills the dialog, and
otherwise it fills the sidebar panel.

**File dialog.** Point `ui.dialog` at an HTML file at the top of your plugin folder. Files
named after it (`dialog.js`, `dialog.css`) come along automatically. `<script src>` and
`<link rel="stylesheet">` tags are stripped out, because the dialog runs under
`default-src 'none'` and could never have loaded them.

`openDialog()` returns as soon as the window exists and does not wait for anybody. To close
from inside the dialog and hand a value back, post the message Tintero listens for:

```javascript
parent.postMessage({ type: 'dialog-close', result: { name: value } }, '*');
```

Data passed as `data` to `openDialog()` arrives as `window.__DIALOG_INIT_DATA__` and also
as a `dialog-init` message.

Dialogs default to 600 by 400 pixels and
are capped at 95vw and 90vh. Opening a second
dialog closes the first.

## Network access

Your frame cannot reach the network, so requests go out through the app, and only to hosts
you list.

```json
{
  "scopes": ["net.fetch"],
  "network": { "domains": ["api.example.com", "*.cdn.example.com"] }
}
```

Exact hosts, or a wildcard on the front for subdomains. A bare `"*"` is rejected at install,
and asking for `net.fetch` with no `network.domains` fails the install outright.

Protocols: http and https. Methods:
GET, POST, PUT, PATCH, DELETE, HEAD. Responses are capped at
10 MB and time out after 20 s.
Redirects are not followed.

## Runtime limits

| Limit | Value | On breach |
|-------|-------|-----------|
| API calls | 100 per 1 s | `RATE_LIMITED` |
| Call arguments | 5 MB | `PAYLOAD_TOO_LARGE` |
| Call duration | 30 s | the promise rejects |
| Heartbeat | ping every 15 s | the SDK answers for you |
| Missed heartbeats | more than 3 in a row (the 4th) | the iframe is destroyed |
| Error budget | more than 50 per 60 s (the 51st) | the plugin is disabled |
| `net.fetch` response | 10 MB, 20 s | `NETWORK_DENIED` |

Ordinary code never gets near the call limits. The one thing that does is a loop over every
file in a large project: read what you need, keep it in `tintero.storage`, and refresh when
`project.changed` fires rather than on a timer.

## Packaging

```bash
cd my-plugin && zip -FSr ../my-plugin.zip . -x '*.git*' 'node_modules/*'
```

Files are read from the top of the zip. One wrapping folder is fine. Only
.js, .html, .json, .css, .svg entries are written during installation.

If you use a bundler, remember your plugin is pasted in as script text rather than served as
files, so it all has to end up in one file: no code splitting, no dynamic imports of your own
chunks, no assets referenced by URL. With Vite, set `entryFileNames: 'plugin.js'`,
`cssCodeSplit: false`, and `manualChunks: undefined`, then either emit `plugin.css`
alongside (Tintero injects it) or inline the CSS into the bundle.

## Before you finish

- Every method called has a matching scope in `scopes`, and every scope declared is used.
- `id` matches the required pattern, `main` ends in `.js`, `icon` is `.svg`.
- `registerPlugin()` is called exactly once.
- `network.domains` lists concrete hosts if `net.fetch` is requested.
- `onDeactivate()` persists state and clears timers.
- Handles a project with nothing open: `getFileContent()` returns `null` for an empty file,
  and there may be no active document at all.
- `license` is set, because leaving it out shows a warning at install.

## Where to look when this file is not enough

- Full documentation: https://developer.tintero.app
- Type definitions, download into your project: https://developer.tintero.app/tintero-plugin-sdk.d.ts
- API reference, every method with its permission: https://developer.tintero.app/reference/api/
- Permission reference, what each one unlocks: https://developer.tintero.app/reference/permissions/
- Manifest reference: https://developer.tintero.app/reference/manifest/
- Events: https://developer.tintero.app/reference/events/
- Security model and limits: https://developer.tintero.app/security/
- Surfaces: https://developer.tintero.app/guides/surfaces/
- Dialogs: https://developer.tintero.app/guides/dialogs/
- Document content: https://developer.tintero.app/guides/content/
- Bundlers and frameworks: https://developer.tintero.app/guides/frameworks/
- Example plugins that ship: https://developer.tintero.app/guides/examples/
