A starter template for building a kandev native-UI plugin — its own git repo, packaged into a versioned tarball and installed against a running kandev instance. Click “Use this template” on GitHub (or copy this repo) to bootstrap your own plugin.
It is a small, complete example of the core plugin surfaces, wired together so you can delete what you do not need rather than assemble it from scratch:
- Native nav item + route —
ui/bundle.jsadds a sidebar entry that opens/template, a page rendered natively inside the kandev SPA (not an iframe) using the host's own React instance. - A page built from host components — one
Cardcontaining aPopover(host-positioned, nogetBoundingClientRectmath), aProgressbar, aTablethat swaps toEmptywhen there's nothing to show, andKbdfor a keyboard hint. Each part is independent: delete the ones you don't want. - Toasts — the page's Clear button calls
host.toast.successandhost.toast.error. The host mounts the single<Toaster/>, so there is nothing to render and it works from anywhere, modals included. - Locale-aware timestamps —
host.utils.formatRelativeTimerenders each row's "seen" column throughIntl.RelativeTimeFormat, andhost.utils.cnmerges conditional classes the same way the host components do. - Live theme —
host.onThemeChangekeeps a readout in the popover current when the user flips light/dark. - Chat toolbar action — a component registered into the
chat-input-actionsslot renders an icon button in the chat composer toolbar, with the current{ sessionId, taskId, taskTitle }asslotProps. - Live WS-driven page — a
registerWsHandler("task.created", ...)handler updates module state that the page re-renders from, live, with no reload. - Backend event handling with Host state —
OnEventcountstask.createddeliveries in a persistent counter via theHost.GetState/SetStateround trip, so restarts don't reset it. - Backend webhook —
HandleWebhookanswers thepingwebhook kandev proxies to the plugin, building its reply from the operator settings. Itsaccess:is declared explicitly — see Webhook access below. - Operator settings (
config_schema) — agreetingstring and a secretapi_token, rendered as a form at Settings > Plugins > Template Plugin and read by the plugin process viahost.GetConfig(ctx). Secret fields are vault-stored and masked everywhere outside the plugin process.
Source-control providers need several newer hooks that would swamp this default
starter. The compile-tested provider-neutral source-control recipe
shows repository search/paging/inspection/branches/create, task Link and unlink,
review status and responsive Review UI, composer # references, cancellation,
and unload fencing. It is opt-in and intentionally excluded from the generated
package until you copy its declarations and adapters into your plugin.
The plugin id appears in four places that must stay in sync. Rename all of
them from kandev-plugin-template to your own id (e.g. kandev-plugin-acme):
manifest.yaml—id, plusdisplay_name/description/author.go.mod— themoduleline.Makefile—BINandPKG_OUT(andVERSIONto match the manifest).ui/bundle.js— the id passed towindow.registerKandevPlugin(...).
Then trim the scaffolding: drop the webhook / event / config blocks in
manifest.yaml you don't use, delete the matching handlers in
server/plugin.go, and keep only the registry.register* calls in
ui/bundle.js your plugin actually contributes. Update server/plugin_test.go
to cover what remains.
If you are building a Git provider, opt into recipes/source-control/ as one
cohesive slice. Do not activate only its UI or only its manifest fragment:
provider ownership, declared actions, backend optional interfaces, and frontend
registrations are one contract.
The page in ui/bundle.js is deliberately built from independent parts —
AboutPopover, the Progress block, RecentTasksTable, EmptyState, the
Clear button — so you can delete any of them without unpicking the others.
Adjust min_kandev_version in manifest.yaml to match whatever you keep.
initialize(registry, host) hands you host.React (and host.jsx, an alias
for host.React.createElement). Use it. Never import or bundle your own
React: a second React instance has its own hook dispatcher and context
registry, so host components rendered inside your tree lose their providers,
refs break, and asChild stops composing.
The same hazard applies to recharts, which is why the host exposes
ChartContainer, ChartTooltip, ChartTooltipContent, ChartLegend,
ChartLegendContent and ChartStyle through host.ui rather than leaving you
to install it: recharts resolves its tooltips and legends through its own React
context and renders them into portals, so a bundled second copy splits exactly
what the host copy is holding. It costs nothing to use the host's — recharts is
already a dependency of the app. The same reasoning covers every
Radix/portal/context-based package. Pure-React libraries (@tabler/icons-react,
say) bundle fine, but this template ships hand-drawn inline SVG so it needs no
bundler at all.
host.ui is much broader than what this template uses — Accordion*,
Collapsible*, Select*, Tabs*, Sheet*, Pagination*, ScrollArea,
Skeleton, Switch, Spinner, TooltipProvider, plus kandev's own
PageTopbar, Combobox and TaskCreateDialog. Check it before hand-rolling a
component: the published plugins that hand-rolled progress bars and popovers
did so only because this template used to stop at Button. The authoritative
list is PLUGIN_UI in apps/web/lib/plugins/host-api.ts.
manifest.yaml's example webhook sets access: explicitly:
webhooks:
- key: "ping"
method: "POST"
access: "public"The field is optional and currently defaults to public, but an open kandev PR
proposes inverting that default to authenticated. A manifest that omits it is
one whose security posture silently changes on a host upgrade; a manifest that
declares it means the same thing under either default. Choose per webhook:
access: |
Caller | Body limit |
|---|---|---|
public |
anonymous — GitHub, Slack, Stripe, any third party delivering to you. Verify the caller yourself (signature header, or a shared secret in a secret: true config field). |
4 MiB |
authenticated |
needs a kandev identity (session cookie or PAT) — your own scripts and CI. | 16 MiB |
If you want to call your plugin from your own frontend bundle, neither is
right: declare an actions: entry instead. The host authenticates and
authorizes those and passes a verified resource context to your backend, and
host.api.fetch is documented as MUST NOT target a public webhook path.
manifest.yaml declares min_kandev_version: "0.86.0" — the first release
carrying the host.ui primitives, host.toast and host.utils that
ui/bundle.js calls. A release host compares it against its own version at
install time and refuses an older one, so an operator gets a clear error
instead of a plugin that loads and then breaks on a missing API.
Raise it as you adopt newer host APIs; lower or drop it if you strip the bundle
back to the pre-0.86 surface (Button, Card*, Tooltip*).
The source-control recipe uses post-v0.87.1 contracts and therefore cannot use
this default 0.86.0 floor. Its README records the pinned source contract; set
an activated provider plugin's minimum to the first release containing those
hooks.
Two caveats worth knowing:
- The check only became load-bearing in 0.86.0. Earlier hosts parsed the field and ignored it, so it can't protect you from a host old enough to have that bug — a reason to declare it, not to skip it.
- It is release-only by design. A host built from a git checkout reports a
git-describe version like
v0.87.1-27-g4705f1fd0, which isn't a release version, so the gate is skipped and the install succeeds whatever floor you declare. A successful sideload onto your dev instance is not evidence that your floor is correct — check it against the kandev history instead (git merge-base --is-ancestor <commit> <tag>).
kandev spawns the platform-matching binary from runtime.executables in
manifest.yaml as a subprocess and talks to it over a private gRPC connection
(hashicorp/go-plugin) — there is no
HTTP listen address, no shared secret, and no manual wiring: pluginsdk.Serve
in server/main.go owns the entire transport. The base interface has two RPCs
and gives you a Host handle back:
type Plugin interface {
OnEvent(ctx context.Context, e *Event) error
HandleWebhook(ctx context.Context, req *WebhookRequest) (*WebhookResponse, error)
}server/plugin.go's templatePlugin embeds pluginsdk.UnimplementedPlugin
(a no-op default for both RPCs, plus Host()/SetHost() accessors) and
overrides what it needs. server/main.go is just
pluginsdk.Serve(&templatePlugin{}).
Newer features are additive optional interfaces. For example, the
source-control recipe implements pluginsdk.ActionHandler,
pluginsdk.EntityReferenceSearcher, and
pluginsdk.EntityReferenceAuthorizer; a provider that needs transient Git
credentials can separately implement GitCredentialResolver and
GitCredentialBinder. Embedding the no-op base preserves the minimal plugin.
pkg/pluginsdk is not published as its own module yet, so go.mod here uses a
local replace:
replace github.com/kandev/kandev => ../kandev/apps/backend
This assumes your plugin repo is checked out as a sibling of the kandev
monorepo:
some-dir/
├── kandev/ # https://github.com/kdlbs/kandev, Go module at apps/backend/
└── kandev-plugin-template/ # this repo
Note the module root is kandev/apps/backend, not the repo root — kandev is
a monorepo and the Go backend (including pkg/pluginsdk) lives one level down.
Adjust the replace path if your layout differs. Once pkg/pluginsdk ships as
a standalone, versioned module, this repo will drop the replace and pin a
real version instead.
The frontend recipe follows the same temporary source-checkout model through
@kandev/plugin-sdk in package.json. It is a runtime-free type dependency:
the recipe uses import type, and the default ui/bundle.js remains a
dependency-free ES module. CI pins both SDK contracts to the same Kandev source
revision.
manifest.yaml # plugin manifest — id, capabilities, runtime.executables, ui.bundle, config_schema
server/
main.go # pluginsdk.Serve wiring — no flags, no HTTP, no secrets
plugin.go # templatePlugin: OnEvent / HandleWebhook
plugin_test.go # tests against a fake Host, no subprocess spawn needed
ui/
bundle.js # hand-written, no-build ES module — the plugin's frontend half
recipes/source-control/
manifest.fragment.yaml
server/ # provider-neutral backend ports and contract tests
ui/ # typed native registrations and lifecycle tests
package.json # recipe-only TypeScript tests/types; no production bundle
tsconfig.recipes.json # strict public-SDK contract check
ui/bundle.js is hand-written, dependency-free ES module JavaScript. There is
no build step: it ships byte-for-byte inside the package tar.gz, and kandev
serves it directly. Edit the file and repackage — nothing else to run.
Install the recipe-only development dependencies once with
npm ci --ignore-scripts; nothing from node_modules enters the plugin
package.
make build # go build -o bin/... ./server/...
make test # base + recipe Go/TypeScript tests
make vet # base + recipe Go vet
make verify-package-host # validate a host-only tarball and checksumsNote: bare
go build ./server/...(no-o) fails withbuild output "server" already exists and is a directory— Go's default output name for a lone main package is the last path element ("server"), which collides with theserver/source directory. Always pass-o, rungo build .from insideserver/, or usemake build.go vet/go testare unaffected.
make package # cross-compiles linux/darwin (amd64+arm64) + windows/amd64,
# then packs manifest + ui/ + binaries into a versioned .tar.gz
make package-host # host platform only — faster local iteration
make verify-package # build + validate the five-platform archiveBoth stage manifest.yaml + ui/ alongside the freshly built
server/plugin-<goos>-<goarch>[.exe] binaries, then pack the tree with
kandev's cmd/plugin-pack, which computes checksums.txt and writes the
tarball.
Note the Makefile runs plugin-pack with cd $(KANDEV_SDK) && go run ./cmd/plugin-pack, from inside the sibling kandev checkout, rather than as
go run github.com/kandev/kandev/cmd/plugin-pack from here. The second
spelling resolves plugin-pack's dependencies against this module's go.sum,
and plugin-pack reaches much further into the kandev backend than server/
does — so those entries are missing and packaging fails with missing go.sum entry. Pulling them in would force this template's go.sum to track every
dependency the kandev backend grows. Building the tool where it lives keeps
go.sum scoped to what your plugin actually imports.
Either through the UI (Settings > Plugins > Install plugin, URL or file upload), or directly:
curl -F package=@kandev-plugin-template-0.1.0.tar.gz \
http://localhost:<kandev-port>/api/plugins/installkandev verifies checksums.txt, validates the manifest, extracts the package,
spawns the host-matching binary, and — once the go-plugin handshake completes —
marks the plugin active. Sideloaded plugins register disabled/unverified;
enable yours in Settings > Plugins (the plugins feature flag must be on).
Reinstalling the same version returns 409 — bump version in manifest.yaml.
Pull requests run .github/workflows/ci.yml (tidy, format, vet, and test) and
.github/workflows/build.yml (host build plus a five-platform package). Push a
tag that matches the manifest version to run .github/workflows/release.yml:
it repeats verification, cross-compiles all platforms, packs the tarball, and
creates a GitHub Release with the two assets the kandev
marketplace
install pipeline expects:
<id>-<version>.tar.gz— the plugin package (with its own internalchecksums.txtverified on install), andchecksums.txt— the package's internal file checksums, extracted from the tarball for inspection and marketplace tooling.
# bump VERSION in Makefile + version in manifest.yaml first, then:
git tag v0.1.0
git push origin v0.1.0The workflows check out the kandev monorepo as a sibling so the local Go and TypeScript SDK paths resolve (see "Developing against the SDK"). They pin one source revision for reproducible provider contracts; advance that pin deliberately and rerun both contract suites when adopting a newer SDK.
MIT — see LICENSE. This template is meant to be copied and made your own; your resulting plugin can carry whatever license you choose.