Settings and Storage

Typed settings and a generated settings panel.

Declare your settings once in settings.ts, beside src/. The build types them and draws the settings panel for you.

Quick Start

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

export default defineSettings({
	greetOnStart: setting.boolean({
		default: true,
		title: 'GREET_ON_START',
		subtitle: 'GREET_ON_START_SUBTITLE',
	}),
	duration: setting.integer({ default: 3, min: 1, max: 10, title: 'DURATION' }),
	theme: setting.choice({
		default: 'dark',
		title: 'THEME',
		options: { dark: 'THEME_DARK', light: 'THEME_LIGHT' },
	}),
	appearance: setting.section(
		{ title: 'APPEARANCE' },
		{ accent: setting.string({ default: '#5865F2', title: 'ACCENT', displayAs: 'color' }) },
	),
});

Every title, subtitle, placeholder, confirm and option label is a message key, not text. A plugin with a settings.ts must have an i18n/en-US.json. A key missing from en-US is a type error.

i18n/en-US.json
{
	"GREET_ON_START": "Greet on start",
	"GREET_ON_START_SUBTITLE": "Show a greeting when the plugin starts.",
	"DURATION": "Duration",
	"THEME": "Theme",
	"THEME_DARK": "Dark",
	"THEME_LIGHT": "Light",
	"APPEARANCE": "Appearance",
	"ACCENT": "Accent"
}

Read and write settings from @unbound-app/settings. Reads apply defaults. A write is checked against the schema and saved at once.

src/greeting/index.ts
import { settings, useSetting } from '@unbound-app/settings';

settings.greetOnStart; // boolean, the default until the user changes it
settings.appearance.accent = '#FF0000'; // saved, and every useSetting of it re-renders

function Badge() {
	const accent = useSetting('appearance.accent'); // re-renders only when the accent changes
	const appearance = useSetting('appearance'); // the whole section, re-rendering when one of its settings changes
}

ubd create writes both files with one example setting. A stored value the schema rejects, such as one an older version saved, is logged once and reset to its default.

Exports

Only settings.ts may import @unbound-app/settings/schema. Plugin code reads @unbound-app/settings. Full signatures: settings in the SDK reference.

@unbound-app/settings/schemaDeclares
defineSettings(properties, options?)The default export of settings.ts. options.title names the top-level section
setting.boolean, number, integer, string, choice, arrayA stored setting. See Builders
setting.section(options, properties)A titled group, one level deep
setting.button({ action, ... })A row that runs an action. Stores nothing
setting.notice({ title, subtitle?, tone?, visibleWhen? })A message row. Stores nothing

setting.string() and setting.choice({ options }) with no default are array items.

@unbound-app/settingsWhat it is
settingsEvery setting, typed. Assign a property, or a section's, to save it
useSetting(key)Reads a setting or section by dot-separated key. Re-renders when it changes
resetSettings()Resets every setting to its default
SettingsPanelThe generated panel
Settings, SettingKey, SettingsEntry<K>, ActionNameTypes. See Types

Builders

displayAs picks how a setting is drawn. Its first listed value is the default. Display options are accepted only with that display.

BuilderStoreddisplayAsOptions
booleanbooleanswitch, checkbox
number, integernumberinput, slider (default with min and max), stepper, duration (ms)min, max; slider/stepper: step (required for a number stepper); duration: units
stringstringinput, multiline, secret, url, color, user, channel, guild, role, emoji, image, fontinput/multiline: minLength, maxLength, placeholder; color: alpha
choicea key of optionsradio (default up to 5), picker (default above 5), segmented (up to 4)options: a key, or { title, subtitle }. Only radio shows subtitles
arraya list of ofof: setting.string(): list, user, channel, guild, role; of: setting.choice({ options }): checkboxesminItems, maxItems
sectionan objectgroup, collapsible
buttonnothingbutton, dangeraction, confirm
noticenothingnoticetone: info (default), warning, danger

The stored type follows the display:

DisplayStored as
color`#${string}`
urlAn http or https URL, or empty
user, channel, guild, roleA Discord id, or empty
emojiIts name, or name:id for a custom emoji

Each display uses Discord's own components. stepper is Unbound's own, since Discord has none.

Common Options

OptionWhat it does
titleThe row's label. Required unless hidden
subtitleThe row's sub-label
hidden: trueStored and typed, not drawn
visibleWhenDraws the row only while it holds
disabledWhenDraws the row disabled while it holds
requiresRestart: trueChanging it shows a "Restart Discord to apply" row with a restart button

visibleWhen and disabledWhen take a boolean setting's key, or a predicate:

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

