Plugin Anatomy

Every file of a plugin, in a real example.

Every file of packages/plugins/character-counter, the reference plugin. It shows 120/2000 above the composer while you type. Code blocks marked with a path are copied from it, and a test fails when a copy drifts.

Files

  • character-counter
    • manifest.jsonId, name, authors, version
    • package.jsonDependencies and scripts
    • rollup.config.mjsHands the build to ubd
    • tsconfig.jsonMaps ~/ to src/, loads API types
    • bunfig.tomlLoads the testing preset
    • settings.tsSettings; the build draws the panel
    • i18n
      • en-US.jsonEvery string. Required; the fallback language
    • src
      • index.tsxThe entry: lists the features
      • patches.tsxThe one feature: patchChatInput
      • unbound-settings.d.tsGenerated. Commit it
      • unbound-i18n.d.tsGenerated. Commit it
      • components
        • character-counter.tsxThe pill
        • use-draft-length.tsHook that reads the draft
      • libOwn logic, no patches
        • limit.tsLimit and tone
        • colors.tsTheme colors
        • constants.tsConstants shared across files
    • tests
      • lib
        • limit.test.tsPure logic
        • colors.test.tsPure logic
      • character-counter.test.tsxThe started plugin, rendered
    • ADDON.mdThe addon page
    • CHANGELOG.mdChanges per version

A plugin with images adds assets/; an addon page with a banner adds media/. Git ignores dist/ and node_modules/. Project Rules lists the layout rules.

Root Files

manifest.json

main names the entry inside src/. icon names one of Discord's icons. unbound.min is the oldest Unbound client the plugin runs on; older clients keep the version they have. See The Manifest.

manifest.json
{
	"id": "unbound.character-counter",
	"name": "Character Counter",
	"description": "Shows the current message length and Discord's character limit in the composer.",
	"authors": [
		{
			"name": "thororen",
			"id": "848339671629299742"
		},
		{
			"name": "creations",
			"id": "209830981060788225"
		},
		{
			"name": "adrian",
			"id": "224617799434108928"
		}
	],
	"version": "1.0.5",
	"unbound": {
		"min": "3.0.0"
	},
	"icon": "TextIcon",
	"main": "index.tsx"
}

Never write platforms or Capabilities. ubd build derives them and fails if you add them by hand.

Build Config

unbound reads the manifest, bundles src/, generates the settings and i18n modules, and runs the build checks.

rollup.config.mjs
import { unbound } from '@unbound-app/cli/rollup';

export default unbound(import.meta.url);
FileHolds
package.json"type": "module", build and test scripts. devDependencies: @unbound-app/api, @types/react, react-native, @unbound-app/cli. First-party plugins also depend on @unbound-plugins/shared; third-party plugins write those helpers themselves
tsconfig.jsontypes: ["bun", "@unbound-app/api/global"] for the scope globals such as logger and addon. Maps ~/* to ./src/*, which the build also resolves. Extends the workspace ../tsconfig.json and includes src, tests and settings.ts
bunfig.tomlPreloads the testing preset, which stands in for Unbound, Discord and the build. See Writing Tests
bunfig.toml
[test]
# Serves the plugin's manifest, translations, settings and a fake Unbound API to every test.
preload = ["@unbound-app/api/testing/preset"]

The Entry

src/index.tsx lists the features and holds no logic: no ifs, no state.

src/index.tsx
import { patcher } from '@unbound-app/api';

import { patchChatInput } from '~/patches';

export function start() {
	patchChatInput();
}

export function stop() {
	patcher.unpatchAll();
}
ExportDoes
startCalls each feature in order. A feature checks its own needs, so start never branches
stoppatcher.unpatchAll() removes this plugin's patches only. Then undo what the patcher does not own (timers kept outside the scope, outside listeners, store redraws, saved state), in the order it matters

A feature that throws is a bug: Unbound logs the failed start and disposes the scope. See Lifecycle for what stop still undoes.

The device matrix reads a self-test after a restart. Unbound builds it from the warnings and errors the plugin's logger wrote during start: the first one fails it, with that line as the detail. Export selfTest only to check more, such as that something is drawn on screen.

Folder Layout

Never deeper than src/<folder>/<file>, except platform code in src/<folder>/ios/<file> and src/<folder>/android/<file>, such as native/ios/views.ts or lib/android/colors.ts. At the top level, at most:

EntryHolds
index.tsx, settings.tsxThe entry, and a settings panel when the plugin draws its own
patches.ts, commands.tsEach kind in one file while it is small
native/hooks.ts, views.ts, worklets.ts, calls.ts; ios/ and android/ for platform-native files
flux/interceptors.ts, listeners.ts
components/React components and their hooks
lib/The plugin's own logic: data, network, storage, helpers. No Discord patches; tests reach it directly

A file that grows too big becomes a folder of named files: patches.ts becomes patches/message-rows.ts, native/worklets.ts becomes native/android/worklets.ts and native/ios/worklets.ts, selected in native/worklets.ts with definePlatform. A platform split always uses the folders, never -ios or -android file names. Function names carry their kind, patchX, interceptX, listenToX, registerX, hookX or drawX, so a file moves without renames.

A Feature

A feature is one function. It checks its own needs first, and each failed check returns early:

  1. A setting that is off returns silently.
  2. A missing module logs one logger.warn naming the feature and what moved, then returns.
  3. Then it patches. No unpatch variables: stop removes every patch at once.

Character Counter has one feature, patchChatInput. The JSDoc names what it patches and reads.

src/patches.tsx
import { cloneElement, isValidElement, type ReactNode } from 'react';
import { flux, metro, patcher } from '@unbound-app/api';

import { CharacterCounter } from '~/components/character-counter';

type ContainerProps = { channel?: { id?: string }; channelId?: string };

/**
 * @description Shows the counter above the composer without changing the composer's layout, by patching the render of
 * Discord's `FloatingChatInputContainer`; the counter reads the draft from `ChatInputs`.
 */
