Skip to content

Writing a plug in

github-actions[bot] edited this page Oct 18, 2026 · 4 revisions

Writing a plug-in

A plug-in adds something to ThoughtKnot through one typed, versioned interface. The built-in exports use the same interface, so a plug-in is never second class.

This page describes main, which the nightly build is made from. The latest release is 0.7.0: ✅ is in it, 🌙 is only in the nightly build, 🟡 is being built and ⏳ is planned.

How it fits

flowchart LR
  P["Your plug-in<br/><i>:Plugin</i>"] -->|names in its manifest| M["Extension points<br/><i>:Manifest</i>"]
  M -->|is given only those| R["Plug-in registry<br/><i>:Registry</i>"]
  R -->|checks what it adds| K["Contract checks<br/><i>:Contract</i>"]
  R -->|offers it in| A["Every app<br/><i>:Host</i>"]
Loading
  1. Your plug-in names itself, its version, the interface version it was built for and the extension points it uses.
  2. The registry gives it those points and nothing else.
  3. Everything it adds is checked as it is added. If one thing is refused, none of it is installed.
  4. Once installed, what it adds is offered in every app: Obsidian, the browser editor and, later, the standalone app and VS Code.
  5. If your code throws while running, ThoughtKnot catches it and names your plug-in. A drawing is never left blank.

The interface is version 1. A plug-in built for another version is refused.

Extension points

Extension point What a plug-in adds through it
exports A data export: a format offered in Export, with its title, file extension, media type and writer.
theme Colours of its own, named under its id, in light and dark, each meeting the locked contrast.

Diagram types, graph steps and sources, storage, AI providers and commands join these one at a time, each with its own contract checks.

A first plug-in

This plug-in adds a plain text export: one line for each thing, then one for each link. The wiki's tests install this exact code, so it always works with the current interface.

import { PLUGIN_API, pluginRegistry } from '@thoughtknot/core';

const textList = {
  id: 'acme.text-list',
  version: '1.0.0',
  api: PLUGIN_API,
  uses: ['exports'],
  register: (points) => {
    points.exports.add({
      format: 'text-list',
      title: 'Text list',
      extension: 'txt',
      mime: 'text/plain',
      write: (data) =>
        [
          ...data.things.map(([id, kinds]) => `${id} (${kinds.join(', ')})`),
          ...data.links.map(([from, type, to]) => `${from} ${type} ${to}`),
        ].join('\n'),
    });
  },
};

const registry = pluginRegistry();
registry.register(textList);

The rules a plug-in is held to

  • Its id is dotted lower-case words, such as acme.text-list. Use your own name first, so no two plug-ins clash.
  • Its version is major.minor.patch.
  • Its writer gives the same text every time for the same graph, and never changes the graph it is given.
  • Its colours, if it adds any, are named --gn-plugin-<your-id>-<word>. Each gives a light and a dark colour that meets the locked contrast on the background it sits on.

Check your plug-in

Run the contract checks against your own plug-in in your tests. They need no test framework: each check throws, saying what was wanted, when something breaks a promise.

import { exporterContract } from '@thoughtknot/core';

for (const check of exporterContract(myExporter)) check.run();

Every data export (exporterContract):

  • ☑ it writes text for every export: none, a graph with notes, and a view with its faded context
  • ☑ it writes the same bytes each time for the same export
  • ☑ it leaves the export it is given as it was
  • ☑ it is offered under a title, with a file extension and a media type

Every theme colour (themeTokenContract):

  • ☑ it is named under its plug-in’s own prefix, so it takes no core token’s name and no other plug-in’s
  • ☑ it gives a colour, as #rrggbb or #rgb, for light and dark, on a core background
  • ☑ it meets the locked contrast in the light theme, on the background it sits on
  • ☑ it meets the locked contrast in the dark theme, on the background it sits on

Colours: the design tokens

A plug-in's styles may use any of the core's design tokens, so it follows the app's theme, light or dark, with no work. The app's own theme always wins: each token reads the app's own colours where it is shown.

