All docs

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

FieldTypeDescription
slugstringGlobally unique identifier. Lowercase alphanumeric + hyphens (/^[a-z0-9-]+$/). Cannot be changed after registration.
namestringHuman-readable display name (max 200 chars).
versionstringSemantic version (e.g., 1.0.0, 2.1.3-beta.1).

#Optional Metadata

FieldTypeDescription
descriptionstringPlugin description (max 2000 chars).
authorstringAuthor name or organization.
authorUrlstringAuthor website (valid URL).
homepagestringPlugin documentation URL.
repositorystringSource code URL.
licensestringLicense identifier (e.g., MIT, Apache-2.0).
iconstringURL or data URI for plugin icon.
minKindrynVersionstringMinimum Kindryn platform version required (semver).

#Entry Points

{
  "entryPoints": {
    "server": "./dist/server/index.js",
    "client": "./dist/client/index.js"
  }
}
FieldDescription
serverPath to server-side entry module. Receives lifecycle hook calls.
clientPath 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):

PermissionDescription
community:readRead community info (name, description, branding).
members:readRead member list and profiles.
spaces:readRead space list and metadata.
posts:readRead posts and comments.
courses:readRead course and lesson data.
events:readRead event data and RSVPs.
coaching:readRead coaching session data.

Write permissions (actions):

PermissionDescription
posts:writeCreate or edit posts and comments.
events:writeCreate or edit events.
notifications:writeSend notifications to members.

Plugin-scoped permissions:

PermissionDescription
storage:ownRead/write plugin's own scoped data storage.
settings:ownRead/write plugin's own configuration settings.
webhooks:receiveReceive 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.

HookTrigger
onInstallPlugin is first installed in a community.
onUninstallPlugin is removed from a community.
onEnablePlugin is re-enabled after being disabled.
onDisablePlugin is disabled without uninstalling.
onMemberJoinA new member joins the community.
onMemberLeaveA member leaves the community.
onPostCreateA new post is created.
onPostDeleteA post is deleted.
onCommentCreateA new comment is created.
onEventCreateA new event is created.
onEventRsvpA member RSVPs to an event.
onCourseEnrollA member enrolls in a course.
onLessonCompleteA member completes a lesson.
onCoachingBookA coaching session is booked. Payload now carries spaceId (TASK-218) so workflow COACHING_SPACE ACTION steps can match per-space.
onSpaceAccessGrantedFirst grant of MemberSpaceAccess for a (member, space). Fired from member-space-access.ts after assignSpaceOnboarding (TASK-216).
onDocumentSignedDocuSeal 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:

FieldTypeRequiredDescription
keystringYesIdentifier (valid JS identifier: [a-zA-Z_][a-zA-Z0-9_]*).
labelstringYesDisplay label for the setting.
descriptionstringNoHelp text shown below the field.
typestringYesOne of: string, number, boolean, select, multiselect, url, color, json.
requiredbooleanNoWhether the field must have a value (default: false).
defaultanyNoDefault value applied on installation.
optionsarrayNoFor select/multiselect types. Array of { label, value }.
validationobjectNoConstraints: min, max (number), minLength, maxLength, pattern (string).

#UI Injection Points

Plugins can inject UI components into predefined slots in the Kindryn interface.

SlotDescription
sidebar:widgetWidget rendered in the community sidebar.
sidebar:navNavigation item added to the sidebar menu.
profile:sectionSection added to member profile pages.
profile:fieldCustom 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:tabCustom tab within a space view.
dashboard:cardCard on the community dashboard/home.
pageFull custom page at /[community]/plugins/[pluginSlug]/[path].

UI injection properties:

FieldTypeRequiredDescription
slotstringYesTarget injection slot (see table above).
labelstringYesDisplay name (tab label, nav item text, etc.).
iconstringNoLucide icon name or URL.
pathstringNoSub-path for page slot (e.g., tracker becomes /[community]/plugins/fitness-tracker/tracker).
tabstringNoFor profile:field slot. Which profile tab the field renders in: overview \engagement \program \billing \notes \contact.
componentstringNoComponent identifier within the plugin's client bundle.
propsobjectNoStatic 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).

MethodPathDescription
GET/api/pluginsList all registered plugins.
POST/api/pluginsRegister a new plugin. Body: { manifest: {...} }
GET/api/plugins/:pluginIdGet plugin details.
PATCH/api/plugins/:pluginIdUpdate plugin manifest. Body: { manifest: {...} }
DELETE/api/plugins/:pluginIdArchive 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.

