Documentation
Kindryn Plugin Developer Guide
Source: PLUGINS.md
#Kindryn Plugin Developer Guide
This document describes how to build plugins for the Kindryn community platform. Plugins extend Kindryn with custom functionality -- community admins install them, developers build them.
#Overview
Kindryn's plugin system is built around a manifest-driven architecture. Each plugin declares its capabilities, permissions, settings, and UI injection points in a JSON manifest. The platform validates the manifest, stores it in the plugin registry, and uses it to manage installation, configuration, and rendering.
#Plugin Manifest
The manifest is the contract between your plugin and Kindryn. It must conform to the KindrynPluginManifest schema defined in server/utils/plugins.ts.
#Minimal Example
{
"slug": "my-plugin",
"name": "My Plugin",
"version": "1.0.0"
}
#Full Example
{
"slug": "fitness-tracker",
"name": "Fitness Tracker",
"version": "1.2.0",
"description": "Track workouts, calories, and progress with charts and accountability.",
"author": "Jane Developer",
"authorUrl": "https://janedeveloper.dev",
"homepage": "https://github.com/janedeveloper/kindryn-fitness-tracker",
"repository": "https://github.com/janedeveloper/kindryn-fitness-tracker",
"license": "MIT",
"icon": "https://example.com/fitness-icon.png",
"minKindrynVersion": "1.0.0",
"entryPoints": {
"server": "./dist/server/index.js",
"client": "./dist/client/index.js"
},
"permissions": ["members:read", "storage:own", "settings:own", "webhooks:receive"],
"hooks": ["onInstall", "onUninstall", "onMemberJoin"],
"settingsSchema": [
{
"key": "unit_system",
"label": "Unit System",
"description": "Choose between metric and imperial units",
"type": "select",
"required": true,
"default": "metric",
"options": [
{ "label": "Metric (kg, km)", "value": "metric" },
{ "label": "Imperial (lbs, mi)", "value": "imperial" }
]
},
{
"key": "enable_leaderboard",
"label": "Enable Leaderboard",
"description": "Show a community-wide fitness leaderboard",
"type": "boolean",
"default": true
},
{
"key": "weekly_goal_calories",
"label": "Default Weekly Calorie Goal",
"type": "number",
"default": 2000,
"validation": {
"min": 0,
"max": 100000
}
}
],
"ui": [
{
"slot": "sidebar:nav",
"label": "Fitness",
"icon": "dumbbell"
},
{
"slot": "profile:section",
"label": "Fitness Stats",
"component": "FitnessProfileSection"
},
{
"slot": "dashboard:card",
"label": "Weekly Fitness Summary",
"component": "FitnessDashboardCard"
},
{
"slot": "page",
"label": "Fitness Tracker",
"path": "tracker",
"component": "FitnessTrackerPage"
}
]
}
#Manifest Reference
#Required Fields
| Field | Type | Description |
|---|---|---|
slug | string | Globally unique identifier. Lowercase alphanumeric + hyphens (/^[a-z0-9-]+$/). Cannot be changed after registration. |
name | string | Human-readable display name (max 200 chars). |
version | string | Semantic version (e.g., 1.0.0, 2.1.3-beta.1). |
#Optional Metadata
| Field | Type | Description |
|---|---|---|
description | string | Plugin description (max 2000 chars). |
author | string | Author name or organization. |
authorUrl | string | Author website (valid URL). |
homepage | string | Plugin documentation URL. |
repository | string | Source code URL. |
license | string | License identifier (e.g., MIT, Apache-2.0). |
icon | string | URL or data URI for plugin icon. |
minKindrynVersion | string | Minimum Kindryn platform version required (semver). |
#Entry Points
{
"entryPoints": {
"server": "./dist/server/index.js",
"client": "./dist/client/index.js"
}
}
| Field | Description |
|---|---|
server | Path to server-side entry module. Receives lifecycle hook calls. |
client | Path to client-side entry module (Vue component bundle). Renders UI injections. |
#Permissions
Plugins must declare which platform capabilities they need. Community admins see these during installation.
Read permissions (data access):
| Permission | Description |
|---|---|
community:read | Read community info (name, description, branding). |
members:read | Read member list and profiles. |
spaces:read | Read space list and metadata. |
posts:read | Read posts and comments. |
courses:read | Read course and lesson data. |
events:read | Read event data and RSVPs. |
coaching:read | Read coaching session data. |
Write permissions (actions):
| Permission | Description |
|---|---|
posts:write | Create or edit posts and comments. |
events:write | Create or edit events. |
notifications:write | Send notifications to members. |
Plugin-scoped permissions:
| Permission | Description |
|---|---|
storage:own | Read/write plugin's own scoped data storage. |
settings:own | Read/write plugin's own configuration settings. |
webhooks:receive | Receive webhook events from platform lifecycle hooks. |
#Lifecycle Hooks
Plugins can subscribe to platform events by listing hooks in the manifest. The server entry point receives these as function calls.
| Hook | Trigger |
|---|---|
onInstall | Plugin is first installed in a community. |
onUninstall | Plugin is removed from a community. |
onEnable | Plugin is re-enabled after being disabled. |
onDisable | Plugin is disabled without uninstalling. |
onMemberJoin | A new member joins the community. |
onMemberLeave | A member leaves the community. |
onPostCreate | A new post is created. |
onPostDelete | A post is deleted. |
onCommentCreate | A new comment is created. |
onEventCreate | A new event is created. |
onEventRsvp | A member RSVPs to an event. |
onCourseEnroll | A member enrolls in a course. |
onLessonComplete | A member completes a lesson. |
onCoachingBook | A coaching session is booked. Payload now carries spaceId (TASK-218) so workflow COACHING_SPACE ACTION steps can match per-space. |
onSpaceAccessGranted | First grant of MemberSpaceAccess for a (member, space). Fired from member-space-access.ts after assignSpaceOnboarding (TASK-216). |
onDocumentSigned | DocuSeal submission completes for a WaiverSignature (TASK-220). Wakes workflow ACTION DOCUMENT steps and updates WaiverSignature.status. |
Reserved (declared but not yet dispatched): onWorkflowCompleted — fires when a WorkflowRun flips to COMPLETED. Plumbing reserved in plugin-hooks.ts; engine dispatch will land with TASK-229 gamification. Plugins can register handlers safely today; they just won't fire until the engine wires the dispatch.
#Settings Schema
Define configuration fields that community admins can set after installation. Each field produces a form control in the admin settings UI.
{
"settingsSchema": [
{
"key": "api_key",
"label": "API Key",
"description": "Your service API key",
"type": "string",
"required": true,
"validation": {
"minLength": 10,
"maxLength": 200
}
}
]
}
Setting field properties:
| Field | Type | Required | Description |
|---|---|---|---|
key | string | Yes | Identifier (valid JS identifier: [a-zA-Z_][a-zA-Z0-9_]*). |
label | string | Yes | Display label for the setting. |
description | string | No | Help text shown below the field. |
type | string | Yes | One of: string, number, boolean, select, multiselect, url, color, json. |
required | boolean | No | Whether the field must have a value (default: false). |
default | any | No | Default value applied on installation. |
options | array | No | For select/multiselect types. Array of { label, value }. |
validation | object | No | Constraints: min, max (number), minLength, maxLength, pattern (string). |
#UI Injection Points
Plugins can inject UI components into predefined slots in the Kindryn interface.
| Slot | Description |
|---|---|
sidebar:widget | Widget rendered in the community sidebar. |
sidebar:nav | Navigation item added to the sidebar menu. |
profile:section | Section added to member profile pages. |
profile:field | Custom field block rendered inside a specific profile tab. Set tab to overview, engagement, program, billing, notes, or contact. Visibility honors the viewer's ProfileVisibilityScope — fields are only rendered when the tab is visible to the viewer. |
space:tab | Custom tab within a space view. |
dashboard:card | Card on the community dashboard/home. |
page | Full custom page at /[community]/plugins/[pluginSlug]/[path]. |
UI injection properties:
| Field | Type | Required | Description | |||||
|---|---|---|---|---|---|---|---|---|
slot | string | Yes | Target injection slot (see table above). | |||||
label | string | Yes | Display name (tab label, nav item text, etc.). | |||||
icon | string | No | Lucide icon name or URL. | |||||
path | string | No | Sub-path for page slot (e.g., tracker becomes /[community]/plugins/fitness-tracker/tracker). | |||||
tab | string | No | For profile:field slot. Which profile tab the field renders in: overview \ | engagement \ | program \ | billing \ | notes \ | contact. |
component | string | No | Component identifier within the plugin's client bundle. | |||||
props | object | No | Static props passed to the injected component. |
#API Reference
#Plugin Registry (Global)
These endpoints manage the global plugin registry. Any authenticated user can browse; registration is open (in the future, this may require elevated privileges or a review process).
| Method | Path | Description |
|---|---|---|
GET | /api/plugins | List all registered plugins. |
POST | /api/plugins | Register a new plugin. Body: { manifest: {...} } |
GET | /api/plugins/:pluginId | Get plugin details. |
PATCH | /api/plugins/:pluginId | Update plugin manifest. Body: { manifest: {...} } |
DELETE | /api/plugins/:pluginId | Archive a plugin (must have zero active installations). |
#Plugin Installation (Per-Community)
These endpoints manage which plugins are installed in a community. Requires ADMIN role or higher.
| Method | Path | Description | |
|---|---|---|---|
GET | /api/communities/:slug/plugins | List installed plugins. | |
POST | /api/communities/:slug/plugins | Install a plugin. Body: { pluginId: "..." } | |
GET | /api/communities/:slug/plugins/:installationId | Get installation details. | |
PATCH | /api/communities/:slug/plugins/:installationId | Update status (enable/disable). Body: `{ status: "ACTIVE" | "DISABLED" }` |
DELETE | /api/communities/:slug/plugins/:installationId | Uninstall plugin (soft delete). |
#Plugin Settings (Per-Community)
| Method | Path | Description |
|---|---|---|
GET | /api/communities/:slug/plugins/:installationId/settings | Get all settings for an installation. |
PUT | /api/communities/:slug/plugins/:installationId/settings | Update settings. Body: { key: value, ... } |
#Database Models
#Plugin
The global plugin registry. One record per plugin, regardless of how many communities install it.
id-- unique identifierslug-- globally unique identifier (matches manifest slug)name,version,description,author,homepage, etc. -- extracted from manifestmanifest-- full validated manifest stored as JSON
#PluginInstallation
Per-community installation record. Created when an admin installs a plugin.
pluginId+communityId-- unique combination (one installation per plugin per community)status--ACTIVE,DISABLED, orERRORinstalledBy-- userId of the admin who installedversion-- plugin version at time of installation
#PluginSetting
Key-value configuration per installation. Admins configure these via the settings UI.
installationId+key-- unique combinationvalue-- JSON value (any type)
#Development Workflow
- Create your manifest -- start with the required fields (
slug,name,version), then add permissions, settings, hooks, and UI injections as needed. - Register the plugin -- POST the manifest to
/api/plugins:
curl -X POST http://localhost:3030/api/plugins \
-H "Content-Type: application/json" \
-d '{
"manifest": {
"slug": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"description": "A test plugin",
"permissions": ["storage:own", "settings:own"],
"settingsSchema": [
{
"key": "greeting",
"label": "Greeting Message",
"type": "string",
"default": "Hello!"
}
]
}
}'
- Install in a community -- POST to the community's plugins endpoint:
curl -X POST http://localhost:3030/api/communities/my-community/plugins \
-H "Content-Type: application/json" \
-d '{ "pluginId": "<plugin-id-from-step-2>" }'
- Configure settings -- PUT to the settings endpoint:
curl -X PUT http://localhost:3030/api/communities/my-community/plugins/<installation-id>/settings \
-H "Content-Type: application/json" \
-d '{ "greeting": "Welcome to our community!" }'
- Update the plugin -- PATCH with a new manifest (version bump):
curl -X PATCH http://localhost:3030/api/plugins/<plugin-id> \
-H "Content-Type: application/json" \
-d '{
"manifest": {
"slug": "my-plugin",
"name": "My Plugin",
"version": "1.1.0",
"description": "Updated description"
}
}'
#Validation
All manifests are validated against the Zod schema at registration and update time. Validation errors return a 400 response with details:
{
"message": "Invalid plugin manifest: slug: Slug must be lowercase alphanumeric with hyphens; version: Version must follow semver (e.g., 1.0.0)"
}
Key validation rules:
- slug: lowercase alphanumeric + hyphens, 1-100 chars, immutable after registration
- version: must follow semver (MAJOR.MINOR.PATCH)
- permissions: must be from the predefined set
- hooks: must be from the predefined set
- settingsSchema keys: must be valid identifiers (
[a-zA-Z_][a-zA-Z0-9_]*) - UI slots: must be from the predefined set
#Lifecycle Hook Execution
When platform events occur (e.g., a post is created, a member joins), Kindryn dispatches the corresponding lifecycle hook to all active plugin installations in the community that subscribe to that hook.
#How It Works
- Subscription: A plugin declares which hooks it wants in its manifest's
hooksarray. - Dispatch: When the platform event fires,
dispatchHook()finds all active installations in the community whose manifest includes that hook. - Execution: Each subscribed plugin's hook is executed (currently logs to console; sandboxed plugin code execution is planned for a future iteration).
- Logging: Every hook execution is recorded in
PluginHookLogwith status (SUCCESS,ERROR), payload, timing, and any error message.
#Currently Wired Hooks
The following hooks are actively dispatched from platform code paths:
| Hook | Trigger Location | Payload |
|---|---|---|
onMemberJoin | Invitation accept (POST /api/invitations/:token) | { communityId, memberId, userId } |
onPostCreate | Post creation (POST /api/spaces/:spaceId/posts) | { communityId, postId, spaceId, authorId } |
onPostDelete | Post deletion (DELETE /api/posts/:postId) | { communityId, postId, spaceId, deletedBy } |
onCommentCreate | Comment creation (POST /api/posts/:postId/comments) | { communityId, commentId, postId, authorId } |
onEventRsvp | Event RSVP (POST /api/communities/:slug/.../rsvp) | { communityId, eventId, memberId, status } |
#Viewing Hook Logs
Admins can view hook execution logs via the API:
# List all hook logs for a community
curl http://localhost:3030/api/communities/my-community/plugins/hook-logs
# Filter by installation
curl "http://localhost:3030/api/communities/my-community/plugins/hook-logs?installationId=xxx"
# Filter by hook name
curl "http://localhost:3030/api/communities/my-community/plugins/hook-logs?hook=onPostCreate"
# Filter by status
curl "http://localhost:3030/api/communities/my-community/plugins/hook-logs?status=ERROR"
# Pagination (cursor-based)
curl "http://localhost:3030/api/communities/my-community/plugins/hook-logs?limit=20&cursor=2026-04-11T00:00:00.000Z"
#PluginHookLog Model
Each execution creates a log entry with:
installationId-- which plugin installation was executedcommunityId-- which community the hook fired inhook-- the hook name (e.g.,onPostCreate)status--SUCCESSorERRORpayload-- the context data passed to the hook (JSON)error-- error message if execution faileddurationMs-- execution time in milliseconds
#Fire-and-Forget
All hook dispatches are fire-and-forget -- they never block or fail the original API response. If a plugin's hook handler fails, the error is logged but the platform operation continues normally.
#Scoped Data Storage
Plugins can store structured data per installation, scoped by community and optionally by member. This is the foundation for plugin features like trackers, logs, and per-user state.
#How It Works
Each plugin installation gets a JSONB key-value store (PluginData model). Data entries are keyed by a string key and can optionally be scoped to a specific memberId:
- Community-wide data (
memberId = null): shared data visible to all members, writable by admins. Example: plugin configuration state, shared leaderboards. - Per-member data (
memberId = <member-id>): private data for a specific member. Members can read/write their own data. Example: workout logs, habit streaks, personal settings.
#Size Limits
- Per value: 64 KB maximum (JSON-serialized size)
- Per installation: 10,000 records maximum
#API Reference
| Method | Path | Description |
|---|---|---|
GET | /api/communities/:slug/plugins/:installationId/data | List data entries (supports scope, keyPrefix, pagination) |
POST | /api/communities/:slug/plugins/:installationId/data | Create or update (upsert) a data entry |
GET | /api/communities/:slug/plugins/:installationId/data/:key | Get a single data entry by key |
DELETE | /api/communities/:slug/plugins/:installationId/data/:key | Delete a data entry |
#Listing Data
# Community-wide data (default scope)
curl "http://localhost:3030/api/communities/my-community/plugins/<installation-id>/data"
# Current member's data
curl "http://localhost:3030/api/communities/my-community/plugins/<installation-id>/data?scope=member"
# All data (admin only)
curl "http://localhost:3030/api/communities/my-community/plugins/<installation-id>/data?scope=all"
# Filter by key prefix
curl "http://localhost:3030/api/communities/my-community/plugins/<installation-id>/data?scope=member&keyPrefix=workout-"
# Pagination
curl "http://localhost:3030/api/communities/my-community/plugins/<installation-id>/data?scope=member&limit=50&offset=100"
#Writing Data
# Set community-wide data (admin only)
curl -X POST http://localhost:3030/api/communities/my-community/plugins/<installation-id>/data \
-H "Content-Type: application/json" \
-d '{
"key": "leaderboard",
"value": { "entries": [], "lastUpdated": "2026-04-11T00:00:00.000Z" }
}'
# Set per-member data (own data, any member)
curl -X POST http://localhost:3030/api/communities/my-community/plugins/<installation-id>/data \
-H "Content-Type: application/json" \
-d '{
"key": "workout-2026-04-11",
"value": { "exercises": ["squats", "bench press"], "duration": 45, "calories": 350 },
"memberId": "<your-member-id>"
}'
#Reading a Single Entry
# Community-wide entry
curl "http://localhost:3030/api/communities/my-community/plugins/<installation-id>/data/leaderboard"
# Per-member entry
curl "http://localhost:3030/api/communities/my-community/plugins/<installation-id>/data/workout-2026-04-11?memberId=<member-id>"
#Deleting Data
# Delete community-wide entry (admin only)
curl -X DELETE http://localhost:3030/api/communities/my-community/plugins/<installation-id>/data/leaderboard
# Delete per-member entry (own data or admin)
curl -X DELETE http://localhost:3030/api/communities/my-community/plugins/<installation-id>/data/workout-2026-04-11 \
-H "Content-Type: application/json" \
-d '{ "memberId": "<member-id>" }'
#Authorization Rules
| Action | Community-wide data | Own member data | Other member's data |
|---|---|---|---|
| Read | Any member | Any member | Admin+ only |
| Write | Admin+ only | Any member | Admin+ only |
| Delete | Admin+ only | Any member | Admin+ only |
#Server Utility
Plugins (and server-side code) can use the plugin-data.ts utility directly:
// These are auto-imported by Nuxt from server/utils/
// Read
const entry = await getPluginData(installationId, 'my-key', memberId)
// List with filters
const { data, total } = await listPluginData(installationId, {
memberId: 'some-member-id',
keyPrefix: 'workout-',
limit: 50,
offset: 0,
})
// Write (upsert)
const record = await setPluginData(installationId, 'my-key', { foo: 'bar' }, memberId)
// Delete single entry
await deletePluginData(installationId, 'my-key', memberId)
// Delete all data for an installation (or scoped to a member)
const count = await deleteAllPluginData(installationId, memberId)
#PluginData Model
id-- unique identifierinstallationId-- which plugin installation owns this datakey-- data key (max 255 chars), e.g."workout-2026-04-11","streak-count"value-- JSONB payload (any JSON-serializable value, max 64 KB)memberId-- nullable; null = community-wide, set = per-member- Unique constraint:
[installationId, key, memberId]
#Plugin API Access Layer
Plugins can read and write community data through a scoped API that respects the plugin's declared permissions. This is the bridge between plugin code and the Kindryn platform.
#How It Works
- API Token: Each plugin installation receives a unique API token (prefixed
kpk_) when installed. This token authenticates all Plugin API calls. - Scoped Client: The
PluginApiClient(inserver/utils/plugin-api.ts) wraps every data access method with a permission check against the plugin's manifest. - HTTP Endpoint: External plugin code calls
POST /api/plugins/apiwith a Bearer token. The endpoint validates the token, creates a scoped client, and dispatches the requested method.
#Authentication
Plugin API calls are authenticated using the installation's API token, passed as a Bearer token in the Authorization header:
curl -X POST http://localhost:3030/api/plugins/api \
-H "Authorization: Bearer kpk_abc123..." \
-H "Content-Type: application/json" \
-d '{ "method": "getMembers", "params": { "limit": 10 } }'
#API Token Management
Tokens are generated automatically when a plugin is installed. Admins can regenerate tokens (invalidating the old one) via:
# Regenerate API token (ADMIN+ only)
curl -X POST http://localhost:3030/api/communities/my-community/plugins/<installation-id>/api-token
# Response includes the new token (shown only once):
# { "installationId": "...", "pluginSlug": "...", "apiToken": "kpk_...", "message": "..." }
| Method | Path | Description |
|---|---|---|
POST | /api/communities/:slug/plugins/:installationId/api-token | Regenerate API token (ADMIN+). Returns the new token once. |
POST | /api/plugins/api | Call a Plugin API method. Auth: Bearer token. |
#Request Format
All Plugin API calls use the same endpoint with a method + params pattern:
{
"method": "getMembers",
"params": {
"limit": 20,
"offset": 0,
"role": "MEMBER"
}
}
#Response Format
Successful responses:
{
"ok": true,
"data": { ... }
}
Error responses use standard HTTP status codes (400, 401, 403, 404, 500) with { "message": "..." }.
#Available Methods
#Read Methods
| Method | Required Permission | Params | Description |
|---|---|---|---|
getCommunity | community:read | (none) | Get community info (name, description, branding) |
getMembers | members:read | { limit?, offset?, role?, search? } | List community members |
getMember | members:read | { memberId } | Get a single member by ID |
getSpaces | spaces:read | { limit?, offset?, type? } | List community spaces |
getPosts | posts:read | { spaceId?, limit?, offset?, sort? } | List posts (optionally filtered by space) |
getPost | posts:read | { postId } | Get a single post with comments |
getEvents | events:read | { spaceId?, limit?, offset?, upcoming?, status? } | List events |
getEvent | events:read | { eventId } | Get a single event with RSVPs |
getCourses | courses:read | { spaceId?, limit?, offset?, published? } | List courses |
getCoachingSessions | coaching:read | { spaceId?, limit?, offset?, upcoming? } | List coaching sessions |
#Write Methods
| Method | Required Permission | Params | Description |
|---|---|---|---|
createPost | posts:write | { spaceId, body, authorId, title? } | Create a post in a space |
createEvent | events:write | { spaceId, title, startsAt, description?, endsAt?, capacity?, isVirtual?, meetingUrl? } | Create an event (as DRAFT) |
#Storage Methods
| Method | Required Permission | Params | Description |
|---|---|---|---|
getOwnData | storage:own | { key, memberId? } | Get a scoped data entry |
setOwnData | storage:own | { key, value, memberId? } | Set (upsert) a scoped data entry |
deleteOwnData | storage:own | { key, memberId? } | Delete a scoped data entry |
listOwnData | storage:own | { memberId?, keyPrefix?, limit?, offset? } | List scoped data entries |
#Settings Methods
| Method | Required Permission | Params | Description |
|---|---|---|---|
getOwnSettings | settings:own | (none) | Get plugin's settings as key-value object |
#Permission Enforcement
Every API method checks the plugin's granted permissions (not manifest permissions) before executing. Community admins review and approve permissions during installation and can restrict them at any time afterward.
If a plugin calls a method it doesn't have permission for, the API returns 403:
{
"message": "Plugin \"my-plugin\" does not have \"members:read\" permission"
}
Plugins must declare the permissions they need in their manifest:
{
"slug": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"permissions": ["members:read", "posts:read", "posts:write", "storage:own"]
}
#Permission Approval Model
When a community admin installs a plugin, they see a permissions review screen showing all permissions the plugin requests. The admin can:
- Grant all -- accept every permission (default, all checkboxes start checked)
- Deny specific -- uncheck permissions they want to withhold
- Update later -- change granted permissions at any time from the plugin management page
The granted permissions are stored separately from the manifest's requested permissions in the PluginInstallation.grantedPermissions field (a JSON array). The PluginApiClient checks grantedPermissions at runtime, not the manifest, so admin restrictions are enforced immediately.
Installation with permission selection:
curl -X POST http://localhost:3030/api/communities/my-community/plugins \
-H "Content-Type: application/json" \
-d '{
"pluginId": "<plugin-id>",
"grantedPermissions": ["members:read", "storage:own"]
}'
If grantedPermissions is omitted, all manifest permissions are granted (backward compatible).
Updating permissions post-install:
curl -X PATCH http://localhost:3030/api/communities/my-community/plugins/<installation-id> \
-H "Content-Type: application/json" \
-d '{
"grantedPermissions": ["members:read", "storage:own", "posts:read"]
}'
The grantedPermissions array must be a subset of the manifest's declared permissions. Attempting to grant a permission not in the manifest returns a 400 error.
Legacy installations (created before the permissions model): grantedPermissions is null, which means all manifest permissions are treated as granted for backward compatibility.
#Server-Side Usage
Plugins running as server-side hooks (or any server utility code) can use the PluginApiClient directly without going through HTTP:
// These are auto-imported by Nuxt from server/utils/
// Create a scoped client for an installation
const client = createPluginApiClient(installation)
// Read data (permission-checked)
const members = await client.getMembers({ limit: 10 })
const posts = await client.getPosts({ spaceId: 'some-space-id' })
// Write data (permission-checked)
const post = await client.createPost({
spaceId: 'some-space-id',
body: 'Hello from a plugin!',
authorId: 'user-id',
})
// Check permissions programmatically
if (client.hasPermission('events:read')) {
const events = await client.getEvents({ upcoming: true })
}
#Example: Full Plugin API Flow
# 1. Register a plugin with API permissions
curl -X POST http://localhost:3030/api/plugins \
-H "Content-Type: application/json" \
-d '{
"manifest": {
"slug": "activity-bot",
"name": "Activity Bot",
"version": "1.0.0",
"permissions": ["members:read", "posts:read", "posts:write", "storage:own"]
}
}'
# 2. Install in a community (admin auth required)
curl -X POST http://localhost:3030/api/communities/my-community/plugins \
-H "Content-Type: application/json" \
-d '{ "pluginId": "<plugin-id>" }'
# Response includes apiToken
# 3. Use the API token to call Plugin API methods
TOKEN="kpk_..."
# List members
curl -X POST http://localhost:3030/api/plugins/api \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "method": "getMembers", "params": { "limit": 5 } }'
# Create a post
curl -X POST http://localhost:3030/api/plugins/api \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"method": "createPost",
"params": {
"spaceId": "<space-id>",
"body": "Weekly activity summary: 42 new posts this week!",
"authorId": "<bot-user-id>"
}
}'
# Store plugin data
curl -X POST http://localhost:3030/api/plugins/api \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"method": "setOwnData",
"params": {
"key": "last-summary",
"value": { "week": "2026-W15", "postCount": 42 }
}
}'
#Plugin Settings UI
Community admins can configure installed plugins through a dedicated settings page at /{community}/plugins/{installationId}/settings. The settings form is auto-generated from the plugin's settingsSchema in the manifest.
#Supported Field Types
| Type | Input Control | Validation |
|---|---|---|
string | Text input | minLength, maxLength, regex pattern |
number | Number input with min/max | min, max range |
boolean | Toggle switch | -- |
select | Dropdown select | Must match one of declared options |
multiselect | Checkbox group | All values must match declared options |
url | URL input | Valid URL format |
color | Color picker + hex input + preview swatch | Valid hex color (#RGB, #RRGGBB, or #RRGGBBAA) |
json | Monospace textarea with format button | Valid JSON syntax |
#Validation
Settings are validated both client-side (instant feedback as you type) and server-side (on save). Validation rules come from the settingsSchema:
- Required fields -- marked with a red asterisk, validated on save
- Number ranges -- enforced via
validation.minandvalidation.max - String constraints --
validation.minLength,validation.maxLength,validation.pattern(regex) - URL format -- must be a parseable URL
- Color format -- must be a valid hex color code
- JSON syntax -- live syntax checking as you type, with a "Format JSON" helper button
- Select/multiselect -- values must match the declared
optionsarray
Server-side validation returns field-level errors (keyed by setting key) which are displayed inline next to each field.
#Default Values and Reset
- Default values from the manifest's
settingsSchemaare applied automatically when a plugin is first installed - The "Reset to defaults" button restores all settings to their manifest-defined defaults
- Changes take effect immediately after saving -- no restart or reload required
#Settings Page Features
- Breadcrumb navigation -- links back to the plugin list
- Plugin info header -- shows plugin name, version, icon, and current status
- Per-field validation errors -- shown inline below each field with red border highlight
- Success confirmation -- green banner after save, auto-dismisses after 5 seconds
- Reset to defaults -- confirmation modal before resetting, then auto-saves
#Accessing Settings via API
Plugin developers can read their own settings through the Plugin API:
# Via Plugin API endpoint (using plugin API token)
curl -X POST http://localhost:3030/api/plugins/api \
-H "Authorization: Bearer kpk_abc123..." \
-H "Content-Type: application/json" \
-d '{ "method": "getOwnSettings" }'
Admin users can also read and update settings via the community endpoints:
# Read settings
curl http://localhost:3030/api/communities/my-community/plugins/<installation-id>/settings
# Update settings (admin+ only)
curl -X PUT http://localhost:3030/api/communities/my-community/plugins/<installation-id>/settings \
-H "Content-Type: application/json" \
-d '{ "unit_system": "imperial", "enable_leaderboard": false }'
#Sidebar Widget Slots
Plugins can inject navigation items and widget cards into the community sidebar. These are rendered automatically for all community members when a plugin with UI injections is installed and active.
#How It Works
- Manifest declaration: Plugins declare UI injection points in the manifest's
uiarray withslot: "sidebar:nav"orslot: "sidebar:widget". - Automatic sync: When a plugin is installed (or its manifest is updated), the platform syncs the
uientries toPluginUIInjectionrecords in the database. - Sidebar rendering: The app layout sidebar fetches active plugin injections via the UI injections API and renders them in the appropriate sections.
#Sidebar Nav Items (sidebar:nav)
Navigation items appear in a "Plugins" section in the sidebar, between the main nav and the Spaces section. Each nav item links to /[community]/plugins/[pluginSlug]/[path].
{
"ui": [
{
"slot": "sidebar:nav",
"label": "Fitness",
"icon": "dumbbell",
"path": "tracker"
}
]
}
#Sidebar Widgets (sidebar:widget)
Widget cards appear in a "Widgets" section below the Spaces list. Each widget shows a title, the plugin name, and optional HTML content. Admins can set the htmlContent field per injection after installation to provide static content.
{
"ui": [
{
"slot": "sidebar:widget",
"label": "Weekly Summary",
"icon": "bar-chart"
}
]
}
#Setting Widget HTML Content
After installation, admins can set HTML content for widget injections:
# Get injections for an installation
curl http://localhost:3030/api/communities/my-community/plugins/<installation-id>/ui-injections
# Update HTML content for a widget
curl -X PATCH http://localhost:3030/api/communities/my-community/plugins/<installation-id>/ui-injections \
-H "Content-Type: application/json" \
-d '{
"injectionId": "<injection-id>",
"htmlContent": "<p>42 workouts this week</p>"
}'
#UI Injections API
| Method | Path | Description |
|---|---|---|
GET | /api/communities/:slug/plugins/ui-injections | List all UI injections for active plugins in the community. Supports ?slot=sidebar:nav filter. |
GET | /api/communities/:slug/plugins/:installationId/ui-injections | List UI injections for a specific installation. |
PATCH | /api/communities/:slug/plugins/:installationId/ui-injections | Update injection fields (htmlContent, sortOrder). Admin+ only. Body: { injectionId, htmlContent?, sortOrder? } |
#PluginUIInjection Model
Each UI injection is stored as a database record:
id-- unique identifierinstallationId-- which plugin installation owns this injectionslot-- UI slot (e.g.,sidebar:nav,sidebar:widget)label-- display labelicon-- Lucide icon name or URL (optional)path-- sub-path for page slot (optional)component-- component identifier in plugin bundle (optional, for future use)htmlContent-- static HTML content for simple widget rendering (optional)props-- static props JSON (optional, for future use)sortOrder-- ordering within the slot
Unique constraint: [installationId, slot, label]
#Profile Section Slots
Plugins can inject custom sections into member profile pages. These sections appear below the profile card and meta info on the /[community]/members/[memberId] page.
#How It Works
- Manifest declaration: Declare a UI injection with
slot: "profile:section"in the manifest'suiarray. - Automatic sync: Injections are synced to the database when the plugin is installed or updated.
- Profile rendering: The member profile page fetches
profile:sectioninjections and renders them as cards below the profile.
#Example Manifest
{
"ui": [
{
"slot": "profile:section",
"label": "Fitness Stats",
"component": "FitnessProfileSection"
}
]
}
#Setting Section Content
Admins can set HTML content for profile sections after installation:
curl -X PATCH http://localhost:3030/api/communities/my-community/plugins/<installation-id>/ui-injections \
-H "Content-Type: application/json" \
-d '{
"injectionId": "<injection-id>",
"htmlContent": "<div><strong>Weekly Stats:</strong> 5 workouts, 2,500 cal burned</div>"
}'
#Space Tab Slots
Plugins can add custom tabs to space views. When a space has active plugin tab injections, a tab bar appears below the space header with the default content tab and any plugin tabs.
#How It Works
- Manifest declaration: Declare a UI injection with
slot: "space:tab"in the manifest'suiarray. - Tab bar rendering: The space view page fetches
space:tabinjections and renders a tab bar. The default tab shows the original space content (discussions, courses, events, or coaching). Plugin tabs show their own content. - Tab switching: Clicking a plugin tab hides the default space content and shows the plugin tab's content card.
#Example Manifest
{
"ui": [
{
"slot": "space:tab",
"label": "Analytics",
"component": "SpaceAnalyticsTab"
}
]
}
#Setting Tab Content
curl -X PATCH http://localhost:3030/api/communities/my-community/plugins/<installation-id>/ui-injections \
-H "Content-Type: application/json" \
-d '{
"injectionId": "<injection-id>",
"htmlContent": "<div><h4>Space Analytics</h4><p>Most active day: Wednesday</p></div>"
}'
#Dashboard Card Slots
Plugins can add cards to the community dashboard (home page). Dashboard cards appear in a grid below the spaces grid on the /[community] page.
#How It Works
- Manifest declaration: Declare a UI injection with
slot: "dashboard:card"in the manifest'suiarray. - Dashboard rendering: The community home page fetches
dashboard:cardinjections and renders them as cards in a 2-column grid in a "Plugin Cards" section.
#Example Manifest
{
"ui": [
{
"slot": "dashboard:card",
"label": "Weekly Fitness Summary",
"component": "FitnessDashboardCard"
}
]
}
#Setting Card Content
curl -X PATCH http://localhost:3030/api/communities/my-community/plugins/<installation-id>/ui-injections \
-H "Content-Type: application/json" \
-d '{
"injectionId": "<injection-id>",
"htmlContent": "<div><p>Community total: <strong>127 workouts</strong> this week</p></div>"
}'
#Plugin-Owned Pages
Plugins can register full pages served under a community route. These pages are accessible at /[community]/plugins/[pluginSlug]/[path] and provide plugins with a dedicated space for custom UI.
#How It Works
- Manifest declaration: Declare a UI injection with
slot: "page"and apathfield in the manifest'suiarray. Thepathis the sub-path under the plugin's route namespace. - Sidebar navigation: If the plugin also declares a
sidebar:navinjection, the nav item automatically links to the plugin's page. - Page rendering: A catch-all page at
/[community]/plugins/[pluginSlug]/[...path]fetchespageinjections and matches against the plugin slug and path to find the correct injection.
#Example Manifest
{
"ui": [
{
"slot": "sidebar:nav",
"label": "Fitness",
"icon": "dumbbell",
"path": "tracker"
},
{
"slot": "page",
"label": "Fitness Tracker",
"path": "tracker",
"component": "FitnessTrackerPage"
}
]
}
This creates:
- A sidebar nav item "Fitness" that links to
/[community]/plugins/fitness-tracker/tracker - A full page at that URL showing the plugin's page content
#Setting Page Content
curl -X PATCH http://localhost:3030/api/communities/my-community/plugins/<installation-id>/ui-injections \
-H "Content-Type: application/json" \
-d '{
"injectionId": "<injection-id>",
"htmlContent": "<div><h2>Your Fitness Dashboard</h2><p>Track your workouts, calories, and progress here.</p></div>"
}'
#Multiple Pages per Plugin
A plugin can register multiple pages by declaring multiple page injections with different path values:
{
"ui": [
{ "slot": "page", "label": "Tracker", "path": "tracker" },
{ "slot": "page", "label": "Leaderboard", "path": "leaderboard" },
{ "slot": "page", "label": "Settings", "path": "settings" }
]
}
These would be accessible at:
/[community]/plugins/my-plugin/tracker/[community]/plugins/my-plugin/leaderboard/[community]/plugins/my-plugin/settings
#First-Party Plugins
Kindryn ships with four first-party plugins that demonstrate the plugin system's capabilities. These serve as reference implementations for plugin developers and provide useful functionality for communities out of the box.
All first-party plugin manifests live in the plugins/ directory at the project root. The seed script (prisma/seed.ts) automatically registers and installs them in the demo communities.
#Fitness Tracker (fitness-tracker)
Track workouts, calories, and fitness progress. Members log daily activity, set calorie goals, and view progress over time.
- Location:
plugins/fitness-tracker/ - Permissions:
members:read,storage:own,settings:own - Hooks:
onInstall,onMemberJoin - Settings: Unit system (metric/imperial), daily calorie goal, workout reminder day, enable leaderboard
- UI: Dashboard card (today's stats), profile section (fitness summary), sidebar nav, full tracker page
- Data keys:
workout-{date}(per-member),daily-log-{date}(per-member),member-settings(per-member) - Seeded in: Iron & Grit Fitness community
#Financial Dashboard (financial-dashboard)
Track revenue, expenses, and financial goals. Log income and expenses, set monthly targets, and visualize progress.
- Location:
plugins/financial-dashboard/ - Permissions:
members:read,storage:own,settings:own - Hooks:
onInstall - Settings: Currency (USD/EUR/GBP/etc.), fiscal year start month, show profit/loss on dashboard
- UI: Dashboard card (monthly summary), sidebar nav, full dashboard page
- Data keys:
income-{date}-{id}(per-member),expense-{date}-{id}(per-member),goal-{id}(per-member),monthly-summary-{YYYY-MM}(per-member) - Seeded in: Founders Circle community
#Habit Tracker (habit-tracker)
Build daily habits and maintain streaks with accountability. Define habits, check them off daily, and track streaks.
- Location:
plugins/habit-tracker/ - Permissions:
members:read,storage:own,settings:own - Hooks:
onInstall,onMemberJoin - Settings: Week start day, streak milestone threshold, max habits per member, show streaks on profile
- UI: Dashboard card (today's habits), profile section (streak info), sidebar nav, full tracker page
- Data keys:
habit-{id}(per-member),completions-{date}(per-member),streaks(per-member) - Seeded in: Iron & Grit Fitness and Founders Circle communities
#Reading Log (reading-log)
Track your reading journey. Log books, rate and review them, set yearly goals, and share reading lists.
- Location:
plugins/reading-log/ - Permissions:
members:read,storage:own,settings:own,posts:read - Hooks:
onInstall,onMemberJoin - Settings: Yearly reading goal, genre list, rating scale (3-10), show currently reading on dashboard
- UI: Dashboard card (currently reading), profile section (books read count), sidebar nav, full reading log page
- Data keys:
book-{id}(per-member),reading-stats(per-member),community-reading-list(community-wide) - Seeded in: Page Turners Collective community
#Installing First-Party Plugins
The seed script handles registration and installation automatically. To manually install in a new community:
# 1. Register (if not already registered from seed)
curl -X POST http://localhost:3030/api/plugins \
-H "Content-Type: application/json" \
-d "$(cat plugins/fitness-tracker/manifest.json | jq '{manifest: del(.dataSchema)}')"
# 2. Find the plugin ID
curl http://localhost:3030/api/plugins | jq '.[] | select(.slug == "fitness-tracker") | .id'
# 3. Install in your community
curl -X POST http://localhost:3030/api/communities/my-community/plugins \
-H "Content-Type: application/json" \
-d '{ "pluginId": "<plugin-id>" }'
#Building Your Own Plugin
Use these first-party plugins as templates. Each manifest demonstrates:
- Settings schema with various field types (select, number, boolean, string)
- UI injections across multiple slots (dashboard, profile, sidebar, page)
- Data key conventions for organizing per-member and community-wide data
- Documented data schemas via the
dataSchemafield (not validated by the platform, but useful for documentation) - Hook subscriptions for responding to platform events
See the individual README files in each plugin directory for detailed documentation.
#What's Next
Future extensibility features will build on the manifest, registry, hook execution, scoped data storage, API access layer, permission approval model, settings UI, and all UI injection slots:
- Sandboxed plugin code execution -- replace console.log placeholder with actual isolated plugin code execution (isolated-vm or worker_threads)
- Permission audit trail -- log when permissions are granted, denied, or changed by admins