Every design token
Token Kind What it is for
--gn-bg colour the background of a card, a pane or the page
--gn-bg-alt colour the background of a bar, a strip, a sheet or a control
--gn-bg-hover colour the background of a row the pointer is over
--gn-border colour the line around a card, a control or a section
--gn-border-hover colour that line where the pointer is over it
--gn-border-focus colour that line, or a ring, with the focus
--gn-text-normal colour body text, and the focus ring drawn in it
--gn-text-muted colour secondary text: captions, hints and counts
--gn-text-faint colour a mark that only accompanies text, such as an underline
--gn-text-error colour the text and mark of a problem
--gn-text-warning colour the text and mark of a warning
--gn-text-on-accent colour text on the accent colour
--gn-text-selection colour the background of selected text
--gn-accent colour the accent of a chosen or leading control
--gn-link colour a link to a note that exists
--gn-link-unresolved colour a link to a note that does not exist yet
--gn-hue-red colour the red of a mark that says a thing went wrong
--gn-hue-orange colour the orange of a mark that says a thing needs a look
--gn-hue-green colour the green of a mark that says a thing is well
--gn-code-background colour the background of a block of code
--gn-code-normal colour the text of code
--gn-code-keyword colour a keyword in highlighted code
--gn-code-important colour a label or type in highlighted code
--gn-code-operator colour an operator or arrow in highlighted code
--gn-code-value colour a number or constant in highlighted code
--gn-code-tag colour a variable in highlighted code
--gn-code-string colour a string in highlighted code
--gn-code-property colour a property name in highlighted code
--gn-code-function colour a function name in highlighted code
--gn-shadow-s depth the shadow of a card or a menu lifted a little
--gn-shadow-l depth the shadow of a sheet or a tooltip lifted over the page
--gn-layer-popover depth the stacking level of a menu or a notice
--gn-layer-tooltip depth the stacking level of a tooltip
--gn-layer-modal depth the stacking level of a full-screen view
--gn-font-ui type the typeface of the interface
--gn-font-mono type the typeface of code and source text
--gn-font-size-xs type the smallest text of the interface
--gn-font-size-s type the small text of the interface, for most controls
--gn-font-size-l type the large text of a heading in the interface
--gn-font-size-text-s type text a step smaller than the note’s own
--gn-code-size type the size of code
--gn-font-weight-bold type the weight of bold text
--gn-font-weight-semibold type the weight of a label that stands out
--gn-icon-xs spacing the size of a small icon
--gn-space-half-1 spacing the finest step of space, 2 pixels by default
--gn-space-half-2 spacing the second fine step of space, 4 pixels by default
--gn-space-1 spacing one step of space, 4 pixels by default
--gn-space-2 spacing two steps of space, 8 pixels by default
--gn-space-3 spacing three steps of space, 12 pixels by default
--gn-space-4 spacing four steps of space, 16 pixels by default
--gn-space-5 spacing five steps of space, 20 pixels by default
--gn-space-6 spacing six steps of space, 24 pixels by default
--gn-space-9 spacing nine steps of space, 36 pixels by default
--gn-radius-s corners the rounding of a small control’s corners
--gn-radius-m corners the rounding of a menu’s or a notice’s corners
--gn-radius-l corners the rounding of a card’s corners
--gn-cursor-link pointer the pointer over something that opens a note

Built in

These come with ThoughtKnot, added through the same interface:

Plug-in Version Uses Adds
thoughtknot.data-exports 1.0.0 exports GQL export, CSV export, GraphML export

Where it stands

Today plug-ins are built into ThoughtKnot. Loading one from outside, with its manifest limiting what it may reach, is being built under the requirements below.

flowchart LR
  s43(["§43 Plug-ins, layers and their contracts<br/><i>:Section</i>"])
  subgraph g_released["✅ Released"]
    affected_tests_by_imports["affected_tests_by_imports<br/><i>:Requirement</i>"]
  end
  s43 --- g_released
  subgraph g_part["🟡 Being built"]
    plugin_interface["plugin_interface<br/><i>:Requirement</i>"]
    plugin_manifest_limits["plugin_manifest_limits<br/><i>:Requirement</i>"]
    plugin_contract_suites["plugin_contract_suites<br/><i>:Requirement</i>"]
    hosts_pass_contract["hosts_pass_contract<br/><i>:Requirement</i>"]
  end
  s43 --- g_part
  subgraph g_open["⏳ Planned"]
    layers_checked["layers_checked<br/><i>:Requirement</i>"]
    plugin_rtl["plugin_rtl<br/><i>:Requirement</i>"]
  end
  s43 --- g_open
Loading
Requirement Where it stands Level Rule
plugin_interface 🟡 Being built must Diagram types, data exports, graph steps and sources, storage backends, AI providers and the commands and panels a plug-in adds must be reached through one typed, versioned plug-in interface, published with the library, and the built-in ones must register through it too.
plugin_manifest_limits 🟡 Being built must A plug-in must declare in a manifest the extension points and host abilities it uses and get nothing else, and a plug-in that throws or runs too long must be stopped and named, never leave a drawing blank.
plugin_contract_suites 🟡 Being built must Each extension point must ship a contract suite that any plug-in can run against itself, and every built-in must pass it in CI.
hosts_pass_contract 🟡 Being built must Every host's adapter, Obsidian's, the browser page's, the standalone app's and VS Code's, must pass the one shared host contract suite in CI, run against the real adapter.
layers_checked ⏳ Planned must Each layer must import only the layers below it, from the graph format library through the core, the display layer and the plug-ins to the hosts, checked in CI on every change.
affected_tests_by_imports ✅ Released must The quick test run must choose, besides the tests named after a changed module, every test that reaches it through the import graph, by importing it directly or through others or by naming it to build, and run the whole suite only for a change no test reaches or one every test shares.
plugin_rtl ⏳ Planned must Every plug-in must work in a right-to-left language: what it adds reads in the host's direction, keeps the person's names in their own direction and uses logical sides only, checked by its extension point's contract suite (Roger, 18 Oct 2026).

Clone this wiki locally