MethodPathDescription
GET/api/communities/:slug/pluginsList installed plugins.
POST/api/communities/:slug/pluginsInstall a plugin. Body: { pluginId: "..." }
GET/api/communities/:slug/plugins/:installationIdGet installation details.
PATCH/api/communities/:slug/plugins/:installationIdUpdate status (enable/disable). Body: `{ status: "ACTIVE""DISABLED" }`
DELETE/api/communities/:slug/plugins/:installationIdUninstall plugin (soft delete).

#Plugin Settings (Per-Community)

MethodPathDescription
GET/api/communities/:slug/plugins/:installationId/settingsGet all settings for an installation.
PUT/api/communities/:slug/plugins/:installationId/settingsUpdate settings. Body: { key: value, ... }

#Database Models

#Plugin

The global plugin registry. One record per plugin, regardless of how many communities install it.

  • id -- unique identifier
  • slug -- globally unique identifier (matches manifest slug)
  • name, version, description, author, homepage, etc. -- extracted from manifest
  • manifest -- 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, or ERROR
  • installedBy -- userId of the admin who installed
  • version -- plugin version at time of installation

#PluginSetting

Key-value configuration per installation. Admins configure these via the settings UI.

  • installationId + key -- unique combination
  • value -- JSON value (any type)

#Development Workflow

  1. Create your manifest -- start with the required fields (slug, name, version), then add permissions, settings, hooks, and UI injections as needed.
  2. 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!"
        }
      ]
    }
  }'
  1. 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>" }'
  1. 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!" }'
  1. 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

  1. Subscription: A plugin declares which hooks it wants in its manifest's hooks array.
  2. Dispatch: When the platform event fires, dispatchHook() finds all active installations in the community whose manifest includes that hook.
  3. Execution: Each subscribed plugin's hook is executed (currently logs to console; sandboxed plugin code execution is planned for a future iteration).
  4. Logging: Every hook execution is recorded in PluginHookLog with status (SUCCESS, ERROR), payload, timing, and any error message.

#Currently Wired Hooks

The following hooks are actively dispatched from platform code paths:

HookTrigger LocationPayload
onMemberJoinInvitation accept (POST /api/invitations/:token){ communityId, memberId, userId }
onPostCreatePost creation (POST /api/spaces/:spaceId/posts){ communityId, postId, spaceId, authorId }
onPostDeletePost deletion (DELETE /api/posts/:postId){ communityId, postId, spaceId, deletedBy }
onCommentCreateComment creation (POST /api/posts/:postId/comments){ communityId, commentId, postId, authorId }
onEventRsvpEvent 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 executed
  • communityId -- which community the hook fired in
  • hook -- the hook name (e.g., onPostCreate)
  • status -- SUCCESS or ERROR
  • payload -- the context data passed to the hook (JSON)
  • error -- error message if execution failed
  • durationMs -- 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

MethodPathDescription
GET/api/communities/:slug/plugins/:installationId/dataList data entries (supports scope, keyPrefix, pagination)
POST/api/communities/:slug/plugins/:installationId/dataCreate or update (upsert) a data entry
GET/api/communities/:slug/plugins/:installationId/data/:keyGet a single data entry by key
DELETE/api/communities/:slug/plugins/:installationId/data/:keyDelete 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

ActionCommunity-wide dataOwn member dataOther member's data
ReadAny memberAny memberAdmin+ only
WriteAdmin+ onlyAny memberAdmin+ only
DeleteAdmin+ onlyAny memberAdmin+ 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 identifier
  • installationId -- which plugin installation owns this data
  • key -- 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

  1. API Token: Each plugin installation receives a unique API token (prefixed kpk_) when installed. This token authenticates all Plugin API calls.
  2. Scoped Client: The PluginApiClient (in server/utils/plugin-api.ts) wraps every data access method with a permission check against the plugin's manifest.
  3. HTTP Endpoint: External plugin code calls POST /api/plugins/api with 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": "..." }
MethodPathDescription
POST/api/communities/:slug/plugins/:installationId/api-tokenRegenerate API token (ADMIN+). Returns the new token once.
POST/api/plugins/apiCall 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