export default defineSettings({
	showBadge: setting.boolean({ default: true, title: 'SHOW_BADGE' }),
	style: setting.choice({
		default: 'roomy',
		title: 'STYLE',
		options: { compact: 'STYLE_COMPACT', roomy: 'STYLE_ROOMY' },
	}),
	locked: setting.boolean({ default: false, title: 'LOCKED' }),
	size: setting.integer({
		default: 14,
		min: 10,
		max: 24,
		title: 'BADGE_SIZE',
		visibleWhen: (settings) => settings.showBadge && settings.style !== 'compact',
		disabledWhen: 'locked',
	}),
});

The build copies each predicate into your plugin, so it must be pure. It reads only its parameter, its own variables and built-ins such as Math. It assigns, constructs and imports nothing. The panel reruns it on every settings change. A predicate that throws is logged once, and its row stays shown and enabled.

Buttons

A setting.button runs a function exported by name from src/actions.ts, as a function or a const arrow. Each action takes nothing and returns void or a promise. action is typed from those exports, and the build checks it too. The action runs in your plugin, with its own unbound.

src/actions.ts
import { settings } from '@unbound-app/settings';

export function clearStarred() {
	settings.starred = [];
}
settings.ts
import { defineSettings, setting } from '@unbound-app/settings/schema';

export default defineSettings({
	clear: setting.button({
		title: 'CLEAR_STARRED',
		action: 'clearStarred',
		displayAs: 'danger',
		confirm: 'CLEAR_CONFIRM',
	}),
});

confirm asks a question first. An action that throws is logged as Failed to run settings action <name> for <plugin id>: and shown in a toast.

Notices

A setting.notice shows a message and stores nothing, so it is not in settings.

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

export default defineSettings({
	size: setting.integer({ default: 14, min: 10, max: 24, title: 'BADGE_SIZE' }),
	sizeWarning: setting.notice({
		title: 'SIZE_WARNING',
		subtitle: 'SIZE_WARNING_DETAIL',
		tone: 'warning',
		visibleWhen: (settings) => settings.size > 20,
	}),
});

A notice takes no hidden, disabledWhen or requiresRestart. It stands alone in its section.

Types

TypeWhat it is
SettingsThe type of settings
SettingKeyEvery setting's and section's dot-separated key, hidden ones included. Buttons and notices have none
SettingsEntry<K>The value at K, such as the union of a choice's keys
ActionNameThe functions src/actions.ts exports
import type { SettingsEntry } from '@unbound-app/settings';

function paint(color: SettingsEntry<'appearance.accent'>) {}

Custom Panel

The generated panel has a row per setting, a section per setting.section, and a "Reset all to defaults" row. Its rows are public in ui: SwitchRow, CheckboxRow, SliderRow, StepperRow, NumberRow, DurationRow, InputRow, MultilineRow, SecretRow, UrlRow, ColorRow, EntityRow, RadioRow, PickerRow, SegmentedRow, ListRow, ChecklistRow, ButtonRow.

Export a SettingsPanel from your entry to replace it, such as for a live preview. It can still render the generated panel:

src/index.tsx
import { SettingsPanel as GeneratedPanel } from '@unbound-app/settings';

export function SettingsPanel() {
	return <GeneratedPanel />;
}

Buckets

Your plugin's settings live in its bucket, named after its manifest id. Store other data there too.

storageWhat it does
getBucket(name?)Your plugin's bucket, or the one called name
onChange(predicate, callback)Calls callback for each change predicate accepts. Returns a remover. Removed when your plugin stops
snapshot(options?)Reads every bucket. secret settings are masked unless includeSecrets is set

Full signatures: storage in the SDK reference.

Bucket methodWhat it does
get(key, default)Reads a value, or the default when unset
set(key, value)Saves a value
toggle(key, default)Flips a boolean. An unset key starts from the default
remove(key)Deletes a value
clear()Deletes every value

Build Errors

The build reports every problem in settings.ts in one run. ubd build fails. ubd dev keeps your last good build.

settings.ts:2:33  a  title "Greet" is not a key of i18n/en-US.json.  Fix: add "Greet" (or "A") to i18n/en-US.json.
settings.ts:5:91  b.visibleWhen  reads outer variable "limit".  Fix: read only the predicate's `settings` parameter; the predicate runs in the plugin bundle without settings.ts.
settings.ts:8:2  layout  shows 5 options as segmented, which fits at most 4.  Fix: use displayAs: 'radio' or 'picker'.

A value import of @unbound-app/settings/schema, typebox or settings.ts from src/ fails with Fix: read settings from '@unbound-app/settings'. import type is allowed. TypeBox runs only at build time and never ships.

Typings

ubd build and ubd dev write src/unbound-settings.d.ts (the builders and your settings) and src/unbound-i18n.d.ts (your message keys). Hovering a setting shows its en-US title and subtitle. ubd dev rewrites them when settings.ts or i18n/en-US.json changes. Commit both, so your editor knows your settings before the first build.