export function patchChatInput(): void {
	const ChatInputs = metro.findByProps('getBestActiveInputForChannelId');
	const FloatingChatInputContainer = metro.findByFilePath(
		'modules/chat_input/native/FloatingChatInputContainer.tsx',
	);

	if (!ChatInputs || !FloatingChatInputContainer) {
		logger.warn('Counter skipped: Discord moved ChatInputs or FloatingChatInputContainer.');

		return;
	}

	const SelectedChannelStore = flux.getStore('SelectedChannel');

	patcher.react.after(FloatingChatInputContainer, 'default', ({ props, result }) => {
		const container: ContainerProps = props;
		const channelId =
			container.channel?.id ?? container.channelId ?? SelectedChannelStore.getChannelId();

		if (!channelId || !isValidElement<{ children?: ReactNode }>(result)) return;

		return cloneElement(
			result,
			{},
			<>
				{result.props.children}
				<CharacterCounter
					key='character-counter'
					channelId={channelId}
					inputs={ChatInputs}
				/>
			</>,
		);
	});
}
HabitWhy
Lookups inside the featureA lazy lookup returns undefined when this Discord build is known to lack the module, so the check is a plain if. A feature that is off makes no lookups
Patch 'default' on the moduleReading the component itself would load the module at start
Add a child, keep childrenOther plugins may patch the same component
Pass handles down as propsThe component needs no lookup of its own. Only handles several features share go in a modules.ts

flux.getStore is typed and never undefined. Prefer metro.api, metro.common and metro.stores when they hold what you need; the component reads metro.common.Theme. When Discord renames a module, the fix is one line at the top of the feature. See Finding Modules.

lib/limit.ts

Decision logic goes in lib/, in plain files with no API imports, so tests reach it directly. lib/colors.ts does the same for theme colors.

src/lib/limit.ts
import type { FluxUser } from '@unbound-app/api/flux';

export type CounterTone = 'primary' | 'muted' | 'yellow' | 'orange' | 'red';

/**
 * @description Discord's message length limit for a user.
 * @param user The signed-in user; a `premiumType` of `2` is Nitro.
 * @returns 4000 for Nitro, otherwise 2000.
 */
export function characterLimit(user: Pick<FluxUser, 'premiumType'> | undefined): number {
	return user?.premiumType === 2 ? 4000 : 2000;
}

/**
 * @description Picks the counter's color as the message approaches the limit.
 * @param length The message length.
 * @param limit The user's message length limit.
 * @param colorEffects Whether the warning colors are turned on.
 * @returns `primary` when warnings are off, otherwise a tone for the share of the limit used.
 */
export function counterTone(length: number, limit: number, colorEffects: boolean): CounterTone {
	if (!colorEffects) return 'primary';

	const used = length / limit;

	if (used < 0.5) return 'muted';
	if (used < 0.75) return 'yellow';
	if (used < 0.9) return 'orange';

	return 'red';
}