MethodRequired PermissionParamsDescription
getCommunitycommunity:read(none)Get community info (name, description, branding)
getMembersmembers:read{ limit?, offset?, role?, search? }List community members
getMembermembers:read{ memberId }Get a single member by ID
getSpacesspaces:read{ limit?, offset?, type? }List community spaces
getPostsposts:read{ spaceId?, limit?, offset?, sort? }List posts (optionally filtered by space)
getPostposts:read{ postId }Get a single post with comments
getEventsevents:read{ spaceId?, limit?, offset?, upcoming?, status? }List events
getEventevents:read{ eventId }Get a single event with RSVPs
getCoursescourses:read{ spaceId?, limit?, offset?, published? }List courses
getCoachingSessionscoaching:read{ spaceId?, limit?, offset?, upcoming? }List coaching sessions

#Write Methods

MethodRequired PermissionParamsDescription
createPostposts:write{ spaceId, body, authorId, title? }Create a post in a space
createEventevents:write{ spaceId, title, startsAt, description?, endsAt?, capacity?, isVirtual?, meetingUrl? }Create an event (as DRAFT)

#Storage Methods

MethodRequired PermissionParamsDescription
getOwnDatastorage:own{ key, memberId? }Get a scoped data entry
setOwnDatastorage:own{ key, value, memberId? }Set (upsert) a scoped data entry
deleteOwnDatastorage:own{ key, memberId? }Delete a scoped data entry
listOwnDatastorage:own{ memberId?, keyPrefix?, limit?, offset? }List scoped data entries

#Settings Methods

MethodRequired PermissionParamsDescription
getOwnSettingssettings: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:

  1. Grant all -- accept every permission (default, all checkboxes start checked)
  2. Deny specific -- uncheck permissions they want to withhold
  3. 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

TypeInput ControlValidation
stringText inputminLength, maxLength, regex pattern
numberNumber input with min/maxmin, max range
booleanToggle switch--
selectDropdown selectMust match one of declared options
multiselectCheckbox groupAll values must match declared options
urlURL inputValid URL format
colorColor picker + hex input + preview swatchValid hex color (#RGB, #RRGGBB, or #RRGGBBAA)
jsonMonospace textarea with format buttonValid 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.min and validation.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 options array

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 settingsSchema are 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 }'

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

  1. Manifest declaration: Plugins declare UI injection points in the manifest's ui array with slot: "sidebar:nav" or slot: "sidebar:widget".
  2. Automatic sync: When a plugin is installed (or its manifest is updated), the platform syncs the ui entries to PluginUIInjection records in the database.
  3. Sidebar rendering: The app layout sidebar fetches active plugin injections via the UI injections API and renders them in the appropriate sections.

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"
    }
  ]
}

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

MethodPathDescription
GET/api/communities/:slug/plugins/ui-injectionsList all UI injections for active plugins in the community. Supports ?slot=sidebar:nav filter.
GET/api/communities/:slug/plugins/:installationId/ui-injectionsList UI injections for a specific installation.
PATCH/api/communities/:slug/plugins/:installationId/ui-injectionsUpdate injection fields (htmlContent, sortOrder). Admin+ only. Body: { injectionId, htmlContent?, sortOrder? }

#PluginUIInjection Model

Each UI injection is stored as a database record:

  • id -- unique identifier
  • installationId -- which plugin installation owns this injection
  • slot -- UI slot (e.g., sidebar:nav, sidebar:widget)
  • label -- display label
  • icon -- 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

  1. Manifest declaration: Declare a UI injection with slot: "profile:section" in the manifest's ui array.
  2. Automatic sync: Injections are synced to the database when the plugin is installed or updated.
  3. Profile rendering: The member profile page fetches profile:section injections 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

  1. Manifest declaration: Declare a UI injection with slot: "space:tab" in the manifest's ui array.
  2. Tab bar rendering: The space view page fetches space:tab injections and renders a tab bar. The default tab shows the original space content (discussions, courses, events, or coaching). Plugin tabs show their own content.
  3. 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

  1. Manifest declaration: Declare a UI injection with slot: "dashboard:card" in the manifest's ui array.
  2. Dashboard rendering: The community home page fetches dashboard:card injections 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

  1. Manifest declaration: Declare a UI injection with slot: "page" and a path field in the manifest's ui array. The path is the sub-path under the plugin's route namespace.
  2. Sidebar navigation: If the plugin also declares a sidebar:nav injection, the nav item automatically links to the plugin's page.
  3. Page rendering: A catch-all page at /[community]/plugins/[pluginSlug]/[...path] fetches page injections 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:

  1. Settings schema with various field types (select, number, boolean, string)
  2. UI injections across multiple slots (dashboard, profile, sidebar, page)
  3. Data key conventions for organizing per-member and community-wide data
  4. Documented data schemas via the dataSchema field (not validated by the platform, but useful for documentation)
  5. 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