Writing a plugin
How to write a first-party plugin: cards, pages, settings, capabilities, data and tests.
This applies to first-party plugins (the counter, habit streak, Pomodoro, sticky note and world
clock are all written this way). The reference implementation is the template plugin
hello-note:
- Frontend:
apps/web/src/modules/_template-plugin/ - Backend:
apps/api/src/semestra/modules/plugins_builtin/_template/
The template is not registered in the app; copy it and rename it. For a fuller example, see
plugin-habit-streak + plugins_builtin/habit_streak/ (its own table, event subscriptions and an
export adapter).
1. Decide what the plugin provides
| Form | Description | Who draws the frame |
|---|---|---|
| Card | Sits on a scope's board (Program / term / course / today), size s/m/l/xl on a 4/6/8-column grid | The host draws the frame (title, size toolbar, settings entry, remove); the plugin draws only the content |
| Page | An entry in the sidebar (Program and term pages) or a tab in the course header, free layout | The host only wraps it in a PluginBoundary |
| Settings section | The plugin's account-level or scope-level configuration | Generated from the schema |
| Capability | A read/write operation, at once a REST endpoint, an assistant tool and an MCP tool | No UI |
Plugins are installed and uninstalled per account. Uninstalling only hides the plugin and
keeps its data; when the user chooses "also delete data", plugins.purge is published and the
plugin must clean up its own rows.
2. Folders and names
apps/web/src/modules/plugin-<id>/ frontend; entry point manifest.ts
apps/api/src/semestra/modules/plugins_builtin/<id_with_underscores>/ backend, only when it stores data or offers agent tools- The plugin id is kebab-case (
habit-streak) and must match on both sides; a CI test compares the frontend manifest list with the backend/plugins/catalog. - Card ids are
<prefix>.<name>(habit.ring), capabilities<prefix>.<object>.<action>(habit.checkin.toggle), tablesplugin_<prefix>_<name>, the settings namespaceplugin.<id>. - Message keys use the plugin prefix (
habit_…) and exist in both languages; error codehabit.date_out_of_rangemaps to messageerror_habit_date_out_of_range.
3. Frontend
3.1 Manifest
export default defineModule({
id: 'hello-note', kind: 'plugin',
name: m.hello_name, description: m.hello_description, icon: NotebookPen,
pages: [{ id, scopes, title, icon, component: preloadable(...), order: 1000 }],
cards: [{
id: 'hello.note', scopes: ['program','term','course','today'],
title, icon, sizes: ['s','m','l'], defaultSize: 'm',
component: preloadable(...),
prefetch: (scope, queryClient) => ..., // optional: start the card's data with its code (queryClient.prefetchQuery)
settings: { schema: zodSchema, component: preloadable(...) }, // component is optional; without it a form is generated from the schema
multiple: true,
headerActions: ..., // card header actions (the counter's "reset"); the host shows them on hover
keepTitleWhenSmall: true, // optional: keep the title row at size s (hidden by default)
}],
settingsSections: [{ id, level: 'account', pluginId, title, order, namespace: 'plugin.hello-note' }],
statusItems: [{ id, order, useStatus }], // sidebar status bar (Pomodoro shows the running timer here)
})- Load every heavy component on demand. A page's
componentand thecomponentof cards and settings sections usepreloadable()(@/app/extensions), so the router and the settings dialog can fetch the code early and render it as soon as it is there. A plainlazy()flashes a placeholder on every first render (registry.test.tscatches it). Passm.xxxas the function itself; do not call it at module level (switching language remounts). - Add the new plugin's manifest to the list in
app/registry.ts. - A custom card settings form looks like a settings section: one or more
SettingsGroups ofSettingRows from@/app/settings(name on the left, control on the right; Switch for on/off,ChoiceControlfor one of several). Pickers that need the full width (icons, colors, a list of cities) go in a row of their own or aSettingsBlock. The host adds Cancel and Save.
3.2 Card components
- Props:
{ scope, instanceId, size, settings }.settingsis what the host parsed withsettings.schema, so give every field a default; otherwise old cards fail to parse when a field is added. - Every declared size needs a design. Size s holds the least information and one main action; do not just scale things down. Tests render every size.
- At size s the host hides the title row to give the content the whole card (it comes back while
editing the board), so the content's first line must say what the card is: the counter's
name, the clock's city, "Focus". Set
keepTitleWhenSmallonly when the content cannot. headerActionsand the settings button sit together at the top right and appear when the pointer is over the card or focus is inside it (always on touch screens); do not hide or show them yourself.- A list that may not fit uses
overflow-y-auto scroll-fade: the edge that has more content fades out, so it is clear the list scrolls. - Change settings with
useCardSettings(instanceId, settings, delayMs): it applies locally at once, saves after the delay, rolls back on failure and reports astatus. The counter uses 300 ms, the sticky note 800 ms. - Do not draw your own card frame or add your own padding; spacing inside the frame is the host's.
- While the card's main data is
undefined, return<CardSkeleton />(@/app/board) instead ofnull, a spinner or an empty state. It is one rounded block that fills the card body without changing its height and fades in after 150 ms; the host shows the same skeleton while the card's code loads. - Business data (rows that are not settings, such as check-ins) goes through the plugin's own
capabilities with the generated
@semestra/api-client; never hand-writefetch. Query keys come from the generated client; invalidate them by matching an object subset. - Remembered UI state (collapsed, last choice, timer state) uses
useUiPref('<prefix>.<name>', default), not settings and notlocalStoragedirectly.
3.3 Styling and accessibility
- Use tokens only:
bg-primary,text-muted-foreground; course and subject colors throughsubjectColor. Nooklch(...)orsky-500. - Controls come from the shadcn components in
@/ui/(Button, Input, Textarea, Checkbox, Switch, RadioGroup, Select…), not hand-styled<input>or<button>; install new ones with the shadcn CLI and never editsrc/ui/by hand. Tables use TanStack Table. - Icon buttons have an
aria-label; changing numbers usearia-live="polite"; animations respect reduced motion, both the system'sprefers-reduced-motion(useReducedMotionfrommotion/react) and the app's reduce-motion preference written to<html data-reduce-motion="on">. If either is set, do not animate (seeplugin-kit/Burst.tsx). - Ask for confirmation only for destructive actions that cannot be undone and have no other confirmation step; submitting a form is the confirmation.
4. Backend
4.1 Settings namespace
register_settings(namespace="plugin.hello-note", schema=HelloNoteSettings,
levels=("account", "course"), plugin_id="hello-note")levels are the levels where a value can be set; deeper scopes read the inherited result. Every
field needs a default and a description. Messages:
settings_plugin_hello_note_<field>_label|_description|_option_<value> (a backend test checks
both languages).
4.2 Capabilities
@capability(name="hello.note.append", summary=..., description=..., effect="write",
http=Http("POST", "/hello/notes/{instance_id}/lines"), tags=("hello-note",),
plugin_id=PLUGIN_ID)descriptionis read by the AI: when to use it, how to find its inputs (card instances vialayouts.card.list), and when not to use it.effectisread | write | destructive. A capability withplugin_idis refused while the plugin is not installed and is left out of the agent's tool list.- Capabilities bound to a card instance use
instances.get_card_instance(ctx, instance_id, card_ids=CARD_IDS): a missing card, someone else's card, or a card of another plugin all returncommon.not_found. - For "today" in the user's time zone, go through the card's program
(
instances.instance_program+academics.program_today); do not compute it in the frontend. - Domain errors are
AppError("<prefix>.<reason>", status, **params).
4.3 Data and cleanup
- Tables use
OwnedMixin(withuser_id) and refer to the card byinstance_id, which is not a foreign key (card instances belong to the layouts module). - Subscribe to
layouts.card.deleted(filter bycard_id) to delete that card's rows, and toplugins.purge(filter byplugin_id) to delete all of the user's rows.plugins.purgeis published inside the uninstall transaction, so subscribe with@events.subscribe("plugins.purge", in_transaction=True); raising makes the uninstall roll back. A plugin that wants its statistics to survive card deletion can respond toplugins.purgeonly (Pomodoro does). Plugins that keep data inuseUiPrefclear it in the same subscription withsettings.service.purge_ui_state(ctx, user_id, '<prefix>'). - Timed reminders (the habit's daily reminder) go through M18:
notifications.reminders.reminder_source(kind, ReminderSource(plan=..., message=...)).planreturns thePlannedreminders of the coming hours;messagedecides when one is due whether it is still worth sending (Nonesends nothing). Devices, quiet hours and sending are the notifications module's; the texts go innotifications/texts/{en,zh-CN}.json. When the plugin's own settings change, callreminders.plan(ctx, [program_id])to plan again. - Migrations follow the single Alembic chain: import your
modelsinmigrations/env.py, runalembic revision --autogenerate, and check the result.
4.4 Export and import adapter
Provide the four callbacks export / import_ / preview / purge. Imports must be idempotent (no
duplicate rows on a repeated import), and id_map remaps ids across modules. The complete example
is plugins_builtin/portability.py, registered in plugins_builtin/__init__.py with
register_portability(..., tables=(...)); add a new plugin's tables in both places, or the
portability coverage test fails (every table with user_id must belong to an adapter and be emptied by it).
A plugin with no data of its own (such as the sticky note, whose text lives in the card
settings) can have an empty adapter.
When a plugin changes what it exports, raise its schema_version and pass
upgrades={old_version: step}: step(payload, archive) turns a payload of the old version into
the next one, so backups made before the change still restore. Steps are pure functions, like a
migration of the plugin's tables.
4.5 Agent skill
<plugin package>/skills/<name>.md. The frontmatter is TOML between +++ lines: name, title = { en = "…", zh-CN = "…" }
(the name shown in the app), description (starts with "Use when …" and says when to load it;
the model sees only this line in its catalog) and tools (every capability the body uses; the
skill appears only when all of them are available, and a test checks this), plus an optional
command (a slash command such as /note, unique across the app). The body says which
capabilities to call in which order for which requests (see hello-note.md in the template).
Every capability whose expose includes the agent needs capability_<name with underscores>_title
in messages/*.json; a backend test checks it.
5. Tests
- Frontend: render every card in every declared size; validate the settings schema
(including old data with missing fields); two instances do not interfere; timer and date logic
as pure functions with a fake clock. Use
renderWithQueryfrom@/test-utilsand the mswserver. Give every endpoint the card calls a handler: a request without one fails the test. - Backend: success and failure paths of each capability over REST (error codes and params);
ownership checks (someone else's instance or another card id returns 404); event subscriptions
(card deletion, purge); an export → purge → import round trip. Card instances are simulated with
the
cardsfixture inplugins_builtin/tests/conftest.py. - After changing backend endpoints, run
pnpm gen:apiand commit the result.
6. Release checklist
- Frontend and backend ids match; the
PLUGINinfo (name and description keys, version,default_installed, cards and capabilities provided) is exported - Every size of every card has a design; size s is usable
- Messages complete in both languages (including settings and error messages)
- Colors are all tokens, controls are all shadcn components, icon buttons have an
aria-label - Uninstall (keep data / delete data) and reinstall behave: cards disappear, data returns after reinstall, and after purge the user has 0 rows in the tables
- The export adapter is written and passes the round-trip test
- Capability
descriptions are clear enough for the AI; write operations have the righteffect -
pnpm lint typecheck test,uv run ruff check,uv run pyright,uv run pytestpass, andpnpm gen:apishows no diff