Frontend Plugin Development Guide
Overview
Section titled “Overview”Frontend plugins provide UI components, pages, routing, and client-side services. They are built using React, React Router, ShadCN UI, and Vite.
Frontend plugins consume oRPC contracts defined in -common packages, enabling type-safe RPC communication with the backend.
Quick Start
Section titled “Quick Start”1. Scaffold Plugin with CLI
Section titled “1. Scaffold Plugin with CLI”The fastest way to create a frontend plugin is using the CLI:
bun run createInteractive prompts:
- Select
frontendas the plugin type - Enter your plugin name (e.g.,
myfeature) - Provide a description (optional)
- Confirm to generate
This will create a complete plugin structure with:
- ✅ Package configuration with React, router, and UI dependencies
- ✅ TypeScript configuration
- ✅ Contract-based API definition with typed client imports
- ✅ Example list page component with CRUD operations
- ✅ Plugin registration with routes and navigation
- ✅ Initial changeset for version management
Generated structure:
plugins/myfeature-frontend/├── .changeset/│ └── initial.md # Version changeset├── package.json # Dependencies├── tsconfig.json # TypeScript config├── README.md # Documentation└── src/ ├── index.tsx # Plugin entry point ├── api.ts # Contract-derived API types └── components/ └── MyFeatureListPage.tsx # Example page2. Install Dependencies
Section titled “2. Install Dependencies”cd plugins/myfeature-frontendbun install3. Customize Your Plugin
Section titled “3. Customize Your Plugin”The generated plugin is a working example. Customize it for your domain:
Update API Types
Section titled “Update API Types”src/api.ts:
The API types are imported from your common package (no derivation needed):
import { createApiRef } from "@checkstack/frontend-api";import { MyFeatureClient } from "@checkstack/myfeature-common";
// Re-export types for convenienceexport type { MyItem, CreateMyItem, UpdateMyItem,} from "@checkstack/myfeature-common";
// Use the client type from the common packageexport type MyFeatureApi = MyFeatureClient;
export const myFeatureApiRef = createApiRef<MyFeatureApi>("myfeature-api");Create Your Components
Section titled “Create Your Components”src/components/MyFeaturePage.tsx:
import { useEffect, useState } from "react";import { useApi } from "@checkstack/frontend-api";import { myFeatureApiRef, type MyItem } from "../api";import { Button, Card } from "@checkstack/ui";
export const MyFeaturePage = () => { const api = useApi(myFeatureApiRef); const [items, setItems] = useState<MyItem[]>([]);
useEffect(() => { api.getItems().then(setItems); }, [api]);
return ( <div className="p-6"> <h1 className="text-2xl font-bold mb-4">My Features</h1> <div className="grid gap-4"> {items.map((item) => ( <Card key={item.id} className="p-4"> <h3>{item.name}</h3> </Card> ))} </div> </div> );};Register Routes
Section titled “Register Routes”src/index.tsx:
import { createFrontendPlugin } from "@checkstack/frontend-api";import { MyFeaturePage } from "./components/MyFeaturePage";import { myFeatureRoutes, pluginMetadata } from "@checkstack/myfeature-common";
export default createFrontendPlugin({ metadata: pluginMetadata,
// Register routes using typed route definitions routes: [ { route: myFeatureRoutes.routes.home, element: <MyFeaturePage />, }, ],});4. Verify
Section titled “4. Verify”# Type checkbun run typecheck
# Lintbun run lintThat’s it! Your frontend plugin is ready to use.
Note: Make sure you have also created the corresponding
-commonand-backendpackages. See Common Plugin Guidelines and Backend Plugin Development for details.
Plugin Configuration
Section titled “Plugin Configuration”createFrontendPlugin(config)
Section titled “createFrontendPlugin(config)”Creates a frontend plugin with the specified configuration.
Parameters:
metadata (required)
Section titled “metadata (required)”Plugin metadata from the common package (contains pluginId).
import { pluginMetadata } from "@checkstack/myplugin-common";
metadata: pluginMetadataroutes (optional)
Section titled “routes (optional)”Register pages and their routes using RouteDefinitions from the common package.
import { myRoutes } from "@checkstack/myplugin-common";
routes: [ { route: myRoutes.routes.home, element: <ItemListPage />, title: "Items", // Optional: page title accessRule: access.itemRead.id, // Optional: required access rule },]extensions (optional)
Section titled “extensions (optional)”Register components to inject into extension slots.
import { UserMenuItemsSlot } from "@checkstack/frontend-api";
extensions: [ { id: "myplugin.user-menu.items", slot: UserMenuItemsSlot, component: MyUserMenuItems, },]foreignSignals (optional)
Section titled “foreignSignals (optional)”Cross-plugin realtime invalidation. The frontend SignalAutoInvalidator already invalidates [[pluginId]] for every signal owned by your plugin — you do not register your own signals here. Use foreignSignals only when your plugin’s queries embed data owned by another plugin and must refetch when that plugin’s signals fire.
import { SYSTEM_STATUS_CHANGED } from "@checkstack/healthcheck-common";
// dependency-frontend embeds system health status in its queries, so it// must refetch when healthcheck.system.status_changed fires.foreignSignals: [SYSTEM_STATUS_CHANGED],Pass actual Signal objects, not strings. Same-plugin signals must NOT be listed.
See Signals for the full signal architecture.
Using Plugin APIs in Components
Section titled “Using Plugin APIs in Components”Components access plugin APIs using the usePluginClient hook with TanStack Query integration.
Basic Usage
Section titled “Basic Usage”import { usePluginClient } from "@checkstack/frontend-api";import { MyPluginApi } from "@checkstack/myplugin-common";
export const ItemListPage = () => { const client = usePluginClient(MyPluginApi);
// Queries - automatic caching, loading states, deduplication const { data: items, isLoading, error } = client.getItems.useQuery({});
if (isLoading) return <div>Loading...</div>; if (error) return <div>Error: {error.message}</div>;
return ( <div> {items?.map((item) => ( <div key={item.id}>{item.name}</div> ))} </div> );};Mutations with Cache Invalidation
Section titled “Mutations with Cache Invalidation”import { useQueryClient } from "@checkstack/frontend-api";
export const CreateItemForm = () => { const client = usePluginClient(MyPluginApi); const queryClient = useQueryClient();
const createMutation = client.createItem.useMutation({ onSuccess: () => { // Invalidate cache to refetch list queryClient.invalidateQueries({ queryKey: ["myplugin"] }); }, });
const handleSubmit = (data: CreateItem) => { createMutation.mutate(data); };
return ( <form onSubmit={handleSubmit}> <Button disabled={createMutation.isPending}> {createMutation.isPending ? "Creating..." : "Create"} </Button> </form> );};Benefits
Section titled “Benefits”- No Manual State Management: TanStack Query handles loading, error, and data states
- Automatic Request Deduplication: Multiple components using the same query share one request
- Built-in Caching: Configurable stale time and cache duration
- Background Refetching: Stale data is automatically refreshed
- Type Safety: Full TypeScript inference from contract definitions ); };
### Benefits of Contract-Based Clients
1. **No Manual URL Construction**: RPC procedures are called like functions2. **Full Type Safety**: Input and output types inferred from contract3. **Auto-completion**: IDE suggests available procedures and their parameters4. **Compile-Time Errors**: Contract changes immediately break incompatible frontend code5. **No Duplication**: Single source of truth for API definitions
## Core APIs
The core provides these APIs for use in components:
### `usePluginClient`
Access plugin APIs with TanStack Query integration for automatic caching, loading states, and request deduplication:
```typescriptimport { usePluginClient } from "@checkstack/frontend-api";import { MyPluginApi } from "@checkstack/myplugin-common";
const client = usePluginClient(MyPluginApi);
// Queries - automatic caching and loading statesconst { data, isLoading } = client.getItems.useQuery({});
// Mutations - with cache invalidationconst mutation = client.createItem.useMutation({ onSuccess: () => queryClient.invalidateQueries({ queryKey: ["items"] }),});Note: Contracts must include
operationType: "query"oroperationType: "mutation"in their metadata.
Mutation Dependency Hazard
Section titled “Mutation Dependency Hazard”// ❌ BAD - causes infinite loopconst mutation = client.createItem.useMutation();const callback = useMemo(() => {...}, [mutation]); // mutation changes every render!
// ✅ GOOD - mutation is stable when accessed inside the callback, not as a dependencyconst mutation = client.createItem.useMutation();const callback = useMemo(() => { return () => mutation.mutate(data); // eslint-disable-next-line react-hooks/exhaustive-deps}, [/* other stable deps */]);The ESLint rule checkstack/no-mutation-in-deps catches this pattern.
accessApiRef
Section titled “accessApiRef”Check user access rules.
const accessApi = useApi(accessApiRef);
const canManage = accessApi.useAccess(access.itemManage.id);
if (canManage.allowed) { // Show management UI}authApiRef
Section titled “authApiRef”Access authentication state.
const authApi = useApi(authApiRef);
const session = authApi.useSession();
if (session.user) { // User is logged in}Access Gating
Section titled “Access Gating”Route-Level Access
Section titled “Route-Level Access”import { access } from "@checkstack/myplugin-common";
routes: [ { path: "/config", element: <ItemConfigPage />, accessRule: access.itemManage.id, },]Users without access will see an “Access Denied” page.
Component-Level Access
Section titled “Component-Level Access”import { useApi, accessApiRef } from "@checkstack/frontend-api";import { access } from "@checkstack/myplugin-common";
export const ItemListPage = () => { const accessApi = useApi(accessApiRef); const canCreate = accessApi.useAccess(access.itemCreate.id);
return ( <div> <h1>Items</h1> {canCreate.allowed && ( <Button onClick={handleCreate}>Create Item</Button> )} </div> );};Access Loading State
Section titled “Access Loading State”const accessState = accessApi.useAccess(access.itemManage.id);
if (accessState.loading) { return <LoadingSpinner />;}
if (!accessState.allowed) { return <AccessDenied />;}
return <ItemConfigPage />;UI Components
Section titled “UI Components”Use components from @checkstack/ui for consistent styling:
import { Button, Card, Input, Label, Table, Dialog, Select, Checkbox,} from "@checkstack/ui";
export const ItemForm = () => { return ( <Card> <Label htmlFor="name">Name</Label> <Input id="name" placeholder="Enter name" />
<Label htmlFor="description">Description</Label> <Input id="description" placeholder="Enter description" />
<Button type="submit">Save</Button> </Card> );};Extension Slots
Section titled “Extension Slots”Available Slots
Section titled “Available Slots”Core slots are available from @checkstack/frontend-api:
import { DashboardSlot, UserMenuItemsSlot, UserMenuItemsBottomSlot, NavbarRightSlot, NavbarLeftSlot,} from "@checkstack/frontend-api";Injecting into Slots
Section titled “Injecting into Slots”Use the slot: property with a SlotDefinition object:
import { UserMenuItemsSlot } from "@checkstack/frontend-api";
extensions: [ { id: "myplugin.user-menu.items", slot: UserMenuItemsSlot, component: MyUserMenuItems, },]Example: User Menu Items
Section titled “Example: User Menu Items”import { DropdownMenuItem } from "@checkstack/ui";import { Link } from "react-router-dom";
export const MyUserMenuItems = () => { return ( <> <DropdownMenuItem asChild> <Link to="/items/config">Item Settings</Link> </DropdownMenuItem> </> );};Routing
Section titled “Routing”Navigation
Section titled “Navigation”import { Link, useNavigate } from "react-router-dom";
// Using Link<Link to="/items/123">View Item</Link>
// Using navigateconst navigate = useNavigate();navigate("/items/123");Route Parameters
Section titled “Route Parameters”import { useParams } from "react-router-dom";
export const ItemDetailPage = () => { const { id } = useParams<{ id: string }>();
// Use id to fetch item};Query Parameters
Section titled “Query Parameters”import { useSearchParams } from "react-router-dom";
export const ItemListPage = () => { const [searchParams, setSearchParams] = useSearchParams();
const page = searchParams.get("page") || "1"; const filter = searchParams.get("filter") || "";
const handlePageChange = (newPage: number) => { setSearchParams({ page: newPage.toString(), filter }); };};State Management
Section titled “State Management”Local State
Section titled “Local State”import { useState } from "react";
export const ItemForm = () => { const [name, setName] = useState(""); const [description, setDescription] = useState("");
return ( <form> <Input value={name} onChange={(e) => setName(e.target.value)} /> <Input value={description} onChange={(e) => setDescription(e.target.value)} /> </form> );};Server State with RPC
Section titled “Server State with RPC”import { useEffect, useState } from "react";import { useApi } from "@checkstack/frontend-api";import { myPluginApiRef, type Item } from "../api";
export const ItemListPage = () => { const api = useApi(myPluginApiRef); const [items, setItems] = useState<Item[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<Error | null>(null);
useEffect(() => { api .getItems() .then(setItems) .catch(setError) .finally(() => setLoading(false)); }, [api]);
if (loading) return <LoadingSpinner />; if (error) return <ErrorMessage error={error} />;
return <ItemList items={items} />;};Server State with TanStack Query (Recommended)
Section titled “Server State with TanStack Query (Recommended)”The preferred approach is using usePluginClient with TanStack Query integration. This provides automatic caching, deduplication, loading states, and background refetching.
import { usePluginClient } from "@checkstack/frontend-api";import { MyPluginApi } from "@checkstack/myplugin-common";
export const ItemListPage = () => { const client = usePluginClient(MyPluginApi);
// Queries - automatic loading/error states const { data: items, isLoading, error } = client.getItems.useQuery({});
if (isLoading) return <LoadingSpinner />; if (error) return <ErrorMessage error={error} />;
return <ItemList items={items ?? []} />;};useQuery for Data Fetching
Section titled “useQuery for Data Fetching”// Query with no parametersconst { data, isLoading } = client.getItems.useQuery();
// Query with parametersconst { data: item } = client.getItem.useQuery({ id: itemId });
// Query with options (disable until ready)const { data } = client.getItems.useQuery({ filter }, { enabled: !!filter, staleTime: 60_000, // Cache for 1 minute});useMutation for Data Modifications
Section titled “useMutation for Data Modifications”export const ItemForm = () => { const client = usePluginClient(MyPluginApi); const queryClient = useQueryClient();
const createMutation = client.createItem.useMutation({ onSuccess: () => { // Invalidate cache to refetch list queryClient.invalidateQueries({ queryKey: ['items'] }); toast.success("Item created!"); }, onError: (error) => { toast.error(error.message); }, });
const handleSubmit = (data: CreateItem) => { createMutation.mutate(data); };
return ( <form onSubmit={handleSubmit}> <Button disabled={createMutation.isPending}> {createMutation.isPending ? "Creating..." : "Create"} </Button> </form> );};Note: Contracts must include
operationType: "query"oroperationType: "mutation"in their metadata. Queries expose.useQuery(), mutations expose.useMutation().
Basic Form
Section titled “Basic Form”import { useState } from "react";import { Button, Input, Label } from "@checkstack/ui";
export const ItemForm = ({ onSubmit }: { onSubmit: (data: ItemData) => void }) => { const [name, setName] = useState(""); const [description, setDescription] = useState("");
const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); onSubmit({ name, description }); };
return ( <form onSubmit={handleSubmit}> <div> <Label htmlFor="name">Name</Label> <Input id="name" value={name} onChange={(e) => setName(e.target.value)} required /> </div>
<div> <Label htmlFor="description">Description</Label> <Input id="description" value={description} onChange={(e) => setDescription(e.target.value)} /> </div>
<Button type="submit">Save</Button> </form> );};Form with Validation
Section titled “Form with Validation”import { z } from "zod";
const itemSchema = z.object({ name: z.string().min(1, "Name is required"), description: z.string().optional(),});
export const ItemForm = () => { const [errors, setErrors] = useState<Record<string, string>>({});
const handleSubmit = (e: React.FormEvent) => { e.preventDefault();
const result = itemSchema.safeParse({ name, description });
if (!result.success) { const fieldErrors: Record<string, string> = {}; result.error.errors.forEach((err) => { if (err.path[0]) { fieldErrors[err.path[0].toString()] = err.message; } }); setErrors(fieldErrors); return; }
onSubmit(result.data); };
return ( <form onSubmit={handleSubmit}> <div> <Label htmlFor="name">Name</Label> <Input id="name" value={name} onChange={(e) => setName(e.target.value)} /> {errors.name && <span className="text-red-500">{errors.name}</span>} </div> </form> );};Common Patterns
Section titled “Common Patterns”List Page with RPC
Section titled “List Page with RPC”import { useApi } from "@checkstack/frontend-api";import { myPluginApiRef, type Item } from "../api";
export const ItemListPage = () => { const api = useApi(myPluginApiRef); const [items, setItems] = useState<Item[]>([]); const navigate = useNavigate();
useEffect(() => { api.getItems().then(setItems); }, [api]);
const handleDelete = async (id: string) => { await api.deleteItem(id); setItems(items.filter(item => item.id !== id)); };
return ( <div className="p-6"> <div className="flex justify-between items-center mb-4"> <h1 className="text-2xl font-bold">Items</h1> <Button onClick={() => navigate("/items/new")}>Create Item</Button> </div>
<Table> <TableHeader> <TableRow> <TableHead>Name</TableHead> <TableHead>Description</TableHead> <TableHead>Actions</TableHead> </TableRow> </TableHeader> <TableBody> {items.map((item) => ( <TableRow key={item.id}> <TableCell>{item.name}</TableCell> <TableCell>{item.description}</TableCell> <TableCell> <Button onClick={() => navigate(`/items/${item.id}`)}> View </Button> <Button onClick={() => handleDelete(item.id)} variant="destructive"> Delete </Button> </TableCell> </TableRow> ))} </TableBody> </Table> </div> );};Detail Page
Section titled “Detail Page”export const ItemDetailPage = () => { const { id } = useParams<{ id: string }>(); const api = useApi(myPluginApiRef); const [item, setItem] = useState<Item | null>(null);
useEffect(() => { if (id) { api.getItem(id).then(setItem); } }, [id, api]);
if (!item) return <LoadingSpinner />;
return ( <div className="p-6"> <h1 className="text-2xl font-bold">{item.name}</h1> <p>{item.description}</p> </div> );};Create/Edit Page
Section titled “Create/Edit Page”export const ItemEditPage = () => { const { id } = useParams<{ id: string }>(); const api = useApi(myPluginApiRef); const navigate = useNavigate(); const [item, setItem] = useState<Item | null>(null);
useEffect(() => { if (id) { api.getItem(id).then(setItem); } }, [id, api]);
const handleSubmit = async (data: CreateItem) => { if (id) { await api.updateItem({ id, data }); } else { await api.createItem(data); } navigate("/items"); };
return ( <div className="p-6"> <h1 className="text-2xl font-bold"> {id ? "Edit Item" : "Create Item"} </h1> <ItemForm initialData={item} onSubmit={handleSubmit} /> </div> );};Best Practices
Section titled “Best Practices”1. Use TypeScript
Section titled “1. Use TypeScript”Always type your components and APIs:
interface ItemListProps { items: Item[]; onItemClick: (id: string) => void;}
export const ItemList: React.FC<ItemListProps> = ({ items, onItemClick }) => { // ...};2. Extract Reusable Components
Section titled “2. Extract Reusable Components”export const ItemCard = ({ item }: { item: Item }) => { return ( <Card> <h3>{item.name}</h3> <p>{item.description}</p> </Card> );};3. Handle Loading and Error States
Section titled “3. Handle Loading and Error States”if (loading) return <LoadingSpinner />;if (error) return <ErrorMessage error={error} />;if (!data) return <EmptyState />;4. Use Semantic HTML
Section titled “4. Use Semantic HTML”<main> <header> <h1>Items</h1> </header> <section> <ItemList items={items} /> </section></main>5. Accessibility
Section titled “5. Accessibility”<Button aria-label="Delete item"> <TrashIcon /></Button>
<Input id="name" aria-describedby="name-error" aria-invalid={!!errors.name}/>6. Leverage Contract Types
Section titled “6. Leverage Contract Types”Import types from the common package instead of redefining them:
// ✅ Good - Use contract typesimport type { Item, CreateItem } from "@checkstack/myplugin-common";
// ❌ Bad - Duplicate type definitionsinterface Item { id: string; name: string; // ...}Testing
Section titled “Testing”Component Tests
Section titled “Component Tests”import { describe, expect, test } from "bun:test";import { render, screen } from "@testing-library/react";import { ItemCard } from "./ItemCard";
describe("ItemCard", () => { test("renders item name", () => { const item = { id: "1", name: "Test Item" }; render(<ItemCard item={item} />); expect(screen.getByText("Test Item")).toBeInTheDocument(); });});E2E Tests with Playwright
Section titled “E2E Tests with Playwright”import { test, expect } from "@playwright/test";
test("create item", async ({ page }) => { await page.goto("/items"); await page.click("text=Create Item"); await page.fill("#name", "New Item"); await page.click("text=Save"); await expect(page.locator("text=New Item")).toBeVisible();});Troubleshooting
Section titled “Troubleshooting”API Not Found
Section titled “API Not Found”Check that:
- The Api definition is exported from the
-commonpackage - Contract’s
pluginIdmatches backend router registration name - Backend for this plugin is running
Type Errors with Contract
Section titled “Type Errors with Contract”If TypeScript complains about contract types:
- Ensure you’re importing from the
-commonpackage - Verify the
*Apidefinition and contract are exported fromsrc/index.ts - Clear TypeScript cache:
rm -rf tsconfig.tsbuildinfo - Restart the TypeScript language server
Routes Not Working
Section titled “Routes Not Working”Check that:
- Routes are registered in plugin
routesarray - Route definitions use
route:with RouteDefinition from common - Element is a valid React component
Access Errors
Section titled “Access Errors”Check that:
- Access rule ID matches backend access rule
- User has required role/access
- Access check is not in loading state
404 Errors from Backend
Section titled “404 Errors from Backend”If RPC calls return 404:
- Verify backend router is registered with correct plugin ID
- Ensure frontend uses the correct Api definition that matches backend pluginId
- Check backend plugin is loaded (check backend logs)
Dynamic Plugin Loading
Section titled “Dynamic Plugin Loading”Frontend plugins can be loaded and unloaded at runtime without a page refresh. When a frontend plugin is installed or uninstalled on the backend, the platform broadcasts a signal to all connected frontends, triggering automatic UI updates.
Architecture
Section titled “Architecture”sequenceDiagram participant Admin as Admin UI participant Backend as Backend participant Signal as Signal Service participant Frontend as Frontend participant Registry as Plugin Registry
Admin->>Backend: Install plugin Backend->>Backend: Load plugin Backend->>Signal: Broadcast PLUGIN_INSTALLED Signal->>Frontend: WebSocket signal Frontend->>Frontend: loadSinglePlugin() Frontend->>Registry: register(plugin) Registry->>Frontend: Re-render UIHow It Works
Section titled “How It Works”-
Signal Emission: The backend emits
PLUGIN_INSTALLEDorPLUGIN_DEREGISTEREDsignals only for frontend plugins (those ending with-frontend) -
Frontend Signal Subscription: The
usePluginLifecyclehook listens for these signals:// Automatically handled in App.tsx via usePluginLifecycle()useSignal(PLUGIN_INSTALLED, ({ pluginId }) => {loadSinglePlugin(pluginId); // Dynamically loads JS/CSS});useSignal(PLUGIN_DEREGISTERED, ({ pluginId }) => {unloadPlugin(pluginId); // Removes from registry}); -
Registry Updates: When a plugin is loaded/unloaded, the
pluginRegistryincrements its version, triggering React re-renders to pick up new routes and extensions.
Plugin Registry Reactivity
Section titled “Plugin Registry Reactivity”The pluginRegistry supports dynamic updates:
// Subscribe to registry changespluginRegistry.subscribe(() => { console.log("Registry changed, re-render!");});
// Check if a plugin is registeredif (pluginRegistry.hasPlugin("my-plugin-frontend")) { // Plugin is available}
// Get current version (increments on every change)const version = pluginRegistry.getVersion();Signals Used
Section titled “Signals Used”| Signal | Payload | Description |
|---|---|---|
PLUGIN_INSTALLED | { pluginId: string } | Frontend plugin was installed |
PLUGIN_DEREGISTERED | { pluginId: string } | Frontend plugin was removed |
Note: Only plugins ending with
-frontendtrigger signals. Backend-only plugins are not signaled to the frontend.
Distributing your plugin
Section titled “Distributing your plugin”When your -frontend package is part of a multi-package plugin (most are
— they ship alongside a -backend and possibly a -common), it gets
packed into a bundle by the runtime distribution tooling. See
Plugin Distribution & Packing
for how the primary package’s checkstack.bundle field declares siblings,
how bunx @checkstack/scripts plugin-pack --bundle produces a single
bundle tarball, and the npm / GitHub release / GitHub Enterprise /
tarball-upload distribution channels operators can install from.
For local development, add @checkstack/dev-server as a devDependency,
wire "dev": "checkstack-dev" into your package.json scripts, and
run bun run dev from your plugin’s repo — see
Developing Plugins in Isolation.
Next Steps
Section titled “Next Steps”- Developing Plugins in Isolation - Running Checkstack locally for plugin dev
- Plugin Distribution & Packing - How to ship your plugin to operators
- Backend Plugin Development
- Common Plugin Guidelines
- Extension Points
- UI Component Library