components

The pill reads the draft, the Nitro tier and the user's setting:

src/components/character-counter.tsx
	const length = useDraftLength(inputs, channelId);
	const limit = flux.useStore(UserStore, (store) => characterLimit(store.getCurrentUser()));
	const colorEffects = useSetting('colorEffects');

format('COUNTER_LABEL', { length, limit }) gives its accessibility label. The build redraws it when the language changes.

A constant read by more than one file goes in src/lib/constants.ts. A value one file uses stays there. Character Counter's colors try Discord's theme tokens in order, then a fixed fallback:

src/lib/constants.ts
// Each tone tries Discord's theme tokens in order, then a fixed color when the theme has none.
export const TONE_COLORS = {
	primary: { tokens: ['BRAND_330', 'BRAND_500'], fallback: '#5865f2' },
	muted: { tokens: ['TEXT_NORMAL'], fallback: '#f2f3f5' },
	yellow: { tokens: ['YELLOW_330', 'YELLOW_300'], fallback: '#fee75c' },
	orange: { tokens: ['ORANGE_330', 'ORANGE_300'], fallback: '#f0b232' },
	red: { tokens: ['RED_360', 'RED_400'], fallback: '#ed4245' },
};

export const COUNTER_HEIGHT = 20;

Settings

settings.ts sits beside src/ and never ships in the bundle. This one setting becomes a switch with no panel code.

settings.ts
import { defineSettings, setting } from '@unbound-app/settings/schema';

export default defineSettings(
	{
		colorEffects: setting.boolean({
			default: true,
			title: 'COLOR_THRESHOLDS',
			subtitle: 'COLOR_THRESHOLDS_SUBTITLE',
		}),
	},
	{ title: 'APPEARANCE' },
);

title and subtitle are message keys. The generated src/unbound-settings.d.ts is why useSetting('colorEffects') returns a boolean. See Settings and Storage.

Translations

i18n/en-US.json holds every string. Other languages sit beside it and may leave keys out.

i18n/en-US.json
{
	"APPEARANCE": "Appearance",
	"COLOR_THRESHOLDS": "Color thresholds",
	"COLOR_THRESHOLDS_SUBTITLE": "Warn as the message approaches Discord’s character limit.",
	"COUNTER_LABEL": "{length} of {limit} characters"
}

The generated src/unbound-i18n.d.ts makes a missing key a type error in format, setting titles and toasts. Commit both generated files so your editor knows the keys before the first build. See Translations.

Tests

Tests sit in tests/, named after what they cover.

KindHow
Pure logic (lib/limit.test.ts, lib/colors.test.ts)Import and call it. The preset is needed only for ~/
Started plugin (character-counter.test.tsx)Register the modules it looks up in beforeEach, call startPlugin(), render the patched container, read the pill
tests/character-counter.test.tsx
	test('shows the length over the limit while a message is written', () => {
		draft.text = 'hello';

		const counter = renderComposer().find('Text');

		expect(counter.text()).toBe('5/2000');
		expect(counter.props.accessibilityLabel).toBe('5 of 2000 characters');
	});

The preset forgets registered modules before each test. metro.markMissing makes a lookup return undefined, as on a Discord build that lacks the module, to test a feature's warning. See Writing Tests.

Images

Images go in assets/ beside src/. Importing one gives its asset id, and the build copies it into dist/. See Images.

Addon Page

ADDON.md has no title; the Marketplace takes the title and summary from the manifest.

ADDON.md
---
banner: media/banner.png
---

## :sparkle: What it does

- :text: Shows length over your limit, like `120/2000`, while you type.
- :star: The limit is 4000 characters with Nitro and 2000 without.
- :eye: Hides while the chat input is empty and never changes its layout.
- :arrow-up: Moves up to clear the slowmode notice in channels that have one.

## :gear: Settings

- :color-palette: **Color thresholds** turns the counter yellow at 50% of the limit, orange at 75% and red at 90%. Off, it stays in Discord's brand color. On by default.
  • The frontmatter names the banner and an optional gallery, both in media/.
  • No frontmatter builds with a warning, and the Marketplace shows no banner.
  • bun run plugins:banners draws first-party banners from the manifest and leaves hand-made ones alone.
  • CHANGELOG.md lists changes newest first. Its version matches manifest.json.

See Addon Page.

Next