feat(plugins): add voice extraction host APIs (#2548)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-12 11:14:50 +02:00
committed by GitHub
parent 723c140013
commit 6033d5717c
40 changed files with 1609 additions and 283 deletions
@@ -144,6 +144,30 @@ func TestEnabledModePATAuthenticates(t *testing.T) {
}
}
func TestEnabledModePATAuthenticatesPluginWebhook(t *testing.T) {
svc := newTestService(t, true)
setupAdmin(t, svc)
_, pat, err := svc.MintToken(context.Background(), userstore.DefaultUserID, "plugin-ui", 0)
if err != nil {
t.Fatal(err)
}
router := newTestRouter(svc)
router.POST("/api/plugins/:id/webhooks/:key", func(c *gin.Context) {
if _, ok := authn.FromGin(c); !ok {
c.JSON(http.StatusInternalServerError, gin.H{"error": "no identity"})
return
}
c.Status(http.StatusOK)
})
rec := doRequest(router, http.MethodPost, "/api/plugins/p1/webhooks/transcribe", func(r *http.Request) {
r.Header.Set("Authorization", "Bearer "+pat)
})
if rec.Code != http.StatusOK {
t.Fatalf("authenticated plugin webhook: %d body=%s", rec.Code, rec.Body.String())
}
}
// TestEnabledModeAllowlistMatrix pins the pass/deny policy for every path
// class from the plan's allowlist spec. 404 = middleware passed through
// (route unregistered), 401 = middleware denied.
+31 -10
View File
@@ -11,7 +11,9 @@ import (
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/auth/authn"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/pkgtar"
"github.com/kandev/kandev/internal/plugins/store"
"github.com/kandev/kandev/pkg/pluginsdk"
@@ -24,7 +26,7 @@ import (
// use to exhaust backend memory. 4 MiB comfortably covers realistic webhook
// payloads (GitHub/Slack/Jira event bodies are KB-sized) while bounding
// worst-case memory use per request.
const maxWebhookBodyBytes = 4 << 20 // 4 MiB
const maxWebhookBodyBytes = manifest.DefaultWebhookMaxBodyBytes
// Controller holds the plugin HTTP handlers: operator-facing management
// (install/list/get/config/uninstall/enable/disable), the bundle/UI
@@ -372,12 +374,19 @@ func (c *Controller) webhook(ctx *gin.Context) {
}
key := ctx.Param("key")
if !manifestDeclaresWebhookKey(record, key) {
declaration, ok := findWebhookDeclaration(record, key)
if !ok {
ctx.JSON(http.StatusNotFound, gin.H{"error": fmt.Sprintf("plugin %q has no webhook %q", id, key)})
return
}
if declaration.EffectiveAccess() == manifest.WebhookAccessAuthenticated {
if _, authenticated := authn.FromGin(ctx); !authenticated {
ctx.JSON(http.StatusUnauthorized, gin.H{"error": "authentication required"})
return
}
}
body, err := readCappedWebhookBody(ctx)
body, err := readCappedWebhookBody(ctx, declaration.EffectiveMaxBodyBytes())
if err != nil {
return // readCappedWebhookBody already wrote the error response
}
@@ -386,7 +395,7 @@ func (c *Controller) webhook(ctx *gin.Context) {
WebhookKey: key,
Method: ctx.Request.Method,
Query: ctx.Request.URL.RawQuery,
Headers: flattenHeaders(ctx.Request.Header),
Headers: flattenWebhookHeaders(ctx.Request.Header),
Body: body,
}
@@ -479,13 +488,18 @@ func (c *Controller) applyAuthLogin(ctx *gin.Context, record *store.Record, raw
// manifestDeclaresWebhookKey reports whether record's manifest declares a
// webhooks[] entry with the given key.
func manifestDeclaresWebhookKey(record *store.Record, key string) bool {
func findWebhookDeclaration(record *store.Record, key string) (manifest.Webhook, bool) {
for _, wh := range record.Webhooks {
if wh.Key == key {
return true
return wh, true
}
}
return false
return manifest.Webhook{}, false
}
func manifestDeclaresWebhookKey(record *store.Record, key string) bool {
_, ok := findWebhookDeclaration(record, key)
return ok
}
// readCappedWebhookBody reads ctx.Request.Body bounded at
@@ -493,14 +507,14 @@ func manifestDeclaresWebhookKey(record *store.Record, key string) bool {
// itself (and returning a non-nil error as a sentinel to the caller) when
// the body exceeds the cap, so a single external webhook POST cannot
// exhaust backend memory via an unbounded io.ReadAll.
func readCappedWebhookBody(ctx *gin.Context) ([]byte, error) {
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxWebhookBodyBytes)
func readCappedWebhookBody(ctx *gin.Context, maxBytes int64) ([]byte, error) {
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxBytes)
body, err := io.ReadAll(ctx.Request.Body)
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
ctx.JSON(http.StatusRequestEntityTooLarge, gin.H{
"error": fmt.Sprintf("webhook body exceeds max size of %d bytes", maxWebhookBodyBytes),
"error": fmt.Sprintf("webhook body exceeds max size of %d bytes", maxBytes),
})
return nil, err
}
@@ -521,3 +535,10 @@ func flattenHeaders(h http.Header) map[string]string {
}
return out
}
func flattenWebhookHeaders(h http.Header) map[string]string {
safe := h.Clone()
safe.Del("Authorization")
safe.Del("Cookie")
return flattenHeaders(safe)
}
+71 -1
View File
@@ -387,6 +387,33 @@ runtime:
return &buf
}
func authenticatedWebhookPackage(t *testing.T, id, key string, maxBodyBytes int64) *bytes.Buffer {
t.Helper()
platformKey := goruntime.GOOS + "-" + goruntime.GOARCH
manifestYAML := fmt.Sprintf(`
id: %s
api_version: 1
version: "1.0.0"
display_name: Test Plugin
webhooks:
- key: %s
access: authenticated
max_body_bytes: %d
runtime:
type: binary
executables:
%s: server/plugin
`, id, key, maxBodyBytes, platformKey)
var buf bytes.Buffer
if err := pkgtartest.WritePackage(&buf, map[string][]byte{
"manifest.yaml": []byte(manifestYAML),
"server/plugin": []byte("#!/bin/sh\necho fake\n"),
}); err != nil {
t.Fatalf("WritePackage: %v", err)
}
return &buf
}
func TestWebhookHandlerUnknownPluginReturns404(t *testing.T) {
router, _ := newTestRouter(t)
rec := doRequest(router, http.MethodPost, "/api/plugins/missing/webhooks/key1", "{}", nil)
@@ -420,13 +447,56 @@ func TestWebhookHandlerOversizedBodyReturns413(t *testing.T) {
t.Fatalf("Install: %v", err)
}
oversized := strings.Repeat("a", maxWebhookBodyBytes+1)
oversized := strings.Repeat("a", int(maxWebhookBodyBytes+1))
rec := doRequest(router, http.MethodPost, "/api/plugins/kandev-plugin-slack/webhooks/key1", oversized, nil)
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, want 413, body=%s", rec.Code, rec.Body.String())
}
}
func TestAuthenticatedWebhookRejectsAnonymousRequest(t *testing.T) {
router, svc := newTestRouter(t)
if _, err := svc.Install(t.Context(), authenticatedWebhookPackage(t, "kandev-plugin-voice", "transcribe", 16<<20)); err != nil {
t.Fatalf("Install: %v", err)
}
rec := doRequest(router, http.MethodPost, "/api/plugins/kandev-plugin-voice/webhooks/transcribe", "audio", nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401, body=%s", rec.Code, rec.Body.String())
}
}
func TestReadCappedWebhookBodyUsesDeclaredLimit(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest(http.MethodPost, "/", strings.NewReader("12345"))
_, err := readCappedWebhookBody(ctx, 4)
if err == nil || rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("error = %v, status = %d, want size error and 413", err, rec.Code)
}
}
func TestWebhookRelayHeadersStripHostCredentials(t *testing.T) {
headers := http.Header{
"Authorization": []string{"Bearer kandev-pat"},
"Cookie": []string{"kandev_session=secret"},
"Content-Type": []string{"audio/webm"},
"X-Plugin-Key": []string{"plugin-secret"},
}
got := flattenWebhookHeaders(headers)
if _, ok := got["Authorization"]; ok {
t.Fatal("Authorization header was forwarded to plugin")
}
if _, ok := got["Cookie"]; ok {
t.Fatal("Cookie header was forwarded to plugin")
}
if got["Content-Type"] != "audio/webm" || got["X-Plugin-Key"] != "plugin-secret" {
t.Fatalf("relay-safe headers = %#v", got)
}
}
func TestWebhookHandlerNotRunningReturns503(t *testing.T) {
router, svc := newTestRouter(t)
if _, err := svc.Install(t.Context(), webhookPackage(t, "kandev-plugin-slack", "key1")); err != nil {
@@ -111,9 +111,32 @@ type AuthProvider struct {
// Webhook is a proxied external webhook endpoint the plugin declares.
type Webhook struct {
Key string `yaml:"key" json:"key"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Method string `yaml:"method,omitempty" json:"method,omitempty"`
Key string `yaml:"key" json:"key"`
Description string `yaml:"description,omitempty" json:"description,omitempty"`
Method string `yaml:"method,omitempty" json:"method,omitempty"`
Access string `yaml:"access,omitempty" json:"access,omitempty"`
MaxBodyBytes int64 `yaml:"max_body_bytes,omitempty" json:"max_body_bytes,omitempty"`
}
const (
WebhookAccessPublic = "public"
WebhookAccessAuthenticated = "authenticated"
DefaultWebhookMaxBodyBytes int64 = 4 << 20
MaximumWebhookMaxBodyBytes int64 = 16 << 20
)
func (w Webhook) EffectiveAccess() string {
if w.Access == "" {
return WebhookAccessPublic
}
return w.Access
}
func (w Webhook) EffectiveMaxBodyBytes() int64 {
if w.MaxBodyBytes == 0 {
return DefaultWebhookMaxBodyBytes
}
return w.MaxBodyBytes
}
// UISection declares UI pages the plugin contributes, and/or a native UI
@@ -455,6 +455,36 @@ func TestParseKeybindingCombo_RejectsNoNonModifierKey(t *testing.T) {
}
}
func TestWebhookAccessAndBodyLimitValidation(t *testing.T) {
tests := []struct {
name string
webhook Webhook
wantErr string
}{
{name: "legacy defaults", webhook: Webhook{Key: "transcribe"}},
{name: "authenticated 16 MiB", webhook: Webhook{Key: "transcribe", Access: "authenticated", MaxBodyBytes: 16 << 20}},
{name: "public over default ceiling", webhook: Webhook{Key: "transcribe", Access: "public", MaxBodyBytes: (4 << 20) + 1}, wantErr: "authenticated"},
{name: "unknown access", webhook: Webhook{Key: "transcribe", Access: "private"}, wantErr: "access"},
{name: "over host ceiling", webhook: Webhook{Key: "transcribe", MaxBodyBytes: (16 << 20) + 1}, wantErr: "max_body_bytes"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := validManifest(t)
m.Webhooks = []Webhook{tt.webhook}
err := m.Validate()
if tt.wantErr == "" {
if err != nil {
t.Fatalf("Validate() unexpected error: %v", err)
}
return
}
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() error = %v, want substring %q", err, tt.wantErr)
}
})
}
}
func TestParseKeybindingCombo_RejectsUnknownModifier(t *testing.T) {
err := parseKeybindingCombo("hyper+k")
if err == nil {
@@ -395,6 +395,15 @@ func (m *Manifest) validateWebhooks() []error {
seen := make(map[string]bool, len(m.Webhooks))
var errs []error
for _, wh := range m.Webhooks {
if access := wh.EffectiveAccess(); access != WebhookAccessPublic && access != WebhookAccessAuthenticated {
errs = append(errs, fmt.Errorf("webhook %q access %q is invalid", wh.Key, wh.Access))
}
if wh.MaxBodyBytes < 0 || wh.MaxBodyBytes > MaximumWebhookMaxBodyBytes {
errs = append(errs, fmt.Errorf("webhook %q max_body_bytes must be between 1 and %d when set", wh.Key, MaximumWebhookMaxBodyBytes))
}
if wh.EffectiveAccess() == WebhookAccessPublic && wh.MaxBodyBytes > DefaultWebhookMaxBodyBytes {
errs = append(errs, fmt.Errorf("webhook %q must use authenticated access when max_body_bytes exceeds %d", wh.Key, DefaultWebhookMaxBodyBytes))
}
if seen[wh.Key] {
errs = append(errs, fmt.Errorf("duplicate webhook key %q", wh.Key))
continue
+4 -1
View File
@@ -247,6 +247,9 @@ func Serve(p Plugin, opts ...Option) {
hcplugin.Serve(&hcplugin.ServeConfig{
HandshakeConfig: Handshake,
Plugins: map[string]hcplugin.Plugin{PluginMapKey: gp},
GRPCServer: hcplugin.DefaultGRPCServer,
GRPCServer: func(options []grpc.ServerOption) *grpc.Server {
// The webhook HTTP body may be 16 MiB; leave room for protobuf metadata.
return grpc.NewServer(append(options, grpc.MaxRecvMsgSize(17<<20))...)
},
})
}
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
computeDisabledReason,
isNativeSubmitDisabled,
resolveDisabledReason,
REASON_NO_COMPATIBLE_AGENT,
REASON_TITLE,
@@ -47,6 +48,20 @@ function makeProps(
};
}
describe("isNativeSubmitDisabled", () => {
it("matches pending uploads and other externally blocked native submissions", () => {
expect(isNativeSubmitDisabled(makeProps({ submitBlockedReason: "upload pending" }))).toBe(true);
});
it("matches missing-profile rejection for the start-task path", () => {
expect(isNativeSubmitDisabled(makeProps({ agentProfileId: "" }))).toBe(true);
});
it("allows a complete native submission", () => {
expect(isNativeSubmitDisabled(makeProps())).toBe(false);
});
});
describe("computeDisabledReason (start-task)", () => {
it("returns null when nothing is missing", () => {
expect(computeDisabledReason(makeProps(), KIND_START)).toBeNull();
@@ -357,6 +357,12 @@ function computeFooterState(props: TaskCreateDialogFooterProps) {
return { showStartTask, splitDisabled, altDisabled, defaultDisabled, disabledReason };
}
export function isNativeSubmitDisabled(props: TaskCreateDialogFooterProps): boolean {
const { showStartTask, splitDisabled, defaultDisabled } = computeFooterState(props);
if (props.isTaskStarted) return props.isCreatingTask || !props.hasTitle;
return showStartTask ? splitDisabled : defaultDisabled;
}
export const TaskCreateDialogFooter = memo(function TaskCreateDialogFooter(
props: TaskCreateDialogFooterProps,
) {
@@ -217,7 +217,7 @@ describe("DialogPromptSection (CLI-mode parity)", () => {
it("forwards onVoiceAutoSend to TaskFormInputs", () => {
taskFormInputsCalls.length = 0;
const onVoiceAutoSend = () => {};
const onVoiceAutoSend = () => true;
render(
<DialogPromptSection
isSessionMode={false}
@@ -377,7 +377,7 @@ export type DialogPromptSectionProps = {
*/
autoFocusDescription?: boolean;
/** Called after a non-empty voice transcript is inserted and auto-send is on. */
onVoiceAutoSend?: () => void;
onVoiceAutoSend?: () => boolean | Promise<boolean>;
};
// importBindings collapses the optional Jira/Linear import callbacks into the
@@ -12,6 +12,7 @@ import {
import { formatBytes } from "@/lib/utils/format-bytes";
import { TaskFormInputs } from "./task-create-dialog-selectors";
import type { TaskFormInputsHandle } from "./task-create-dialog-types";
import type { PluginComposerSlotProps } from "@/lib/plugins/types";
const TOAST_MESSAGE_TEST_ID = "toast-message";
@@ -32,6 +33,7 @@ type VoiceProps = {
disabled?: boolean;
};
const voiceCalls: VoiceProps[] = [];
const pluginSlotCalls: PluginComposerSlotProps[] = [];
vi.mock("@/components/task/chat/voice-input-button", () => ({
VoiceInputButton: (props: VoiceProps) => {
voiceCalls.push(props);
@@ -39,6 +41,13 @@ vi.mock("@/components/task/chat/voice-input-button", () => ({
},
}));
vi.mock("@/components/plugins/plugin-slot", () => ({
PluginSlot: ({ slotProps }: { slotProps: PluginComposerSlotProps }) => {
pluginSlotCalls.push(slotProps);
return null;
},
}));
// Inert mention popover — the real hook installs a `keydown` listener that
// drains React's event queue across re-renders and adds noise to assertions.
vi.mock("@/hooks/use-task-create-prompt-mention", () => ({
@@ -60,10 +69,17 @@ vi.mock("@/hooks/use-task-create-prompt-mention", () => ({
afterEach(() => {
cleanup();
voiceCalls.length = 0;
pluginSlotCalls.length = 0;
vi.restoreAllMocks();
vi.mocked(processFile).mockReset();
});
function lastPluginSlotProps(): PluginComposerSlotProps {
const last = pluginSlotCalls.at(-1);
if (!last) throw new Error("PluginSlot was not rendered");
return last;
}
function lastVoiceProps(): VoiceProps {
const last = voiceCalls.at(-1);
if (!last) throw new Error("VoiceInputButton was not rendered");
@@ -158,6 +174,44 @@ describe("TaskFormInputs voice-input wiring — rendering", () => {
});
});
describe("TaskFormInputs plugin composer submission", () => {
it("reports blocked when the native creation path rejects submission", async () => {
const ref = createRef<TaskFormInputsHandle>();
render(
<TaskFormInputs
isSessionMode={false}
autoFocus={false}
initialDescription="prompt"
onDescriptionChange={() => {}}
onKeyDown={() => {}}
descriptionValueRef={ref}
onVoiceAutoSend={() => false}
/>,
{ wrapper: Wrapper },
);
await expect(lastPluginSlotProps().composer.submit()).resolves.toEqual({ status: "blocked" });
});
it("reports submitted only when the native creation path accepts submission", async () => {
const ref = createRef<TaskFormInputsHandle>();
render(
<TaskFormInputs
isSessionMode={false}
autoFocus={false}
initialDescription="prompt"
onDescriptionChange={() => {}}
onKeyDown={() => {}}
descriptionValueRef={ref}
onVoiceAutoSend={() => true}
/>,
{ wrapper: Wrapper },
);
await expect(lastPluginSlotProps().composer.submit()).resolves.toEqual({ status: "submitted" });
});
});
describe("TaskFormInputs voice-input wiring — at-cursor splice", () => {
it("splices the transcript at the caret with a leading space after a word", () => {
const { textarea } = renderTaskFormInputs("hello world");
@@ -42,6 +42,9 @@ import { deleteAttachment, uploadAttachment } from "@/lib/api/domains/attachment
import { ApiError } from "@/lib/api/client";
import { useTranslation } from "react-i18next";
import { t } from "@/lib/i18n";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { createPluginComposerCapability } from "@/lib/plugins/composer-capability";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
const CURSOR_POINTER_CLASS = "cursor-pointer";
@@ -330,6 +333,7 @@ export const InlineTaskName = memo(function InlineTaskName({
// Memoized description input to prevent re-rendering the entire dialog on every keystroke
type TaskFormInputsProps = {
isSessionMode: boolean;
taskId?: string | null;
workspaceId?: string | null;
autoFocus?: boolean;
initialDescription: string;
@@ -357,7 +361,7 @@ type TaskFormInputsProps = {
* when the user has voice auto-send enabled. The dialog wires this to a
* programmatic form submit so dictation can create the task hands-free.
*/
onVoiceAutoSend?: () => void;
onVoiceAutoSend?: () => boolean | Promise<boolean>;
};
// eslint-disable-next-line max-lines-per-function
@@ -697,6 +701,7 @@ type FormInputsToolbarProps = {
onTranscript: (text: string) => void;
onAutoSend?: () => void;
};
pluginActions?: React.ReactNode;
};
function FormInputsToolbar({
@@ -708,6 +713,7 @@ function FormInputsToolbar({
jiraImport,
linearImport,
voice,
pluginActions,
}: FormInputsToolbarProps) {
return (
<div className="flex items-center px-1 pb-1">
@@ -733,6 +739,7 @@ function FormInputsToolbar({
onImport={linearImport.onImport}
/>
)}
{pluginActions}
{voice && (
<div className="ml-auto flex items-center">
<VoiceInputButton
@@ -766,6 +773,53 @@ function PromptMentionPopover({
);
}
function useCreationComposerPluginActions(args: {
isSessionMode: boolean;
taskId: string | null;
disabled: boolean;
description: string;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
insertAtCursor: (text: string) => void;
submit?: () => boolean | Promise<boolean>;
}) {
const { isMobile } = useResponsiveBreakpoint();
const handle = useMemo(
() =>
createPluginComposerCapability({
insertText: (text) => {
args.insertAtCursor(text);
return true;
},
focus: () => {
if (!args.textareaRef.current) return false;
args.textareaRef.current.focus();
return true;
},
submit: async () => {
if (args.disabled || !args.description.trim() || !args.submit) return false;
return await args.submit();
},
}),
[args],
);
useEffect(() => () => handle.revoke(), [handle]);
return (
<PluginSlot
name={args.isSessionMode ? "new-session-input-actions" : "task-create-input-actions"}
slotProps={{
surface: args.isSessionMode ? "new-session" : "task-create",
presentation: isMobile ? "mobile" : "desktop",
taskId: args.taskId,
activeSessionId: null,
sessionIds: [],
disabled: args.disabled,
submittable: !args.disabled && args.description.trim().length > 0,
composer: handle.api,
}}
/>
);
}
function useTextareaHandlers(
mention: ReturnType<typeof useTaskCreatePromptMention>,
onKeyDown: TaskFormInputsProps["onKeyDown"],
@@ -830,6 +884,8 @@ function DraggingOverlay({ isDragging }: { isDragging: boolean }) {
);
}
// The input coordinates existing attachment, mention, voice, and plugin controls in one field.
// eslint-disable-next-line max-lines-per-function
export const TaskFormInputs = memo(function TaskFormInputs({
workspaceId,
isSessionMode,
@@ -847,6 +903,7 @@ export const TaskFormInputs = memo(function TaskFormInputs({
jiraImport,
linearImport,
onVoiceAutoSend,
taskId = null,
}: TaskFormInputsProps) {
const { t } = useTranslation();
const {
@@ -884,6 +941,15 @@ export const TaskFormInputs = memo(function TaskFormInputs({
() => ({ onTranscript: insertAtCursor, onAutoSend: onVoiceAutoSend }),
[insertAtCursor, onVoiceAutoSend],
);
const pluginActions = useCreationComposerPluginActions({
isSessionMode,
taskId,
disabled: Boolean(disabled),
description,
textareaRef,
insertAtCursor,
submit: onVoiceAutoSend,
});
return (
<div
@@ -923,6 +989,7 @@ export const TaskFormInputs = memo(function TaskFormInputs({
jiraImport={jiraImport}
linearImport={linearImport}
voice={voiceBinding}
pluginActions={pluginActions}
/>
<HiddenFileInput inputRef={fileInputRef} onChange={handleFileInputChange} />
</div>
@@ -505,5 +505,5 @@ export type DialogFormBodyProps = {
* routes this to a programmatic form submit so dictation can create the task
* hands-free.
*/
onVoiceAutoSend?: () => void;
onVoiceAutoSend?: () => boolean | Promise<boolean>;
};
@@ -64,6 +64,7 @@ vi.mock("@/hooks/use-keyboard-shortcut", () => ({
}));
vi.mock("@/components/task-create-dialog-footer", () => ({
isNativeSubmitDisabled: () => false,
TaskCreateDialogFooter: () => null,
}));
+16 -14
View File
@@ -5,7 +5,10 @@ import { useTranslation } from "react-i18next";
import { Dialog, DialogContent, DialogHeader, DialogFooter } from "@kandev/ui/dialog";
import type { Task } from "@/lib/types/http";
import type { TaskCreateLastUsedState } from "@/lib/state/slices/settings/types";
import { TaskCreateDialogFooter } from "@/components/task-create-dialog-footer";
import {
isNativeSubmitDisabled,
TaskCreateDialogFooter,
} from "@/components/task-create-dialog-footer";
import { DiscardLocalChangesDialog } from "@/components/discard-local-changes-dialog";
import { DialogHeaderContent } from "@/components/task-create-dialog-header";
import {
@@ -286,14 +289,19 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
}
resetQueuedLastUsedOnClose();
}, [props.open, resetQueuedLastUsedOnClose]);
// Voice auto-send invokes the same submit handler as the in-form Submit
// button. Every existing validation gate (missing title/repo/branch/agent,
// `submitBlockedReason`, in-flight create) still applies because they live
// inside `handleSubmit` itself, so a dictation with incomplete fields
// silently no-ops rather than creating a malformed task.
// Programmatic submissions use the native control's preflight before
// entering the same guarded submit handler as the form button.
const pendingAttachmentReason = setup.fs.hasPendingAttachmentUploads
? t("chat:attachmentUploadPendingSubmit")
: null;
const nativeSubmitDisabled = isNativeSubmitDisabled(
buildDialogFooterProps(setup, props, pendingAttachmentReason),
);
const handleVoiceAutoSend = useCallback(() => {
if (nativeSubmitDisabled) return false;
guardedHandleSubmit(VOICE_SUBMIT_EVENT);
}, [guardedHandleSubmit]);
return true;
}, [guardedHandleSubmit, nativeSubmitDisabled]);
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent
@@ -323,13 +331,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
/>
<DialogFooter className="border-t border-border pt-3 flex-col gap-3 sm:flex-row sm:gap-2">
<TaskCreateDialogFooter
{...buildDialogFooterProps(
setup,
props,
setup.fs.hasPendingAttachmentUploads
? t("chat:attachmentUploadPendingSubmit")
: null,
)}
{...buildDialogFooterProps(setup, props, pendingAttachmentReason)}
/>
</DialogFooter>
</form>
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback } from "react";
import { useState, useCallback, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { cn } from "@/lib/utils";
@@ -13,6 +13,8 @@ import type { ContextItem } from "@/lib/types/context";
import type { ContextFile } from "@/lib/state/context-files-store";
import type { ImagePasteIssue } from "./clipboard-attachments";
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
import { createPluginComposerCapability } from "@/lib/plugins/composer-capability";
import type { PluginComposerCapability } from "@/lib/plugins/types";
export type ChatInputEditorAreaProps = {
inputRef: React.RefObject<import("./tiptap-input").TipTapInputHandle | null>;
@@ -129,6 +131,41 @@ function FileInput({
);
}
function useChatPluginComposer(p: {
inputRef: ChatInputEditorAreaProps["inputRef"];
submitDisabled: boolean;
isEnhancingPrompt: boolean;
onSubmit: () => void;
}): PluginComposerCapability {
const handle = useMemo(
() =>
createPluginComposerCapability({
insertText: (text) => {
const editor = p.inputRef.current;
if (!editor) return false;
const from = editor.getSelectionStart();
const prefix = from > 0 && !/\s/.test(editor.getValue().charAt(from - 1)) ? " " : "";
editor.insertText(prefix + text.trim(), from, editor.getSelectionEnd());
editor.focus();
return true;
},
focus: () => {
if (!p.inputRef.current) return false;
p.inputRef.current.focus();
return true;
},
submit: async () => {
if (p.isEnhancingPrompt || p.submitDisabled) return false;
p.onSubmit();
return true;
},
}),
[p.inputRef, p.isEnhancingPrompt, p.onSubmit, p.submitDisabled],
);
useEffect(() => () => handle.revoke(), [handle]);
return handle.api;
}
export function ChatInputEditorArea(p: ChatInputEditorAreaProps) {
const { t } = useTranslation("chat");
const { inputRef, value, handleChange, handleSubmitWithReset, inputPlaceholder } = p;
@@ -148,6 +185,13 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) {
const hasContent = value.trim().length > 0 || userContextCount > 0;
// Block submit while enhancing prompt, but keep editor editable for programmatic updates
const wrappedSubmit = isEnhancingPrompt || p.submitDisabled ? () => {} : handleSubmitWithReset;
const composerCapability = useChatPluginComposer({
inputRef,
submitDisabled: p.submitDisabled,
isEnhancingPrompt: Boolean(isEnhancingPrompt),
onSubmit: handleSubmitWithReset,
});
const submitDisabledReason = p.hasPendingAttachmentUploads
? t("chat:attachmentUploadPendingSubmit")
: p.submitDisabledReason;
@@ -200,6 +244,8 @@ export function ChatInputEditorArea(p: ChatInputEditorAreaProps) {
isSending={isSending}
onCancel={onCancel}
onSubmit={wrappedSubmit}
composerCapability={composerCapability}
composerSurface={taskId ? "task-chat" : "quick-chat"}
submitKey={submitKey}
contextCount={contextCount}
contextPopoverOpen={contextPopoverOpen}
@@ -5,6 +5,17 @@ import { useOptionalAppStore } from "@/components/state-provider";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import type { AppState } from "@/lib/state/store";
import type { TaskSession } from "@/lib/types/http";
import type {
PluginComposerCapability,
PluginComposerSurface,
PluginPresentation,
} from "@/lib/plugins/types";
const UNAVAILABLE_COMPOSER: PluginComposerCapability = {
insertText: () => ({ status: "unavailable" }),
focus: () => ({ status: "unavailable" }),
submit: async () => ({ status: "unavailable" }),
};
/**
* Props forwarded to every plugin component registered for the
@@ -27,6 +38,12 @@ export type ChatInputActionsSlotProps = {
activeSessionId: string | null;
/** Every kandev session id on the task (includes `activeSessionId`). */
sessionIds: string[];
surface: PluginComposerSurface;
presentation: PluginPresentation;
disabled: boolean;
submittable: boolean;
disabledReason?: string;
composer: PluginComposerCapability;
};
const EMPTY_SESSIONS: TaskSession[] = [];
@@ -42,6 +59,12 @@ export function ChatInputPluginActions(props: {
sessionId: string | null;
taskId: string | null;
taskTitle?: string;
surface?: "task-chat" | "quick-chat";
presentation?: PluginPresentation;
disabled?: boolean;
submittable?: boolean;
disabledReason?: string;
composer?: PluginComposerCapability;
}) {
const { sessionId, taskId, taskTitle } = props;
// itemsByTaskId holds a stable per-task array reference (updated only when
@@ -60,8 +83,19 @@ export function ChatInputPluginActions(props: {
// The active session may not yet be in the store list (freshly prepared);
// make sure the plugin always receives it.
if (sessionId && !sessionIds.includes(sessionId)) sessionIds.unshift(sessionId);
return { taskId, taskTitle, activeSessionId: sessionId, sessionIds };
}, [taskSessions, sessionId, taskId, taskTitle]);
return {
taskId,
taskTitle,
activeSessionId: sessionId,
sessionIds,
surface: props.surface ?? (taskId ? "task-chat" : "quick-chat"),
presentation: props.presentation ?? "desktop",
disabled: props.disabled ?? false,
submittable: props.submittable ?? false,
disabledReason: props.disabledReason,
composer: props.composer ?? UNAVAILABLE_COMPOSER,
};
}, [taskSessions, sessionId, taskId, taskTitle, props]);
return <PluginSlot name="chat-input-actions" slotProps={slotProps} />;
}
@@ -161,6 +161,8 @@ function DesktopRightSection(props: {
submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS];
onVoiceTranscript?: (text: string) => void;
onVoiceAutoSend?: () => void;
composerCapability?: ChatInputToolbarProps["composerCapability"];
composerSurface?: ChatInputToolbarProps["composerSurface"];
}) {
return (
<div className="flex items-center gap-0.5 shrink-0">
@@ -176,6 +178,12 @@ function DesktopRightSection(props: {
sessionId={props.sessionId}
taskId={props.taskId}
taskTitle={props.taskTitle}
surface={props.composerSurface ?? (props.taskId ? "task-chat" : "quick-chat")}
presentation="desktop"
disabled={props.isDisabled}
submittable={!props.isDisabled && props.hasContent}
disabledReason={props.submitDisabledReason}
composer={props.composerCapability}
/>
)}
<div className="ml-1 flex items-center gap-1">
@@ -21,6 +21,7 @@ import {
import type { ContextFile } from "@/lib/state/context-files-store";
import type { SHORTCUTS } from "@/lib/keyboard/constants";
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
import type { PluginComposerCapability } from "@/lib/plugins/types";
import { t } from "@/lib/i18n";
type MobileToolbarProps = {
@@ -57,6 +58,8 @@ type MobileToolbarProps = {
submitShortcut: (typeof SHORTCUTS)[keyof typeof SHORTCUTS];
onVoiceTranscript?: (text: string) => void;
onVoiceAutoSend?: () => void;
composerCapability?: PluginComposerCapability;
composerSurface?: "task-chat" | "quick-chat";
};
function mobileContextButton(contextCount: number) {
@@ -177,6 +180,12 @@ export function MobileChatInputToolbar(props: MobileToolbarProps) {
sessionId={props.sessionId}
taskId={props.taskId}
taskTitle={props.taskTitle}
surface={props.composerSurface ?? (props.taskId ? "task-chat" : "quick-chat")}
presentation="mobile"
disabled={props.isDisabled}
submittable={!props.isDisabled && props.hasContent}
disabledReason={props.submitDisabledReason}
composer={props.composerCapability}
/>
)}
{props.onVoiceTranscript && (
@@ -10,6 +10,7 @@ import { shouldUseCompactTaskChrome } from "@/hooks/use-compact-task-chrome";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import type { ContextFile } from "@/lib/state/context-files-store";
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
import type { PluginComposerCapability } from "@/lib/plugins/types";
export type ChatInputToolbarProps = {
planModeEnabled: boolean;
@@ -61,6 +62,8 @@ export type ChatInputToolbarProps = {
hideAgentControls?: boolean;
/** Hide the plan mode toggle button (for ephemeral/quick chat sessions) */
hidePlanMode?: boolean;
composerCapability?: PluginComposerCapability;
composerSurface?: "task-chat" | "quick-chat";
};
function MinimalToolbar({
@@ -40,6 +40,7 @@ export type TipTapInputHandle = {
focus: () => void;
blur: () => void;
getSelectionStart: () => number;
getSelectionEnd: () => number;
getValue: () => string;
setValue: (value: string) => void;
clear: () => void;
@@ -497,6 +498,7 @@ function useEditorImperativeHandle(
focus: () => editor?.commands.focus(),
blur: () => editor?.commands.blur(),
getSelectionStart: () => editor?.state.selection.from ?? 0,
getSelectionEnd: () => editor?.state.selection.to ?? 0,
getValue: () => (editor ? getMarkdownText(editor) : ""),
setValue: (v: string) => {
if (!editor) return;
@@ -444,6 +444,7 @@ function NewSessionForm({
/>
<TaskFormInputs
isSessionMode
taskId={taskId}
workspaceId={workspaceId}
autoFocus
initialDescription=""
@@ -468,8 +469,11 @@ function NewSessionForm({
isEnhancingPrompt={isEnhancingPrompt}
isUtilityConfigured={isUtilityConfigured}
onVoiceAutoSend={() => {
if (isBusyState || hasPendingAttachmentUploads || !profileSelection.hasProfiles) return;
if (isBusyState || hasPendingAttachmentUploads || !profileSelection.hasProfiles) {
return false;
}
void handleSubmit(VOICE_SUBMIT_EVENT);
return true;
}}
/>
<PromptResultRecovery
@@ -0,0 +1,32 @@
import { describe, expect, it, vi } from "vitest";
import { createPluginComposerCapability } from "./composer-capability";
describe("createPluginComposerCapability", () => {
it("fails closed after revocation", async () => {
const insertText = vi.fn(() => true);
const focus = vi.fn(() => true);
const submit = vi.fn(async () => true);
const capability = createPluginComposerCapability({ insertText, focus, submit });
capability.revoke();
expect(capability.api.insertText("hello")).toEqual({ status: "unavailable" });
expect(capability.api.focus()).toEqual({ status: "unavailable" });
await expect(capability.api.submit()).resolves.toEqual({ status: "unavailable" });
expect(insertText).not.toHaveBeenCalled();
expect(focus).not.toHaveBeenCalled();
expect(submit).not.toHaveBeenCalled();
});
it("maps native operations to public outcomes", async () => {
const capability = createPluginComposerCapability({
insertText: () => true,
focus: () => true,
submit: async () => false,
});
expect(capability.api.insertText("hello")).toEqual({ status: "inserted" });
expect(capability.api.focus()).toEqual({ status: "focused" });
await expect(capability.api.submit()).resolves.toEqual({ status: "blocked" });
});
});
@@ -0,0 +1,35 @@
import type { PluginComposerCapability } from "./types";
type NativeComposerOperations = {
insertText(text: string): boolean;
focus(): boolean;
submit(): Promise<boolean>;
};
export function createPluginComposerCapability(operations: NativeComposerOperations): {
api: PluginComposerCapability;
revoke(): void;
} {
let active = true;
return {
api: {
insertText(text) {
if (!active) return { status: "unavailable" };
if (!text.trim()) return { status: "ignored" };
return { status: operations.insertText(text) ? "inserted" : "unavailable" };
},
focus() {
if (!active) return { status: "unavailable" };
return { status: operations.focus() ? "focused" : "unavailable" };
},
async submit() {
if (!active) return { status: "unavailable" };
return (await operations.submit()) ? { status: "submitted" } : { status: "blocked" };
},
},
revoke() {
active = false;
},
};
}
+26
View File
@@ -128,6 +128,32 @@ export type WsHandler = (payload: unknown) => void;
/** Presentation context a task panel or kanban menu action renders under. */
export type PluginPresentation = "desktop" | "mobile";
export type PluginComposerSurface = "task-chat" | "quick-chat" | "task-create" | "new-session";
export type PluginComposerSubmitResult =
| { status: "submitted" }
| { status: "blocked"; reason?: string }
| { status: "unavailable" };
export interface PluginComposerCapability {
insertText(text: string): { status: "inserted" | "ignored" | "unavailable" };
focus(): { status: "focused" | "unavailable" };
submit(): Promise<PluginComposerSubmitResult>;
}
export interface PluginComposerSlotProps {
surface: PluginComposerSurface;
presentation: PluginPresentation;
taskId: string | null;
taskTitle?: string;
activeSessionId: string | null;
sessionIds: string[];
disabled: boolean;
submittable: boolean;
disabledReason?: string;
composer: PluginComposerCapability;
}
/** Props passed to a `TaskPanelRegistration.Component`. */
export interface PluginTaskPanelProps {
/** This registration's panel id, so one Component can back multiple panels. */
@@ -0,0 +1,50 @@
# ADR-2026-08-11-composer-access-authenticated-webhooks: Composer Access and Authenticated Webhooks
**Status:** accepted
**Date:** 2026-08-11
**Area:** frontend, backend, protocol, security
## Context
Voice Mode currently reaches private editor handles and native submit callbacks in four composer
families, while plugin slots receive identifiers only. Plugin UI already calls its backend through
webhooks, but declarations cannot distinguish public integration ingress from authenticated Kandev UI
calls and all requests share a 4 MiB cap. Voice transcription needs authenticated UI access and at
least 10 MiB while keeping its OpenAI key in plugin-owned secret settings.
## Decision
Kandev exposes a typed, mounted-composer capability only through composer slot props. The capability
adapts insertion, focus, state, and submission to the owning surface's existing editor/form code; it
does not expose draft contents or let plugins synthesize outbound messages. Task chat, Quick Chat,
task creation, and new-session creation each supply an adapter to the same public shape.
Kandev extends each webhook declaration with `access: public|authenticated` and
`max_body_bytes`. Existing declarations default to `public` and 4 MiB. Authenticated webhooks require
the normal Kandev identity and same-origin browser checks; their configured limit may be raised to a
16 MiB host ceiling. Both modes continue through the existing `HandleWebhook` RPC, with request
cancellation propagated through its context.
Plugin localization is deferred to a separate prerequisite. It is required before core Voice Mode
can be removed, but it is independent of composer access and authenticated backend invocation.
## Consequences
Plugins can augment native composers without duplicating message construction or form behavior. A
plugin UI can use the existing webhook API for a larger authenticated upload without adding a route,
frontend API, protobuf method, or token lifecycle. Plugin authors must deliberately classify each
webhook; older manifests retain their current behavior.
## Alternatives Considered
- **Expose drafts and editor handles through `host.store`.** Rejected because drafts are local React
state, editor implementations differ, and a global mutable handle would outlive or target the wrong
composer during navigation.
- **Let plugins send messages through `api_write:messages`.** Rejected because it bypasses the native
submit and form behavior this extraction must preserve.
- **Add a separate browser-action route and RPC.** Rejected because access mode and body size are
properties of the existing UI-to-plugin webhook relay; a second transport duplicates it.
- **Raise every webhook to 16 MiB.** Rejected because existing public webhook callers do not need the
larger exposure. Limits remain declaration-specific and bounded by the host ceiling.
- **Add host localization in this package.** Rejected because locale/catalog ownership is independent
of the two host blockers addressed here and can be designed as a separate prerequisite.
+1
View File
@@ -107,6 +107,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or
| 2026-08-01-share-artifact-locale | [Shared-Task Artifacts Render in the Creator's Locale](2026-08-01-share-artifact-locale.md) | accepted | backend | 2026-08-01 |
| 2026-08-01-gitlab-mr-lifecycle-notifications | [GitLab MR Lifecycle Notifications](2026-08-01-gitlab-mr-lifecycle-notifications.md) | accepted | backend, frontend | 2026-08-01 |
| 2026-08-01-per-user-plugin-storage | [Host-Provided Per-User Plugin Storage](2026-08-01-per-user-plugin-storage.md) | accepted | backend, frontend, security | 2026-08-01 |
| 2026-08-11-composer-access-authenticated-webhooks | [Composer Access and Authenticated Webhooks](2026-08-11-composer-access-authenticated-webhooks.md) | accepted | frontend, backend, protocol, security | 2026-08-11 |
| 2026-08-01-plugin-task-panel-contributions | [Plugin Task Panel, Kanban Menu Action, and Card Indicator Contributions](2026-08-01-plugin-task-panel-contributions.md) | accepted | frontend | 2026-08-01 |
| 2026-08-02-new-workspace-github-access-defaults | [Bootstrap New Workspaces From Host GitHub Access](2026-08-02-new-workspace-github-access-defaults.md) | accepted | backend, frontend, security | 2026-08-02 |
| 2026-08-02-explicit-user-cancel-completion | [Explicit User Cancellation May Complete a Workflow Step](2026-08-02-explicit-user-cancel-completion.md) | accepted | workflow | 2026-08-02 |
@@ -0,0 +1,147 @@
---
spec: docs/specs/plugins/voice-extraction-host.md
created: 2026-08-11
status: building
---
# Implementation Plan: Voice Plugin Host Prerequisites
## Overview
Add host-owned composer adapters to every Voice Mode surface, then extend existing webhooks with
per-declaration access and request-size policies. Publish the contract through frontend types,
manifest validation, the frozen plugin API, and public authoring documentation.
Keep core Voice Mode installed throughout; a separate plugin repository will consume these APIs and
only a later parity task may remove core code.
## Contract Decisions
- Composer state stays local to each mounted surface. Slot props expose current disabled/submittable
state plus insertion, focus, and native submit without exposing draft contents or private refs.
- `chat-input-actions` grows compatible props and covers task chat plus Quick Chat. Creation forms get
explicit `task-create-input-actions` and `new-session-input-actions` slots because their identifiers,
validation, and submit lifecycles differ.
- Plugin UI continues to use `host.api.fetch("webhooks/<key>")` and the existing `HandleWebhook` RPC.
`webhooks[].access` selects public or authenticated invocation and `max_body_bytes` sets a bounded
per-key limit up to 16 MiB; old manifests retain public/4 MiB defaults.
- Plugin localization is a separate prerequisite. It must be resolved before core Voice Mode is
removed, but this package adds no host i18n API.
## Frontend Composer Architecture
- Add public types in `apps/web/lib/plugins/types.ts` and mirror them in
`docs/plans/plugins/PLUGIN-API.md`. Implement adapters guarded by the mounted composer generation;
normal React props update disabled/submittable presentation without draft state entering Zustand.
- Extend `TipTapInputHandle` only where required for the host adapter. Reuse its selection-aware
`insertText`, but centralize the Voice spacing rule so textarea and TipTap adapters behave identically.
- Adapt `useChatInputState`/`ChatInputArea` to expose native `handleSubmit` and the same disabled gate
to `ChatInputPluginActions`. Do not call `useMessageHandler` or construct `ChatSubmitPayload` from
plugin code. Quick Chat inherits the adapter through its existing `ChatInputArea` composition.
- Extend `TaskFormInputsHandle` with guarded focus/insertion operations or provide a sibling adapter
around `useDescriptionInput`. Task creation invokes its existing form submit handler; new-session
creation invokes its existing `handleSubmit`, including profile, upload, and busy-state gates.
- Keep slot controls in the existing toolbars on desktop. On native mobile, use the existing composer
toolbar/overflow composition and 44px touch targets; do not introduce a separate drawer solely for
plugin actions.
## Mobile Design Contract
- **Outcome and entry points:** Voice plugin actions remain beside native composer controls in task
chat and inside the prompt toolbar for task/new-session creation; Quick Chat uses its existing input
toolbar. All actions are reachable by touch.
- **Exemplars:** task chat reuses `chat-input-toolbar-mobile.tsx` and
`mobile-voice-mode.spec.ts`; new session reuses the full-height dialog in
`mobile-new-session-dialog.spec.ts`; task creation reuses the existing responsive Task Create dialog.
- **Hierarchy and action:** the plugin action is secondary to the native submit action and never hides
submit, model/profile selection, or cancellation. Recording/transcription state belongs
to the plugin control; the native composer remains the single draft and submit owner.
- **Surface rationale:** frequent, one-tap input augmentation belongs inline in the existing toolbar,
not behind navigation or a new drawer. Existing dialog and composer scroll owners remain unchanged;
overlays and toolbars keep safe-area clearance and no document horizontal overflow.
- **Shared behavior:** capability state and native adapters are shared across presentations. Only the
existing toolbar placement differs. Pixel 5 Playwright proves insertion plus native submission in
task chat, task creation, and new-session creation; Quick Chat mobile coverage is included if its
shipped mobile entry exposes the full toolbar.
## Backend Authenticated Webhook Architecture
- Extend manifest `Webhook` with `Access` and `MaxBodyBytes`. Validate `access` as
`public|authenticated`, default it to `public`, default the limit to 4 MiB, reject non-positive or
>16 MiB values, and preserve those values through install/registry serialization.
- Keep the existing webhook URL and `HandleWebhook` RPC. In the handler, resolve the declared key
before reading the body, apply its effective limit, and require normal Kandev identity plus accepted
Origin for authenticated cookie requests. Authentication-disabled mode uses the synthetic user.
- Propagate `Request.Context()` cancellation through `Service.InvokeWebhook`; bound and sanitize the
response as today. Logs may add access mode and byte count but never request content or secrets.
- Keep existing public webhooks byte-for-byte compatible: no authentication requirement and a 4 MiB
default. The Voice plugin stores its OpenAI key through existing secret config fields; no host key
storage or new secret API is introduced.
## Localization And Compatibility
- Add optional manifest fields without changing plugin `api_version: 1`. Document that consumers must
declare the first supporting `min_kandev_version`; older hosts fail installation instead of falling
back to webhooks.
- Update `docs/specs/plugins/spec.md`, `docs/plans/plugins/PLUGIN-API.md`,
`docs/public/plugins-authoring.md`, and `docs/public/plugins-manifest.md` in the same contract task.
## Test Strategy
- Frontend unit/component tests prove selection replacement, spacing, focus, live state props,
stale-adapter failure, native submit delegation, blocked submission, cleanup, Quick Chat props, and
desktop/mobile slot placement across all composer families.
- Backend manifest tests prove defaults, validation, and compatibility. Handler/service tests prove
authenticated/public access, Origin enforcement, 10 MiB success, >16 MiB rejection, cancellation,
disabled/runtime failure, and unchanged legacy webhook behavior.
- Desktop and mobile Playwright install or inject a fixture plugin action, insert a distinctive string
at a non-terminal selection, invoke native submit, and assert the resulting task/message/session.
A backend-integrated E2E action uploads >10 MiB only where the container/runtime fixture is available;
focused Go integration tests remain the deterministic size/security proof.
## Implementation Waves And Tasks
Wave 1:
- [x] [Task 01: Publish composer contract](task-01-publish-composer-contract.md)
- [x] [Task 02: Extend webhook declarations](task-02-authenticated-webhook-manifest.md)
Wave 2:
- [x] [Task 03: Adapt chat and Quick Chat composers](task-03-chat-composer-adapters.md) (depends on Task 01)
- [x] [Task 04: Adapt creation composers](task-04-creation-composer-adapters.md) (depends on Task 01)
- [x] [Task 05: Enforce authenticated webhooks](task-05-authenticated-webhook-handler.md) (depends on Task 02)
Wave 3:
- [ ] [Task 06: Document and verify extraction prerequisites](task-06-docs-e2e-verification.md) (depends on Tasks 03-05)
Tasks in the same wave are parallel-safe only where their owned files remain disjoint. Execution stays
sequential unless the user explicitly authorizes subagents.
## Risks
- A capability that snapshots React state can auto-submit after a gate changes; `submit()` must
revalidate the native gate at call time.
- React Strict Mode or a task/session switch can leave stale plugin closures alive; generation guards
must make them fail closed rather than target the newly mounted editor.
- Conditional authentication inside a route currently treated as public must fail closed per declared
key; direct handler and middleware-boundary tests are required.
- Raising gRPC message limits globally would unnecessarily affect other plugin traffic; configure only
the existing webhook call path sufficiently for the 16 MiB host ceiling.
- Core Voice removal in this package would make rollback and parity validation unsafe; it is explicitly
deferred to the plugin implementation/extraction task.
## Verification Commands
```bash
make -C apps/backend test
make -C apps/backend lint
cd apps && pnpm install --frozen-lockfile
cd apps && pnpm --filter @kandev/web test -- --run lib/plugins components/task/chat components/task-create-dialog-selectors.test.tsx components/task/new-session-dialog.test.tsx
cd apps/web && pnpm run typecheck
cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet
make build-web && make build-backend
cd apps/web && pnpm e2e:raw --project=chrome e2e/tests/plugins/composer-actions.spec.ts e2e/tests/chat/quick-chat.spec.ts e2e/tests/session/new-session-dialog.spec.ts
cd apps/web && pnpm e2e:raw --project=mobile-chrome e2e/tests/plugins/mobile-composer-actions.spec.ts e2e/tests/session/mobile-new-session-dialog.spec.ts
node --test scripts/validate-public-docs.test.mjs
```
@@ -0,0 +1,44 @@
---
id: "01-publish-composer-contract"
title: "Publish composer contract"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/plugins/voice-extraction-host.md"
---
# Task 01: Publish Composer Contract
## Acceptance
- Public plugin types define composer surface/state/capability/results exactly as the
spec, with backward-compatible additions to existing slot props.
- Internal capability lifecycle utilities make stale/unmounted capabilities
fail closed without exposing draft values or editor handles.
- Host API tests cover teardown, prop updates, and stale capability behavior.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- --run lib/plugins/host-api.test.ts lib/plugins/host-lifecycle.test.ts lib/plugins/host.test.ts
cd apps/web && pnpm run typecheck
```
Follow RED-GREEN-REFACTOR: add contract/lifecycle failures first, then the smallest public types and
host wiring. Do not add composer surface adapters in this task.
## Files Likely Touched
- `apps/web/lib/plugins/types.ts`
- `apps/web/lib/plugins/host-api.ts`
- `apps/web/lib/plugins/host.ts`
- focused tests beside those files
- a new internal composer-capability utility and test
- `docs/plans/plugins/PLUGIN-API.md`
## Inputs And Risks
- Spec API Surface and ADR mounted-capability decision.
- Avoid a Zustand draft slice, public editor handle, or snapshot-only state. Strict Mode teardown must
not notify or mutate after revocation.
@@ -0,0 +1,39 @@
---
id: "02-authenticated-webhook-manifest"
title: "Extend webhook declarations"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/plugins/voice-extraction-host.md"
---
# Task 02: Extend Webhook Declarations
## Acceptance
- Manifest webhooks support optional `access: public|authenticated` and `max_body_bytes`, with legacy
declarations resolving to public access and a 4 MiB limit.
- Validation rejects unknown access modes, public limits above 4 MiB, and authenticated limits above
16 MiB, while preserving existing key uniqueness and method behavior.
- Install, registry, and JSON/YAML round-trip tests preserve the new fields. Protobuf and the Go plugin
SDK remain unchanged.
## Verification
```bash
cd apps/backend && go test ./internal/plugins/manifest ./internal/plugins
```
Follow RED-GREEN-REFACTOR, beginning with legacy-default and invalid-limit cases.
## Files Likely Touched
- `apps/backend/internal/plugins/manifest/*`
- `apps/backend/internal/plugins/types.go`
- plugin install/registry serialization tests
## Inputs And Risks
- Spec authenticated-webhook API and compatibility section.
- Do not reinterpret omitted fields during YAML/JSON round trips in a way that breaks old packages.
@@ -0,0 +1,48 @@
---
id: "03-chat-composer-adapters"
title: "Adapt chat and Quick Chat composers"
status: done
wave: 2
depends_on: ["01-publish-composer-contract"]
plan: "plan.md"
spec: "../../specs/plugins/voice-extraction-host.md"
---
# Task 03: Adapt Chat And Quick Chat Composers
## Acceptance
- Desktop/mobile task chat and Quick Chat pass a live composer capability through
`chat-input-actions`, with correct surface, presentation, task, and session metadata.
- Insertion replaces the active TipTap selection with native spacing/focus semantics; submit delegates
to the existing draft state machine and preserves native submission behavior.
- Unit tests cover blocked, stale, task/session-switch, and task-less Quick Chat cases.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- --run components/task/chat lib/plugins components/quick-chat
cd apps/web && pnpm run typecheck
```
Follow RED-GREEN-REFACTOR. Keep `useChatInputState` and `useSubmitHandler` as the only submit owners.
## Files Likely Touched
- `apps/web/components/task/chat/chat-input-plugin-actions.tsx`
- `apps/web/components/task/chat/chat-input-toolbar-desktop.tsx`
- `apps/web/components/task/chat/chat-input-toolbar-mobile.tsx`
- `apps/web/components/task/chat/chat-input-area.tsx`
- `apps/web/components/task/chat/use-chat-input-state.ts`
- `apps/web/components/task/chat/use-tiptap-editor.ts`
- focused chat and Quick Chat tests
## Mobile Design Contract
Reuse the native mobile toolbar and its scroll/overflow behavior. The plugin action remains secondary
to send/cancel, has a >=44px touch target, and receives the same capability semantics as desktop.
## Risks
- Fast insertion then submit must read the updated value ref before React effects.
- An old session capability must return `unavailable`, never insert into the newly active session.
@@ -0,0 +1,50 @@
---
id: "04-creation-composer-adapters"
title: "Adapt creation composers"
status: done
wave: 2
depends_on: ["01-publish-composer-contract"]
plan: "plan.md"
spec: "../../specs/plugins/voice-extraction-host.md"
---
# Task 04: Adapt Creation Composers
## Acceptance
- Task creation and new-session creation render their declared plugin action slots on desktop and
native mobile with live prompt state and relevant identifiers.
- Selection insertion reuses one spacing algorithm and restores focus/caret; plugin submit invokes the
exact native form handler with existing busy and validation gates.
- Tests cover selected-range replacement, blocked submit, stale dialog handles, successful native
task/session creation delegation, and cleanup on close.
## Verification
```bash
cd apps && pnpm --filter @kandev/web test -- --run components/task-create-dialog-selectors.test.tsx components/task-create-dialog.test.tsx components/task/new-session-dialog.test.tsx
cd apps/web && pnpm run typecheck
```
Follow RED-GREEN-REFACTOR. Do not make Task Create or New Session send a chat message directly.
## Files Likely Touched
- `apps/web/components/task-create-dialog-types.ts`
- `apps/web/components/task-create-dialog-selectors.tsx`
- `apps/web/components/task-create-dialog-form-body.tsx`
- `apps/web/components/task-create-dialog.tsx`
- `apps/web/components/task/new-session-dialog.tsx`
- new shared creation-composer plugin action component/helper
- focused tests beside these files
## Mobile Design Contract
Keep actions inside the existing prompt toolbar and full-height responsive dialogs. Preserve the
dialog's scroll owner, safe-area behavior, primary submit hierarchy, and >=44px touch targets.
## Risks
- Programmatic form submission must not bypass native validation or submit while the form is blocked.
- Task Create and New Session share input UI but have different submit ownership; avoid a false shared
callback that drops either form's gates.
@@ -0,0 +1,41 @@
---
id: "05-authenticated-webhook-handler"
title: "Enforce authenticated webhooks"
status: done
wave: 2
depends_on: ["02-authenticated-webhook-manifest"]
plan: "plan.md"
spec: "../../specs/plugins/voice-extraction-host.md"
---
# Task 05: Enforce Authenticated Webhooks
## Acceptance
- The existing webhook handler enforces each declared key's access mode and effective body limit before
invoking the existing `HandleWebhook` RPC.
- Authenticated webhooks require Kandev identity and accepted Origin for cookie-authenticated browser
calls; public webhooks retain their current unauthenticated behavior.
- Integration tests cover legacy 4 MiB behavior, authenticated denial/success, Origin rejection,
10 MiB multipart success, >16 MiB rejection, cancellation, disabled plugin, and runtime failure.
## Verification
```bash
cd apps/backend && go test ./internal/plugins ./internal/auth/httpmw ./internal/backendapp
make -C apps/backend lint
```
Follow RED-GREEN-REFACTOR, beginning with direct handler and middleware-boundary security tests.
## Files Likely Touched
- `apps/backend/internal/plugins/handlers.go`
- `apps/backend/internal/plugins/invoke.go` and focused tests if cancellation/size wiring needs changes
- `apps/backend/internal/auth/httpmw/*`
## Inputs And Risks
- ADR conditional webhook access decision.
- Because the webhook path is currently public at middleware level, handler authentication must use
the canonical auth service and fail closed before reading or relaying the request body.
@@ -0,0 +1,54 @@
---
id: "06-docs-e2e-verification"
title: "Document and verify extraction prerequisites"
status: pending
wave: 3
depends_on: ["03-chat-composer-adapters", "04-creation-composer-adapters", "05-authenticated-webhook-handler"]
plan: "plan.md"
spec: "../../specs/plugins/voice-extraction-host.md"
---
# Task 06: Document And Verify Extraction Prerequisites
## Acceptance
- Public authoring/manifest docs and the plugin spec describe composer slots, webhook access/size
fields, min-version rules, cancellation, plugin-owned secret settings, and the separate localization
prerequisite.
- Desktop and Pixel 5 Playwright prove selection insertion plus native submit for task chat, task
creation, and new session; Quick Chat is covered wherever its toolbar is exposed.
- Core Voice Mode remains present and its focused unit/E2E coverage still passes, establishing a safe
prerequisite release for the later external plugin task.
## Verification
```bash
cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet
make build-web && make build-backend
cd apps/web && pnpm e2e:raw --project=chrome e2e/tests/plugins/composer-actions.spec.ts e2e/tests/chat/quick-chat.spec.ts e2e/tests/session/new-session-dialog.spec.ts
cd apps/web && pnpm e2e:raw --project=mobile-chrome e2e/tests/plugins/mobile-composer-actions.spec.ts e2e/tests/chat/mobile-voice-mode.spec.ts e2e/tests/session/mobile-new-session-dialog.spec.ts
node --test scripts/validate-public-docs.test.mjs
```
## Files Likely Touched
- `docs/specs/plugins/spec.md`
- `docs/plans/plugins/PLUGIN-API.md`
- `docs/public/plugins-authoring.md`
- `docs/public/plugins-manifest.md`
- `apps/web/e2e/tests/plugins/composer-actions.spec.ts`
- `apps/web/e2e/tests/plugins/mobile-composer-actions.spec.ts`
- fixture/plugin E2E helpers and existing Quick Chat/New Session specs where needed
## Mobile Design Contract
Pixel 5 tests use the shipped task toolbar, Task Create dialog, and New Session full-height dialog.
They assert the action is touch-reachable, native submission completes, the intended surface owns
scrolling, and the document has no horizontal overflow.
## Risks
- A mock that calls the submit callback directly does not prove native behavior; E2E must interact
through a registered fixture plugin slot and observe the created message/task/session.
- Do not remove or weaken current Voice Mode tests. Extraction and core removal belong to the later
dedicated plugin task.
+101 -29
View File
@@ -40,9 +40,10 @@ they must use `host.React` (NOT bundle their own React) to share the host instan
```ts
interface PluginHostApi {
pluginId: string;
React: typeof import("react"); // host React instance (shared)
jsx: typeof React.createElement; // convenience alias (h)
store: { // kandev app store (zustand StoreApi)
React: typeof import("react"); // host React instance (shared)
jsx: typeof React.createElement; // convenience alias (h)
store: {
// kandev app store (zustand StoreApi)
getState(): AppState;
setState(partial): void;
subscribe(listener): () => void;
@@ -56,7 +57,7 @@ interface PluginHostApi {
// dev/desktop base URL from window internals.
baseUrl: string;
};
ui: Record<string, unknown>; // curated @kandev/ui components + app UI (see below)
ui: Record<string, unknown>; // curated @kandev/ui components + app UI (see below)
// The resolved light/dark theme, read live on every access. `host` is built
// once per plugin load, so copying this into a variable that outlives a
// render freezes it; read it during render, and pair it with onThemeChange
@@ -89,7 +90,11 @@ interface PluginHostApi {
storage: PluginStorageApi;
}
interface PluginStorageEntry { key: string; value: unknown; updatedAt: string; }
interface PluginStorageEntry {
key: string;
value: unknown;
updatedAt: string;
}
interface PluginStorageSetOptions {
// Optimistic-concurrency guard: the updatedAt the caller last read. The
@@ -115,20 +120,37 @@ interface PluginStorageSetOptions {
}
// Mirrors the backend's user-state route scopes.
type PluginStorageScope = "instance" | "workspace" | "task" | "session" | "repository";
type PluginStorageScope =
| "instance"
| "workspace"
| "task"
| "session"
| "repository";
interface PluginStorageApi {
get(scope: PluginStorageScope, scopeId: string, key: string): Promise<PluginStorageEntry | undefined>;
get(
scope: PluginStorageScope,
scopeId: string,
key: string,
): Promise<PluginStorageEntry | undefined>;
set(
scope: PluginStorageScope, scopeId: string, key: string, value: unknown,
scope: PluginStorageScope,
scopeId: string,
key: string,
value: unknown,
options?: PluginStorageSetOptions,
): Promise<{ updatedAt: string }>;
delete(
scope: PluginStorageScope, scopeId: string, key: string,
scope: PluginStorageScope,
scopeId: string,
key: string,
options?: Pick<PluginStorageSetOptions, "writerId">,
): Promise<void>;
// Every entry under (scope, scopeId), ordered by key. Not paginated.
list(scope: PluginStorageScope, scopeId: string): Promise<PluginStorageEntry[]>;
list(
scope: PluginStorageScope,
scopeId: string,
): Promise<PluginStorageEntry[]>;
// Subscribes to live updates for this plugin's own storage made from
// another tab, device, or surface — e.g. the kanban Edit modal and the
// task panel both editing the same document. filter.scope/scopeId/key
@@ -145,7 +167,12 @@ interface PluginStorageApi {
// it would incorrectly suppress each other's legitimate writes as if they
// were one surface's own echo.
subscribe(
filter: { scope?: PluginStorageScope; scopeId?: string; key?: string; writerId?: string },
filter: {
scope?: PluginStorageScope;
scopeId?: string;
key?: string;
writerId?: string;
},
handler: (change: PluginUserStateChange) => void,
): () => void;
}
@@ -159,10 +186,10 @@ interface PluginUserStateChange {
}
interface PluginModalOptions {
title?: string; // rendered in a DialogHeader/DialogTitle; omit for no header title
title?: string; // rendered in a DialogHeader/DialogTitle; omit for no header title
content: React.ComponentType<{ slotProps?: unknown }>; // reuses the slot-component contract
size?: "sm" | "md" | "lg" | "xl"; // maps to the host's Dialog width classes; default "md"
dismissible?: boolean; // overlay click / Escape close the modal; default true
size?: "sm" | "md" | "lg" | "xl"; // maps to the host's Dialog width classes; default "md"
dismissible?: boolean; // overlay click / Escape close the modal; default true
}
interface PluginModalHandle {
@@ -261,7 +288,11 @@ interface RichTextEditorProps {
testId?: string;
}
// RichTextReadOnly: renders markdown read-only, no taskId dependency.
interface RichTextReadOnlyProps { value: string; className?: string; testId?: string; }
interface RichTextReadOnlyProps {
value: string;
className?: string;
testId?: string;
}
```
Deliberately narrow — not the plan editor's `comments`, `onSelectionChange`,
@@ -324,16 +355,22 @@ own write).
// "integrations" renders inside the sidebar's Integrations section alongside
// the first-party integration links (GitHub, Jira, ...). Hosts predating a
// section value simply don't render items targeting it (additive change).
interface NavItem { id: string; label: string; path: string; icon?: string; section?: "main" | "settings" | "integrations"; }
interface NavItem {
id: string;
label: string;
path: string;
icon?: string;
section?: "main" | "settings" | "integrations";
}
// Configuration for the kandev-style title bar the host renders above a plugin
// route. All fields optional; defaults are derived (see registerRoute below).
interface PluginPageChrome {
title?: string; // default: nav-item label for the same path, else plugin name
subtitle?: string; // muted text next to the title
icon?: string; // curated icon name; default: matching nav item's icon
backHref?: string; // back-link target (host default "/")
backLabel?: string; // back-link label (host default "Kandev")
title?: string; // default: nav-item label for the same path, else plugin name
subtitle?: string; // muted text next to the title
icon?: string; // curated icon name; default: matching nav item's icon
backHref?: string; // back-link target (host default "/")
backLabel?: string; // back-link label (host default "Kandev")
actions?: React.ComponentType; // rendered on the right side of the topbar
}
@@ -349,7 +386,11 @@ interface PluginRegistry {
// required for v1 — exact + startsWith("/plugins/{id}") allowed). The host wraps the
// page in kandev chrome (PageTopbar + scrollable content area) by default —
// configure or opt out via options.topbar.
registerRoute(path: string, Component: React.ComponentType, options?: PluginRouteOptions): void;
registerRoute(
path: string,
Component: React.ComponentType,
options?: PluginRouteOptions,
): void;
// Sidebar/main nav entry. Rendered by <PluginNavItems/> in the app sidebar,
// and by <MobilePluginNavSection/> in the phone menu sheet (the sidebar is
@@ -398,7 +439,10 @@ interface PluginRegistry {
// registered by the plugin currently being viewed, so your card appears on
// your own settings page and never on another plugin's — no per-id gating
// needed in your component.
registerComponent(slot: string, Component: React.ComponentType<{ slotProps?: unknown }>): void;
registerComponent(
slot: string,
Component: React.ComponentType<{ slotProps?: unknown }>,
): void;
// WS action handler. Bridged into the existing lib/ws dispatch; called with the
// decoded message payload for that action string.
@@ -442,6 +486,32 @@ interface PluginRegistry {
type PluginPresentation = "desktop" | "mobile";
type PluginComposerSurface =
| "task-chat"
| "quick-chat"
| "task-create"
| "new-session";
interface PluginComposerCapability {
insertText(text: string): { status: "inserted" | "ignored" | "unavailable" };
focus(): { status: "focused" | "unavailable" };
submit(): Promise<{
status: "submitted" | "blocked" | "unavailable";
reason?: string;
}>;
}
interface PluginComposerSlotProps {
surface: PluginComposerSurface;
presentation: PluginPresentation;
taskId: string | null;
taskTitle?: string;
activeSessionId: string | null;
sessionIds: string[];
disabled: boolean;
submittable: boolean;
disabledReason?: string;
composer: PluginComposerCapability;
}
interface PluginTaskPanelProps {
panelId: string; // this registration's panel id, so one Component can back multiple panels
taskId: string;
@@ -450,9 +520,9 @@ interface PluginTaskPanelProps {
}
interface TaskPanelRegistration {
id: string; // plugin-local panel id (unique within the plugin, not globally)
title: string; // add-panel-menu row label and dockview tab title
icon?: string; // curated icon name (apps/web/lib/plugins/icons.ts)
id: string; // plugin-local panel id (unique within the plugin, not globally)
title: string; // add-panel-menu row label and dockview tab title
icon?: string; // curated icon name (apps/web/lib/plugins/icons.ts)
Component: React.ComponentType<PluginTaskPanelProps>; // wrapped in a PluginErrorBoundary
mobileEnabled?: boolean; // include in the phone's grouped Panels picker. Default: false.
}
@@ -488,7 +558,7 @@ interface PluginTaskFilterOption {
}
interface TaskFilterRegistration {
id: string; // plugin-local filter id (unique within the plugin, not globally)
id: string; // plugin-local filter id (unique within the plugin, not globally)
label: string; // filter section label shown in the dropdown
getOptions(): PluginTaskFilterOption[];
// Called only when `selected` is non-empty — an empty selection is
@@ -652,8 +722,10 @@ defaults, or the bare component when the route opted out (`topbar: false`).
The chat composer toolbar
(`components/task/chat/chat-input-toolbar-desktop.tsx` and
`-mobile.tsx`, via `chat-input-plugin-actions.tsx`) hosts the
`chat-input-actions` slot, passing
`{ taskId, taskTitle, activeSessionId, sessionIds }`. `kanban-card-plugin-slots.tsx`
`chat-input-actions` slot for task and Quick Chat, passing the typed
`PluginComposerSlotProps`. Task creation and new-session creation mount
`task-create-input-actions` and `new-session-input-actions` with the same
capability contract. `kanban-card-plugin-slots.tsx`
hosts `task-card-indicators` beside `PRTaskIcon` and `task-card-tags` as its
own row, both mounted from `kanban-card-content.tsx`'s `KanbanCardBody`.
- `components/task/dockview-shared.tsx` / `dockview-panel-content.tsx` /
+149 -113
View File
@@ -89,11 +89,11 @@ directory.
## Choose a plugin shape
| Shape | Package contents | Typical contract | Minimal capability set |
| --- | --- | --- | --- |
| Backend plugin | Go binary and manifest | events, webhooks, Host data, state, secrets, or utility-agent calls | only the capabilities used by the backend |
| Shape | Package contents | Typical contract | Minimal capability set |
| ----------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------ |
| Backend plugin | Go binary and manifest | events, webhooks, Host data, state, secrets, or utility-agent calls | only the capabilities used by the backend |
| UI-focused plugin | no-op managed binary, ui/bundle.js, optional styles/assets | routes, nav, named slots, WebSocket handlers, keybindings, shared store | ui.bundle; add ui.keybindings when declaring shortcuts |
| Combined plugin | Go binary plus UI bundle | UI calls a declared webhook or backend API; backend uses Host | union of the two surfaces, kept least-privilege |
| Combined plugin | Go binary plus UI bundle | UI calls a declared webhook or backend API; backend uses Host | union of the two surfaces, kept least-privilege |
There is no separate HTTP server to launch. pluginsdk.Serve owns the
go-plugin/gRPC handshake and Host injection. The backend implements
@@ -116,22 +116,23 @@ curated React, UI, and app-store surface.
capabilities.events controls event delivery.
- GetConfig and EmitEvent are ungated. GetConfig returns this plugin's own
config, including cleartext secret fields, so do not log or commit it.
- Declared webhook keys are routable, but Kandev does not authenticate callers
or enforce webhooks[].method. Validate the method, provider signature, and
replay/timestamp rules inside the plugin before side effects.
- Declared webhook keys default to public. Set `webhooks[].access: authenticated`
for browser UI and billable operations. Kandev does not enforce
`webhooks[].method`; public integrations must still validate the provider
signature and replay/timestamp rules before side effects.
- capabilities.auth is the highest-risk capability. A webhook response may
assert a verified external identity with X-Kandev-Auth-Login; only assert an
email the IdP verified as owned by the subject. See [ADR 0050](../decisions/0050-plugin-external-auth-capability.md).
## Storage decision table
| Need | Use | Scope/lifecycle | Capability or rule |
| --- | --- | --- | --- |
| Small JSON object owned by this plugin | Host state: GetState, SetState, DeleteState, ListState | instance, workspace, task, or agent; survives restart/upgrade and is included in Kandev state backups | capabilities.state: true; values are JSON objects, not bare scalars |
| Per-user browser/plugin storage | host.storage: get/set/delete/list/subscribe | instance, workspace, task, session, or repository, scoped per user | capabilities.user_state: true; set/delete accept ifUnmodifiedSince and writerId |
| Operator configuration | Host.GetConfig and manifest config_schema | Plugin-owned settings; config changes restart an active subprocess | Ungated GetConfig; secret fields arrive cleartext in the subprocess |
| Plugin-owned credentials | Host.GetSecret/SetSecret/DeleteSecret, or secret: true config fields | Encrypted Kandev vault, namespaced to this plugin | capabilities.secrets: true; never log values |
| Files, caches, or plugin-managed database | KANDEV_PLUGIN_DATA_DIR | Shared across versions, removed on uninstall | Write only below the injected directory; own schema, locking, and migrations |
| Need | Use | Scope/lifecycle | Capability or rule |
| ----------------------------------------- | -------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| Small JSON object owned by this plugin | Host state: GetState, SetState, DeleteState, ListState | instance, workspace, task, or agent; survives restart/upgrade and is included in Kandev state backups | capabilities.state: true; values are JSON objects, not bare scalars |
| Per-user browser/plugin storage | host.storage: get/set/delete/list/subscribe | instance, workspace, task, session, or repository, scoped per user | capabilities.user_state: true; set/delete accept ifUnmodifiedSince and writerId |
| Operator configuration | Host.GetConfig and manifest config_schema | Plugin-owned settings; config changes restart an active subprocess | Ungated GetConfig; secret fields arrive cleartext in the subprocess |
| Plugin-owned credentials | Host.GetSecret/SetSecret/DeleteSecret, or secret: true config fields | Encrypted Kandev vault, namespaced to this plugin | capabilities.secrets: true; never log values |
| Files, caches, or plugin-managed database | KANDEV_PLUGIN_DATA_DIR | Shared across versions, removed on uninstall | Write only below the injected directory; own schema, locking, and migrations |
Host state is not host.storage: Host state is plugin-scoped backend state with
no transaction primitive, so design updates to be idempotent; host.storage
@@ -178,43 +179,45 @@ bookkeeping is host-internal; plugins do not call lifecycle methods.
### Frontend hook/API matrix
| Surface | Location and input | Manifest requirement | Cleanup/lifecycle | Small example |
| --- | --- | --- | --- | --- |
| registerRoute | registry.registerRoute(path, Component, options?); exact SPA path; options.topbar defaults to host chrome or false for full-bleed | Active ui.bundle | Route is removed on disable/uninstall; use destroy for subscriptions | registry.registerRoute("/acme", Page, { topbar: { title: "Acme" } }) |
| registerNavItem | { id, label, path, icon?, section? }; section is main, integrations, or accepted-but-not-rendered settings | Active ui.bundle | Nav item is revoked and removed from desktop/phone navigation | registry.registerNavItem({ id: "home", label: "Acme", path: "/acme", icon: "chart" }) |
| registerSettingsRoute | registerSettingsRoute(fullPath, Component) with an exact path under /settings/plugins/<id>/...; settings shell supplies chrome | Active ui.bundle | Route is removed on disable/uninstall | registry.registerSettingsRoute("/settings/plugins/acme/health", HealthPage) |
| registerComponent | registerComponent(slot, Component); component receives { slotProps?: unknown } | Active ui.bundle | Every registration is owner-tracked, error-isolated, and bulk-revoked | registry.registerComponent("task-sidebar", Panel) |
| registerWsHandler | registerWsHandler(action, handler(payload)); receives actions bridged from lib/ws | Active ui.bundle | Handler is removed on disable/uninstall; tolerate duplicate/replayed actions | registry.registerWsHandler("acme.updated", renderUpdate) |
| registerKeybinding | registerKeybinding(id, handler(event)); id must be declared in ui.keybindings; users can override the effective combo | ui.bundle and ui.keybindings[] | Handler is removed on disable/uninstall; editable targets/core shortcuts win | registry.registerKeybinding("open-panel", () => host.openModal(...)) |
| registerTaskPanel | { id, title, icon?, Component, mobileEnabled? }; adds a row to the task workspace's "+" (add panel) menu; Component receives { panelId, taskId, sessionId, presentation } | Active ui.bundle | Panel renders behind its own error boundary; slow/failed reloads preserve it, a ready generation missing it closes it, and disable/uninstall closes every owned instance | registry.registerTaskPanel({ id: "notes", title: "Notes", Component: NotesPanel }) |
| registerTaskMenuAction | { id, label, icon?, group: "edit" \| "primary", visible?(context), run(context) }; "edit" nests inside the card's Edit submenu, "primary" renders as a flat top-level item between "Move to"/"Send to workflow" and "Link" | Active ui.bundle | Action is revoked on disable/uninstall; a throwing/rejecting run is caught and logged | registry.registerTaskMenuAction({ id: "enhance", label: "Enhance", group: "edit", run: doEnhance }) |
| registerTaskFilter | { id, label, getOptions(), matches(context, selected) }; adds a client-side, multi-select filter section to the kanban board's display dropdown, alongside Workflow/Repository | Active ui.bundle | Filter is revoked on disable/uninstall; selections are ephemeral (not persisted); matches is only called for a non-empty selection, and a throw is caught, logged, and treated as non-matching | registry.registerTaskFilter({ id: "tags", label: "Tags", getOptions: listTagOptions, matches: taskHasSelectedTag }) |
| host.React / host.jsx | Shared React instance and React.createElement alias | Active ui.bundle | No cleanup; never bundle a second React/Radix runtime | const h = host.jsx |
| host.store | Curated Zustand { getState, setState, subscribe } for the live app store | Active ui.bundle | Unsubscribe in destroy; setState mutates the whole SPA and is not a plugin database | const stop = host.store.subscribe(render) |
| host.api.fetch / baseUrl | fetch(path, init?) is scoped to /api/plugins/<id>/...; baseUrl is the backend origin for split-origin deployments | Active ui.bundle; backend path must be a declared webhook when relayed | Abort/ignore requests after destroy; do not assume a webhook authenticates callers | host.api.fetch("webhooks/inbound", { method: "POST" }) |
| host.storage | Authenticated, per-user key/value storage: get(scope, scopeId, key)/set(scope, scopeId, key, value, options?)/delete(scope, scopeId, key)/list(scope, scopeId) plus subscribe(filter, handler); no plugin backend required | capabilities.user_state: true | set/delete accept an optional writerId (appended to the host's per-tab id, not a replacement) for echo suppression; list returns every entry under the scope pair, unpaginated | host.storage.set("task", taskId, "note", value, { writerId: panelId }) |
| host.ui | Curated host instances: Alert*, Badge, Button, Card*, Checkbox, Dialog*, DropdownMenu*, Input, Label, Pagination*, ScrollArea, Select*, Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea, Tooltip*, RichTextEditor, RichTextReadOnly, plus Combobox, PageTopbar, TaskCreateDialog | Active ui.bundle | Host owns contexts/portals; render with host React and let modal/slot cleanup run | const Button = host.ui.Button |
| host.theme | Current "light" or "dark" theme | Active ui.bundle | Read during render; subscribe through host/app patterns if theme-sensitive | host.theme === "dark" |
| host.navigate | Soft SPA navigation navigate(href, { replace? }) | Active ui.bundle | No registry cleanup; avoid navigating to undeclared external origins | host.navigate("/t/" + taskId) |
| host.openModal | Host-owned modal: { title?, content, size?, dismissible? } -> { close() } | Active ui.bundle | Modal auto-closes on disable/uninstall; close handles are idempotent | const modal = host.openModal({ content: Panel }) |
| Surface | Location and input | Manifest requirement | Cleanup/lifecycle | Small example |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| registerRoute | registry.registerRoute(path, Component, options?); exact SPA path; options.topbar defaults to host chrome or false for full-bleed | Active ui.bundle | Route is removed on disable/uninstall; use destroy for subscriptions | registry.registerRoute("/acme", Page, { topbar: { title: "Acme" } }) |
| registerNavItem | { id, label, path, icon?, section? }; section is main, integrations, or accepted-but-not-rendered settings | Active ui.bundle | Nav item is revoked and removed from desktop/phone navigation | registry.registerNavItem({ id: "home", label: "Acme", path: "/acme", icon: "chart" }) |
| registerSettingsRoute | registerSettingsRoute(fullPath, Component) with an exact path under /settings/plugins/<id>/...; settings shell supplies chrome | Active ui.bundle | Route is removed on disable/uninstall | registry.registerSettingsRoute("/settings/plugins/acme/health", HealthPage) |
| registerComponent | registerComponent(slot, Component); component receives { slotProps?: unknown } | Active ui.bundle | Every registration is owner-tracked, error-isolated, and bulk-revoked | registry.registerComponent("task-sidebar", Panel) |
| registerWsHandler | registerWsHandler(action, handler(payload)); receives actions bridged from lib/ws | Active ui.bundle | Handler is removed on disable/uninstall; tolerate duplicate/replayed actions | registry.registerWsHandler("acme.updated", renderUpdate) |
| registerKeybinding | registerKeybinding(id, handler(event)); id must be declared in ui.keybindings; users can override the effective combo | ui.bundle and ui.keybindings[] | Handler is removed on disable/uninstall; editable targets/core shortcuts win | registry.registerKeybinding("open-panel", () => host.openModal(...)) |
| registerTaskPanel | { id, title, icon?, Component, mobileEnabled? }; adds a row to the task workspace's "+" (add panel) menu; Component receives { panelId, taskId, sessionId, presentation } | Active ui.bundle | Panel renders behind its own error boundary; slow/failed reloads preserve it, a ready generation missing it closes it, and disable/uninstall closes every owned instance | registry.registerTaskPanel({ id: "notes", title: "Notes", Component: NotesPanel }) |
| registerTaskMenuAction | { id, label, icon?, group: "edit" \| "primary", visible?(context), run(context) }; "edit" nests inside the card's Edit submenu, "primary" renders as a flat top-level item between "Move to"/"Send to workflow" and "Link" | Active ui.bundle | Action is revoked on disable/uninstall; a throwing/rejecting run is caught and logged | registry.registerTaskMenuAction({ id: "enhance", label: "Enhance", group: "edit", run: doEnhance }) |
| registerTaskFilter | { id, label, getOptions(), matches(context, selected) }; adds a client-side, multi-select filter section to the kanban board's display dropdown, alongside Workflow/Repository | Active ui.bundle | Filter is revoked on disable/uninstall; selections are ephemeral (not persisted); matches is only called for a non-empty selection, and a throw is caught, logged, and treated as non-matching | registry.registerTaskFilter({ id: "tags", label: "Tags", getOptions: listTagOptions, matches: taskHasSelectedTag }) |
| host.React / host.jsx | Shared React instance and React.createElement alias | Active ui.bundle | No cleanup; never bundle a second React/Radix runtime | const h = host.jsx |
| host.store | Curated Zustand { getState, setState, subscribe } for the live app store | Active ui.bundle | Unsubscribe in destroy; setState mutates the whole SPA and is not a plugin database | const stop = host.store.subscribe(render) |
| host.api.fetch / baseUrl | fetch(path, init?) is scoped to /api/plugins/<id>/...; baseUrl is the backend origin for split-origin deployments | Active ui.bundle; backend path must be a declared webhook when relayed | Declare `webhooks[].access: authenticated` for UI-only or billable operations; abort requests on destroy | host.api.fetch("webhooks/inbound", { method: "POST" }) |
| host.storage | Authenticated, per-user key/value storage: get(scope, scopeId, key)/set(scope, scopeId, key, value, options?)/delete(scope, scopeId, key)/list(scope, scopeId) plus subscribe(filter, handler); no plugin backend required | capabilities.user_state: true | set/delete accept an optional writerId (appended to the host's per-tab id, not a replacement) for echo suppression; list returns every entry under the scope pair, unpaginated | host.storage.set("task", taskId, "note", value, { writerId: panelId }) |
| host.ui | Curated host instances: Alert*, Badge, Button, Card*, Checkbox, Dialog*, DropdownMenu*, Input, Label, Pagination*, ScrollArea, Select*, Separator, Sheet*, Skeleton, Spinner, Switch, Table*, Tabs*, Textarea, Tooltip*, RichTextEditor, RichTextReadOnly, plus Combobox, PageTopbar, TaskCreateDialog | Active ui.bundle | Host owns contexts/portals; render with host React and let modal/slot cleanup run | const Button = host.ui.Button |
| host.theme | Current "light" or "dark" theme | Active ui.bundle | Read during render; subscribe through host/app patterns if theme-sensitive | host.theme === "dark" |
| host.navigate | Soft SPA navigation navigate(href, { replace? }) | Active ui.bundle | No registry cleanup; avoid navigating to undeclared external origins | host.navigate("/t/" + taskId) |
| host.openModal | Host-owned modal: { title?, content, size?, dismissible? } -> { close() } | Active ui.bundle | Modal auto-closes on disable/uninstall; close handles are idempotent | const modal = host.openModal({ content: Panel }) |
### Supported named slots
registerComponent currently has these mounted slots. The source type is open
to strings, but an unmounted name renders nowhere.
| Slot | Mounted location | slotProps |
| --- | --- | --- |
| task-sidebar | Bottom of task-detail sidebar | none |
| settings-nav | Settings navigation tree | none |
| chat-input-actions | Chat composer toolbar | { taskId, taskTitle?, activeSessionId, sessionIds } |
| chat-top-bar | Session top bar | { taskId, taskTitle?, workspaceId, activeSessionId, sessionIds } |
| main-top-bar | Home/Kanban/Tasks top bar | { workspaceId, workspaceLabel?, currentPage } |
| app-status-bar-left | Left side of desktop status bar or mobile status drawer | AppStatusBarSlotProps |
| app-status-bar-right | Right side of desktop status bar or mobile status drawer | AppStatusBarSlotProps |
| plugin-settings | Top of this plugin's Settings > Plugins page | { pluginId, status }; owner-scoped to the plugin being viewed |
| task-card-indicators | Kanban card, beside the PR status icon | { taskId, workspaceId, workflowStepId } |
| task-card-tags | Kanban card, its own row below the badges row | { taskId, workspaceId, workflowStepId } |
| Slot | Mounted location | slotProps |
| ------------------------- | -------------------------------------------------------- | ---------------------------------------------------------------- |
| task-sidebar | Bottom of task-detail sidebar | none |
| settings-nav | Settings navigation tree | none |
| chat-input-actions | Task or Quick Chat composer toolbar | PluginComposerSlotProps |
| task-create-input-actions | Task creation composer toolbar | PluginComposerSlotProps |
| new-session-input-actions | New-session composer toolbar | PluginComposerSlotProps |
| chat-top-bar | Session top bar | { taskId, taskTitle?, workspaceId, activeSessionId, sessionIds } |
| main-top-bar | Home/Kanban/Tasks top bar | { workspaceId, workspaceLabel?, currentPage } |
| app-status-bar-left | Left side of desktop status bar or mobile status drawer | AppStatusBarSlotProps |
| app-status-bar-right | Right side of desktop status bar or mobile status drawer | AppStatusBarSlotProps |
| plugin-settings | Top of this plugin's Settings > Plugins page | { pluginId, status }; owner-scoped to the plugin being viewed |
| task-card-indicators | Kanban card, beside the PR status icon | { taskId, workspaceId, workflowStepId } |
| task-card-tags | Kanban card, its own row below the badges row | { taskId, workspaceId, workflowStepId } |
AppStatusBarSlotProps is { placement, presentation, density, pathname,
activeWorkspaceId, activeTaskId, activeSessionId }. Desktop presentation is a
@@ -222,15 +225,21 @@ compact 24px bar; mobile presentation is an in-flow drawer, so render a
touch-usable row. Status items can be reordered by the host; plugins do not get
an ordering API.
`PluginComposerSlotProps` is `{ surface, presentation, taskId, taskTitle?,
activeSessionId, sessionIds, disabled, submittable, disabledReason?, composer }`.
The `composer` capability provides `insertText`, `focus`, and asynchronous
`submit`; submit returns `submitted` only when the native composer accepts the
operation, otherwise `blocked` or `unavailable`.
## Backend contract
### Plugin lifecycle surface
| Surface | Input/output | Manifest capability or declaration | Lifecycle/cleanup | Example |
| --- | --- | --- | --- | --- |
| Plugin.OnEvent | OnEvent(ctx, *pluginsdk.Event) error; event has EventID, EventType, OccurredAt, WorkspaceID, Payload | Matching capabilities.events subject/pattern | Delivery is sequential per plugin with bounded queue and retries; make handlers idempotent by EventID and reconcile critical state | if event.EventType == "task.created" { ... } |
| Plugin.HandleWebhook | HandleWebhook(ctx, *pluginsdk.WebhookRequest) (*WebhookResponse, error); request includes key, method, path, query, headers, body | webhooks[].key; auth additionally required for login assertion header | Undeclared keys are 404; GET/POST are relayed; validate method/signature and bound side effects | if req.WebhookKey == "inbound" { ... } |
| Host.EmitEvent | EmitEvent(ctx, name, payload) publishes plugin.<id>.<name> | None; intentionally ungated | Subscribers must declare the emitted subject; use versioned names/payloads | host.EmitEvent(ctx, "sync.completed", payload-map) |
| Surface | Input/output | Manifest capability or declaration | Lifecycle/cleanup | Example |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
| Plugin.OnEvent | OnEvent(ctx, \*pluginsdk.Event) error; event has EventID, EventType, OccurredAt, WorkspaceID, Payload | Matching capabilities.events subject/pattern | Delivery is sequential per plugin with bounded queue and retries; make handlers idempotent by EventID and reconcile critical state | if event.EventType == "task.created" { ... } |
| Plugin.HandleWebhook | HandleWebhook(ctx, *pluginsdk.WebhookRequest) (*WebhookResponse, error); request includes key, method, path, query, headers, body | webhooks[].key; auth additionally required for login assertion header | Undeclared keys are 404; GET/POST are relayed; validate method/signature and bound side effects | if req.WebhookKey == "inbound" { ... } |
| Host.EmitEvent | EmitEvent(ctx, name, payload) publishes plugin.<id>.<name> | None; intentionally ungated | Subscribers must declare the emitted subject; use versioned names/payloads | host.EmitEvent(ctx, "sync.completed", payload-map) |
Event delivery is best-effort: Kandev makes the initial attempt plus retries
with 5s, 15s, and 45s delays, and a bounded in-memory queue/error buffer can
@@ -241,21 +250,21 @@ subscription vocabulary and wildcard rules are in the
### Host API matrix
| Host surface | Methods | Required manifest capability | Notes |
| --- | --- | --- | --- |
| State | GetState, SetState, DeleteState, ListState | state: true | Plugin-scoped JSON objects keyed by scope/scopeID/key; no transactions |
| Config | GetConfig | None | Reads this plugin's validated config_schema; secret fields are cleartext in the subprocess; config updates restart active plugins |
| Secrets | RevealSecret, GetSecret, SetSecret, DeleteSecret | secrets: true | Encrypted vault; plugin-owned keys are namespaced; never log values |
| Tasks | Tasks().List, Tasks().Get | api_read: tasks | Typed DTOs and opaque pagination cursor |
| Tasks writes | Tasks().Create, Tasks().Update | api_write: tasks | Implemented; routed through Kandev services so events/WS updates fire |
| Sessions | Sessions().List, Sessions().CodeStats | api_read: sessions | Typed session and computed code-stat records |
| Workspaces | Workspaces().List | api_read: workspaces | Instance-visible workspaces |
| Workflows | Workflows().List, Workflows().ListSteps | api_read: workflows | List steps by workflow id |
| Agent profiles | AgentProfiles().List | api_read: agent_profiles | Global agent profiles exposed by the Host data API |
| Repositories | Repositories().List | api_read: repositories | List by workspace id |
| Messages | Messages().List | api_read: messages | Historical user/agent content; Kandev system blocks are stripped |
| Message send | Messages().Send | api_write: messages | Sends a prompt to a task session and records plugin:<id> author |
| Utility agent | InvokeUtilityAgent(ctx, prompt) | agent_invoke: true plus config_schema.utility_agent (format: utility-agent) | One-shot completion using the selected utility-agent ID; Kandev resolves that utility's enabled profile, permissions, and launch settings. Missing or stale bindings are FailedPrecondition |
| Host surface | Methods | Required manifest capability | Notes |
| -------------- | ------------------------------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| State | GetState, SetState, DeleteState, ListState | state: true | Plugin-scoped JSON objects keyed by scope/scopeID/key; no transactions |
| Config | GetConfig | None | Reads this plugin's validated config_schema; secret fields are cleartext in the subprocess; config updates restart active plugins |
| Secrets | RevealSecret, GetSecret, SetSecret, DeleteSecret | secrets: true | Encrypted vault; plugin-owned keys are namespaced; never log values |
| Tasks | Tasks().List, Tasks().Get | api_read: tasks | Typed DTOs and opaque pagination cursor |
| Tasks writes | Tasks().Create, Tasks().Update | api_write: tasks | Implemented; routed through Kandev services so events/WS updates fire |
| Sessions | Sessions().List, Sessions().CodeStats | api_read: sessions | Typed session and computed code-stat records |
| Workspaces | Workspaces().List | api_read: workspaces | Instance-visible workspaces |
| Workflows | Workflows().List, Workflows().ListSteps | api_read: workflows | List steps by workflow id |
| Agent profiles | AgentProfiles().List | api_read: agent_profiles | Global agent profiles exposed by the Host data API |
| Repositories | Repositories().List | api_read: repositories | List by workspace id |
| Messages | Messages().List | api_read: messages | Historical user/agent content; Kandev system blocks are stripped |
| Message send | Messages().Send | api_write: messages | Sends a prompt to a task session and records plugin:<id> author |
| Utility agent | InvokeUtilityAgent(ctx, prompt) | agent_invoke: true plus config_schema.utility_agent (format: utility-agent) | One-shot completion using the selected utility-agent ID; Kandev resolves that utility's enabled profile, permissions, and launch settings. Missing or stale bindings are FailedPrecondition |
The Go signatures, filters, DTOs, and pagination types live in
apps/backend/pkg/pluginsdk/host.go and data_types.go. api_write task/message
@@ -318,7 +327,12 @@ window.registerKandevPlugin("acme-dashboard", {
return host.jsx("main", null, "Acme dashboard");
}
registry.registerRoute("/acme-dashboard", Page);
registry.registerNavItem({ id: "home", label: "Acme", path: "/acme-dashboard", icon: "chart" });
registry.registerNavItem({
id: "home",
label: "Acme",
path: "/acme-dashboard",
icon: "chart",
});
},
});
```
@@ -388,34 +402,38 @@ function useNoteValue(taskId, panelId) {
// duplicating the read-and-commit logic without its guards.
const guardRef = host.React.useRef({ cancelled: false, generation: 0 });
const refresh = host.React.useCallback((options = {}) => {
const preserveValue = options.preserveValue ?? dirtyRef.current;
const guard = guardRef.current;
const generation = ++guard.generation;
return host.storage.get("task", taskId, "note").then(
(entry) => {
// Ignore a response that resolves after taskId changed, the panel
// unmounted, or a newer refresh started — otherwise a stale read
// (the previous task's note, or one of two overlapping reads that
// resolved out of order) can land in the current field.
if (guard.cancelled || generation !== guard.generation) return;
if (!preserveValue && !dirtyRef.current) setValue(entry ? entry.value : "");
updatedAtRef.current = entry ? entry.updatedAt : undefined;
setLoadedTaskId(taskId);
setReadError(false);
return true;
},
() => {
// Do not mark the task loaded after a rejected read. Rendering an
// empty editor here would allow its first save to omit
// ifUnmodifiedSince and overwrite an existing note. Keep the panel
// in a retry state until an authoritative read succeeds.
if (guard.cancelled || generation !== guard.generation) return false;
setReadError(true);
return false;
},
);
}, [taskId]);
const refresh = host.React.useCallback(
(options = {}) => {
const preserveValue = options.preserveValue ?? dirtyRef.current;
const guard = guardRef.current;
const generation = ++guard.generation;
return host.storage.get("task", taskId, "note").then(
(entry) => {
// Ignore a response that resolves after taskId changed, the panel
// unmounted, or a newer refresh started — otherwise a stale read
// (the previous task's note, or one of two overlapping reads that
// resolved out of order) can land in the current field.
if (guard.cancelled || generation !== guard.generation) return;
if (!preserveValue && !dirtyRef.current)
setValue(entry ? entry.value : "");
updatedAtRef.current = entry ? entry.updatedAt : undefined;
setLoadedTaskId(taskId);
setReadError(false);
return true;
},
() => {
// Do not mark the task loaded after a rejected read. Rendering an
// empty editor here would allow its first save to omit
// ifUnmodifiedSince and overwrite an existing note. Keep the panel
// in a retry state until an authoritative read succeeds.
if (guard.cancelled || generation !== guard.generation) return false;
setReadError(true);
return false;
},
);
},
[taskId],
);
host.React.useEffect(() => {
const guard = guardRef.current;
@@ -454,10 +472,15 @@ function useNoteValue(taskId, panelId) {
}
function NotesPanel({ taskId, panelId }) {
const { value, setValue, updatedAtRef, dirtyRef, loaded, readError, refresh } = useNoteValue(
taskId,
panelId,
);
const {
value,
setValue,
updatedAtRef,
dirtyRef,
loaded,
readError,
refresh,
} = useNoteValue(taskId, panelId);
const [conflict, setConflict] = host.React.useState(false);
const [writeError, setWriteError] = host.React.useState(false);
const pendingValueRef = host.React.useRef(undefined);
@@ -542,11 +565,13 @@ function NotesPanel({ taskId, panelId }) {
setConflict(false);
setWriteError(false);
return () => {
if (writeGenerationRef.current === generation) writeGenerationRef.current += 1;
if (writeGenerationRef.current === generation)
writeGenerationRef.current += 1;
pendingValueRef.current = undefined;
writeInFlightRef.current = false;
writeBlockedRef.current = false;
if (writeTimerRef.current !== undefined) clearTimeout(writeTimerRef.current);
if (writeTimerRef.current !== undefined)
clearTimeout(writeTimerRef.current);
writeTimerRef.current = undefined;
};
}, [taskId, panelId]);
@@ -574,7 +599,11 @@ function NotesPanel({ taskId, panelId }) {
"div",
null,
"This note changed elsewhere. Your edit is preserved.",
host.jsx("button", { type: "button", onClick: retryWrite }, "Retry my edit"),
host.jsx(
"button",
{ type: "button", onClick: retryWrite },
"Retry my edit",
),
)
: writeError
? host.jsx(
@@ -587,7 +616,13 @@ function NotesPanel({ taskId, panelId }) {
return host.jsx("div", null, editor, status);
}
registry.registerTaskPanel({ id: "notes", title: "Notes", icon: "book", Component: NotesPanel, mobileEnabled: true });
registry.registerTaskPanel({
id: "notes",
title: "Notes",
icon: "book",
Component: NotesPanel,
mobileEnabled: true,
});
```
On a phone, all `mobileEnabled` panels appear under one **Panels** bottom-nav
@@ -612,8 +647,10 @@ task, not for an unbounded per-item collection.
### 4. Webhook receiver
Declare the route, validate the method and provider signature inside the
backend, and return a bounded status/body. Kandev caps incoming bodies at 4 MiB
but does not authenticate the caller.
backend, and return a bounded status/body. Public webhooks are capped at 4 MiB.
For plugin-UI operations, declare `access: authenticated`; those routes may
raise `max_body_bytes` to 16 MiB. Kandev verifies the current user but strips
its session cookie and PAT before relaying the remaining headers.
```yaml
webhooks:
@@ -717,7 +754,6 @@ An empty selection is implicit "All" for that section — `matches` is only
called once at least one option is selected, and a `matches` that throws is
caught, logged, and treated as non-matching for that task.
## Build, package, install, and test
### Plugin repository
@@ -788,12 +824,12 @@ instance; it removes the plugin package and KANDEV_PLUGIN_DATA_DIR.
Reinstalling the same id/version is rejected. Bump version or uninstall before
repackaging.
| Layer | Command/source | Checks | Does not check |
| --- | --- | --- | --- |
| Source | go test, go vet, template lint/build | Backend compilation and tests | Package paths or browser runtime |
| Package | plugin-pack | Manifest presence, safe staging, generated checksums | Full manifest semantics, JS execution, live Host calls |
| Install | pkgtar.Install via upload/sideload | Manifest validation, archive safety, checksums, managed runtime, host executable | Plugin behavior or UI rendering |
| Runtime | Disposable instance smoke test | Subprocess, Host permissions, events, webhooks, UI cleanup | Production-scale load/provenance |
| Layer | Command/source | Checks | Does not check |
| ------- | ------------------------------------ | -------------------------------------------------------------------------------- | ------------------------------------------------------ |
| Source | go test, go vet, template lint/build | Backend compilation and tests | Package paths or browser runtime |
| Package | plugin-pack | Manifest presence, safe staging, generated checksums | Full manifest semantics, JS execution, live Host calls |
| Install | pkgtar.Install via upload/sideload | Manifest validation, archive safety, checksums, managed runtime, host executable | Plugin behavior or UI rendering |
| Runtime | Disposable instance smoke test | Subprocess, Host permissions, events, webhooks, UI cleanup | Production-scale load/provenance |
## Common mistakes
@@ -818,8 +854,8 @@ repackaging.
uninstall.
- **Importing direct DB/internal packages:** use apps/backend/pkg/pluginsdk and
typed Host methods. Direct database access is outside the plugin contract.
- **Trusting webhook metadata:** webhooks[].method is informational and the route
is not authenticated by Kandev. Validate method, signature, timestamp, replay
- **Trusting webhook metadata:** `webhooks[].method` is informational. Public
routes are not authenticated by Kandev, so validate method, signature, timestamp, replay
protection, and body before side effects.
- **Bundling React:** use host.React, host.jsx, and host.ui; a second React or
Radix copy breaks shared contexts and portals.
+122 -104
View File
@@ -22,62 +22,78 @@ workflow and [Plugins](plugins.md) for install/operate.
## Annotated example
```yaml
id: "kandev-plugin-slack" # ^[a-z0-9][a-z0-9._-]*$
api_version: 1 # must be 1 (the only supported value)
id: "kandev-plugin-slack" # ^[a-z0-9][a-z0-9._-]*$
api_version: 1 # must be 1 (the only supported value)
version: "1.0.0"
display_name: "Slack Notifications"
description: "Post to Slack on task events, relay messages to agents"
author: "kandev"
categories: ["connector"] # connector | automation | tools | analytics
repo_url: "https://github.com/kdlbs/kandev-plugin-slack" # optional; "Repo" link in Settings > Plugins
categories: ["connector"] # connector | automation | tools | analytics
repo_url: "https://github.com/kdlbs/kandev-plugin-slack" # optional; "Repo" link in Settings > Plugins
runtime:
type: binary # only supported value today
type: binary # only supported value today
executables:
linux-amd64: server/plugin-linux-amd64
linux-arm64: server/plugin-linux-arm64
darwin-amd64: server/plugin-darwin-amd64
darwin-arm64: server/plugin-darwin-arm64
windows-amd64: server/plugin-windows-amd64.exe
# any subset; the host's <goos>-<goarch> key
# is required at install time
min_kandev_version: "0.78.0" # optional
windows-amd64:
server/plugin-windows-amd64.exe
# any subset; the host's <goos>-<goarch> key
# is required at install time
min_kandev_version: "0.78.0" # optional
capabilities:
events: ["task.created", "task.state_changed", "agent.completed"]
api_read: ["tasks", "agent_profiles"] # gates the Host data-reader RPCs
api_write: ["tasks"] # gates Tasks().Create/Update
api_read: ["tasks", "agent_profiles"] # gates the Host data-reader RPCs
api_write: ["tasks"] # gates Tasks().Create/Update
state: true
secrets: true
agent_invoke: true # gates Host.InvokeUtilityAgent
auth: true # gates external (OIDC/SAML) login — see ADR 0050
user_state: true # gates host.storage (per-user browser storage)
agent_invoke: true # gates Host.InvokeUtilityAgent
auth: true # gates external (OIDC/SAML) login — see ADR 0050
user_state: true # gates host.storage (per-user browser storage)
webhooks:
- key: "slack-events"
description: "Slack Events API webhook"
method: "POST" # informational only, not enforced
method: "POST" # informational only, not enforced
access: public # public (default) or authenticated
max_body_bytes: 4194304 # public maximum 4 MiB; authenticated maximum 16 MiB
auth_providers: # login buttons (needs capabilities.auth)
auth_providers: # login buttons (needs capabilities.auth)
- id: "google"
display_name: "Google"
initiate: "login-start" # names a webhook key above; the button navigates there
initiate: "login-start" # names a webhook key above; the button navigates there
config_schema:
type: object
properties:
bot_token: { type: string, secret: true, title: "Bot Token", description: "Slack bot OAuth token" }
default_channel: { type: string, description: "Default channel for notifications" }
bot_token:
{
type: string,
secret: true,
title: "Bot Token",
description: "Slack bot OAuth token",
}
default_channel:
{ type: string, description: "Default channel for notifications" }
notify_on_task_created: { type: boolean, default: true }
utility_agent: { type: string, format: utility-agent, title: "Utility Agent", description: "Agent used for plugin LLM calls" }
utility_agent:
{
type: string,
format: utility-agent,
title: "Utility Agent",
description: "Agent used for plugin LLM calls",
}
required: ["bot_token", "default_channel", "utility_agent"]
ui: # optional native frontend plugin
bundle: "/ui/bundle.js" # root-relative
styles: ["/ui/plugin.css"] # optional, root-relative
keybindings: # optional, requires ui.bundle
- id: "open-panel" # plugin-local: ^[a-z0-9][a-z0-9-]*$
default: "mod+shift+j" # combo grammar, see field reference
ui: # optional native frontend plugin
bundle: "/ui/bundle.js" # root-relative
styles: ["/ui/plugin.css"] # optional, root-relative
keybindings: # optional, requires ui.bundle
- id: "open-panel" # plugin-local: ^[a-z0-9][a-z0-9-]*$
default: "mod+shift+j" # combo grammar, see field reference
description: "Open the Acme panel"
```
@@ -88,44 +104,46 @@ ui: # optional native frontend plugin
<details>
<summary>Complete field reference and validation rules</summary>
| Field | Required | Type | Notes |
|---|---|---|---|
| `id` | yes | string | Must match `^[a-z0-9][a-z0-9._-]*$` (lowercase alphanumeric, dots, underscores, hyphens; must start with a lowercase alphanumeric). Directory name under `~/.kandev/plugins/`. |
| `api_version` | yes | int | Must be exactly `1`. Any other value is rejected. |
| `version` | yes | string | Free-form; used as the version directory name (`~/.kandev/plugins/<id>/<version>/`). |
| `display_name` | no | string | Shown in Settings > Plugins. |
| `description` | no | string | Shown in Settings > Plugins. |
| `author` | no | string | Free-form. |
| `categories` | no | string[] | Each entry must be one of `connector`, `automation`, `tools`, `analytics`. Unknown values are rejected. |
| `icon` | no | string | Package-relative path (e.g. `icon.svg`) to an image the package ships, rendered on the [marketplace](plugins-marketplace.md) card and in plugin lists. The registry index-build resolves it to an absolute `icon_url`; for an installed plugin it is served from the extracted package. Omit it and the card falls back to a letter tile. |
| `repo_url` | no | string | Absolute `http(s)` URL to the plugin's source repository. Rendered as a "Repo" link in Settings > Plugins (both the installed list and the plugin detail). Any other scheme (e.g. `javascript:`) is rejected at registration. Distinct from the marketplace card's `repo_url`, which the registry derives from `plugins.yaml`; declare this in the manifest so sideloaded and directly-installed plugins also carry the link. |
| `runtime.type` | conditionally | string | `"binary"` is the only supported value. Setting it (vs. leaving it empty) makes the manifest **runtime-managed** — see "Managed vs. legacy" below. |
| `runtime.executables` | required when `runtime.type: binary` | map\<string,string\> | Key is `<goos>-<goarch>` (e.g. `linux-amd64`, `darwin-arm64`, `windows-amd64`); value is a clean, package-relative path under `server/` (no leading `/`, no `..` segments). At least one entry required; the running host's key must be present at install time. Windows values end in `.exe`. |
| `min_kandev_version` | no | string | Lowest Kandev version this plugin supports. Enforced at **install time**: installing onto an older Kandev fails with `requires kandev >= <version>, running <version>` and nothing is registered. Already-installed plugins are never re-checked at load or start. A Kandev built from source reports the version `dev` and skips the check entirely, so local development is unaffected. Compare as dotted numeric segments (`0.78.0`) — a `v` prefix does not parse as a number and falls back to a string comparison. |
| `capabilities.events` | no | string[] | Bus subjects (or wildcard patterns) this plugin subscribes to. See "Event subscription vocabulary" below. |
| `capabilities.api_read` | no | string[] | Gates the Host data API's read-only accessors. Each entry is a resource name: `tasks`, `sessions`, `messages`, `workspaces`, `workflows`, `agent_profiles`, `repositories`. Calling the matching `Host` accessor (e.g. `Tasks()`) without its resource declared returns gRPC `PermissionDenied`. See "Host data API resource vocabulary" below. |
| `capabilities.api_write` | no | string[] | Gates Host data writes. Current resources are `tasks` (`Tasks().Create/Update`) and `messages` (`Messages().Send`); reads and writes are gated independently. |
| `capabilities.state` | no | bool | Gates `Host.GetState`/`SetState`/`DeleteState`/`ListState`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. |
| `capabilities.secrets` | no | bool | Gates `Host.RevealSecret`/`GetSecret`/`SetSecret`/`DeleteSecret`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. |
| `capabilities.agent_invoke` | no | bool | Gates `Host.InvokeUtilityAgent` — a one-shot completion run by the utility agent selected for this plugin. Declare a `utility_agent` config property with `type: string` and `format: utility-agent`; Settings > Plugins renders the picker. The selected utility agent resolves its own enabled ACP profile. Calling without this capability returns gRPC `PermissionDenied`; calling without a valid profile-backed selection returns gRPC `FailedPrecondition`. See ADR 0048. |
| `capabilities.auth` | no | bool | Lets the plugin log a visitor in against an external IdP (OIDC/SAML). Its webhook validates the token, then asserts the identity to Kandev via the `X-Kandev-Auth-Login` response header (`{provider, subject, email, display_name}`); Kandev mints the session and sets the cookie — the plugin never sees the token. Requires authentication enabled; new users are provisioned as members, and Kandev never creates an admin nor auto-links to an existing admin account. **You MUST only assert an email the IdP verified as owned by the subject — a spoofed email claim is account takeover.** Highest-privilege capability; grant only to trusted plugins. See ADR 0050. |
| `capabilities.user_state` | no | bool | Gates `host.storage` (`get`/`set`/`delete`/`list`/`subscribe`), the authenticated per-user browser storage surface at `/api/plugins/{id}/user-state/...`. Unlike `capabilities.state` (the gRPC `Host.SetState` family, written by the plugin's own backend), this is reachable directly from the plugin's frontend bundle with no Go backend required — every read/write is scoped to the calling user. Calling the route without this capability returns `403`. See [Authoring a plugin](plugins-authoring.md) and the per-user-plugin-storage decision record. |
| `webhooks[].key` | yes | string | Must be unique within the manifest. Used in the relay path `POST /api/plugins/{id}/webhooks/{key}`. |
| `webhooks[].description` | no | string | Free-form. |
| `webhooks[].method` | no | string | **Informational only** — kandev does not validate or enforce the inbound HTTP method against this value. |
| `auth_providers[]` | no | object[] | Login buttons this plugin contributes to the pre-auth login screen (needs `capabilities.auth`). Each is `{ id, display_name, initiate }`, where `initiate` names one of the plugin's `webhooks[].key` values — the button navigates to that webhook, which 302-redirects to the IdP. Surfaced anonymously in the boot payload as `auth.ssoProviders`. |
| `config_schema` | no | object | JSON-Schema-like object driving the settings form at **Settings > Plugins > `<plugin>`** (`GET /api/plugins/{id}/config` and `PATCH /api/plugins/{id}`). See "Config schema validation and secret fields" below. |
| `ui.bundle` | no | string | Root-relative path (must start with `/`, e.g. `/ui/bundle.js`) to the plugin's native UI ES module, served at `GET /api/plugins/{id}/bundle`. |
| `ui.styles` | no | string[] | Root-relative CSS paths (each must start with `/`), served at `GET /api/plugins/{id}/ui/*` and injected as `<link>` tags on load. |
| `ui.pages` | no | object[] | Optional declarative metadata accepted by the manifest model. The current frontend does not render these entries; a native bundle registers its supported routes/nav/slots at runtime, so most plugins omit `ui.pages`. |
| `ui.pages[].key` | yes* | string | Stable identifier for the page (*required when a page entry is present). |
| `ui.pages[].title` | yes* | string | Display title. |
| `ui.pages[].path` | yes* | string | Route path for the page. |
| `ui.pages[].surface` | yes* | string | Metadata enum (`settings` · `task-panel` · `main-nav`) validated by the manifest parser. It is not a current frontend mount; use the registry hooks in the authoring guide. |
| `ui.keybindings` | no | object[] | Declares plugin keybindings bound at runtime via `registerKeybinding`. Requires `ui.bundle`. |
| `ui.keybindings[].id` | yes* | string | Stable, plugin-local slug (*required when a keybinding entry is present). Must match `^[a-z0-9][a-z0-9-]*$` and be unique within this plugin's own `ui.keybindings` list — not globally; the effective shortcut is namespaced `plugin:{pluginId}:{id}`. |
| `ui.keybindings[].default` | yes* | string | Default combo string. `+`-separated tokens: zero or more modifiers from `mod`, `ctrl`, `cmd`, `meta`, `alt`, `option`, `shift` (`mod` = ⌘ on macOS, Ctrl elsewhere) plus exactly one non-modifier key. `shift` may not combine with a digit or symbol key — the browser reports the shifted glyph for those keys, so the combo could never match. |
| `ui.keybindings[].description` | yes* | string | Non-empty, human-readable label shown in **Settings > Keyboard Shortcuts**. |
| Field | Required | Type | Notes |
| ------------------------------ | ------------------------------------ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | yes | string | Must match `^[a-z0-9][a-z0-9._-]*$` (lowercase alphanumeric, dots, underscores, hyphens; must start with a lowercase alphanumeric). Directory name under `~/.kandev/plugins/`. |
| `api_version` | yes | int | Must be exactly `1`. Any other value is rejected. |
| `version` | yes | string | Free-form; used as the version directory name (`~/.kandev/plugins/<id>/<version>/`). |
| `display_name` | no | string | Shown in Settings > Plugins. |
| `description` | no | string | Shown in Settings > Plugins. |
| `author` | no | string | Free-form. |
| `categories` | no | string[] | Each entry must be one of `connector`, `automation`, `tools`, `analytics`. Unknown values are rejected. |
| `icon` | no | string | Package-relative path (e.g. `icon.svg`) to an image the package ships, rendered on the [marketplace](plugins-marketplace.md) card and in plugin lists. The registry index-build resolves it to an absolute `icon_url`; for an installed plugin it is served from the extracted package. Omit it and the card falls back to a letter tile. |
| `repo_url` | no | string | Absolute `http(s)` URL to the plugin's source repository. Rendered as a "Repo" link in Settings > Plugins (both the installed list and the plugin detail). Any other scheme (e.g. `javascript:`) is rejected at registration. Distinct from the marketplace card's `repo_url`, which the registry derives from `plugins.yaml`; declare this in the manifest so sideloaded and directly-installed plugins also carry the link. |
| `runtime.type` | conditionally | string | `"binary"` is the only supported value. Setting it (vs. leaving it empty) makes the manifest **runtime-managed** — see "Managed vs. legacy" below. |
| `runtime.executables` | required when `runtime.type: binary` | map\<string,string\> | Key is `<goos>-<goarch>` (e.g. `linux-amd64`, `darwin-arm64`, `windows-amd64`); value is a clean, package-relative path under `server/` (no leading `/`, no `..` segments). At least one entry required; the running host's key must be present at install time. Windows values end in `.exe`. |
| `min_kandev_version` | no | string | Lowest Kandev version this plugin supports. Enforced at **install time**: installing onto an older Kandev fails with `requires kandev >= <version>, running <version>` and nothing is registered. Already-installed plugins are never re-checked at load or start. A Kandev built from source reports the version `dev` and skips the check entirely, so local development is unaffected. Compare as dotted numeric segments (`0.78.0`) — a `v` prefix does not parse as a number and falls back to a string comparison. |
| `capabilities.events` | no | string[] | Bus subjects (or wildcard patterns) this plugin subscribes to. See "Event subscription vocabulary" below. |
| `capabilities.api_read` | no | string[] | Gates the Host data API's read-only accessors. Each entry is a resource name: `tasks`, `sessions`, `messages`, `workspaces`, `workflows`, `agent_profiles`, `repositories`. Calling the matching `Host` accessor (e.g. `Tasks()`) without its resource declared returns gRPC `PermissionDenied`. See "Host data API resource vocabulary" below. |
| `capabilities.api_write` | no | string[] | Gates Host data writes. Current resources are `tasks` (`Tasks().Create/Update`) and `messages` (`Messages().Send`); reads and writes are gated independently. |
| `capabilities.state` | no | bool | Gates `Host.GetState`/`SetState`/`DeleteState`/`ListState`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. |
| `capabilities.secrets` | no | bool | Gates `Host.RevealSecret`/`GetSecret`/`SetSecret`/`DeleteSecret`. Calling any of them without this set to `true` returns gRPC `PermissionDenied`. |
| `capabilities.agent_invoke` | no | bool | Gates `Host.InvokeUtilityAgent` — a one-shot completion run by the utility agent selected for this plugin. Declare a `utility_agent` config property with `type: string` and `format: utility-agent`; Settings > Plugins renders the picker. The selected utility agent resolves its own enabled ACP profile. Calling without this capability returns gRPC `PermissionDenied`; calling without a valid profile-backed selection returns gRPC `FailedPrecondition`. See ADR 0048. |
| `capabilities.auth` | no | bool | Lets the plugin log a visitor in against an external IdP (OIDC/SAML). Its webhook validates the token, then asserts the identity to Kandev via the `X-Kandev-Auth-Login` response header (`{provider, subject, email, display_name}`); Kandev mints the session and sets the cookie — the plugin never sees the token. Requires authentication enabled; new users are provisioned as members, and Kandev never creates an admin nor auto-links to an existing admin account. **You MUST only assert an email the IdP verified as owned by the subject — a spoofed email claim is account takeover.** Highest-privilege capability; grant only to trusted plugins. See ADR 0050. |
| `capabilities.user_state` | no | bool | Gates `host.storage` (`get`/`set`/`delete`/`list`/`subscribe`), the authenticated per-user browser storage surface at `/api/plugins/{id}/user-state/...`. Unlike `capabilities.state` (the gRPC `Host.SetState` family, written by the plugin's own backend), this is reachable directly from the plugin's frontend bundle with no Go backend required — every read/write is scoped to the calling user. Calling the route without this capability returns `403`. See [Authoring a plugin](plugins-authoring.md) and the per-user-plugin-storage decision record. |
| `webhooks[].key` | yes | string | Must be unique within the manifest. Used in the relay path `POST /api/plugins/{id}/webhooks/{key}`. |
| `webhooks[].description` | no | string | Free-form. |
| `webhooks[].method` | no | string | **Informational only** — kandev does not validate or enforce the inbound HTTP method against this value. |
| `webhooks[].access` | no | string | `public` (default) allows anonymous callers; `authenticated` requires a Kandev identity. Cookie-authenticated browser calls remain subject to Kandev's same-origin policy. |
| `webhooks[].max_body_bytes` | no | int | Request-body limit for this key. Defaults to 4 MiB. Public webhooks cannot exceed 4 MiB; authenticated webhooks can request up to 16 MiB. Plugins using this field should declare the first supporting `min_kandev_version`. |
| `auth_providers[]` | no | object[] | Login buttons this plugin contributes to the pre-auth login screen (needs `capabilities.auth`). Each is `{ id, display_name, initiate }`, where `initiate` names one of the plugin's `webhooks[].key` values — the button navigates to that webhook, which 302-redirects to the IdP. Surfaced anonymously in the boot payload as `auth.ssoProviders`. |
| `config_schema` | no | object | JSON-Schema-like object driving the settings form at **Settings > Plugins > `<plugin>`** (`GET /api/plugins/{id}/config` and `PATCH /api/plugins/{id}`). See "Config schema validation and secret fields" below. |
| `ui.bundle` | no | string | Root-relative path (must start with `/`, e.g. `/ui/bundle.js`) to the plugin's native UI ES module, served at `GET /api/plugins/{id}/bundle`. |
| `ui.styles` | no | string[] | Root-relative CSS paths (each must start with `/`), served at `GET /api/plugins/{id}/ui/*` and injected as `<link>` tags on load. |
| `ui.pages` | no | object[] | Optional declarative metadata accepted by the manifest model. The current frontend does not render these entries; a native bundle registers its supported routes/nav/slots at runtime, so most plugins omit `ui.pages`. |
| `ui.pages[].key` | yes\* | string | Stable identifier for the page (\*required when a page entry is present). |
| `ui.pages[].title` | yes\* | string | Display title. |
| `ui.pages[].path` | yes\* | string | Route path for the page. |
| `ui.pages[].surface` | yes\* | string | Metadata enum (`settings` · `task-panel` · `main-nav`) validated by the manifest parser. It is not a current frontend mount; use the registry hooks in the authoring guide. |
| `ui.keybindings` | no | object[] | Declares plugin keybindings bound at runtime via `registerKeybinding`. Requires `ui.bundle`. |
| `ui.keybindings[].id` | yes\* | string | Stable, plugin-local slug (_required when a keybinding entry is present). Must match `^[a-z0-9][a-z0-9-]_$`and be unique within this plugin's own`ui.keybindings`list — not globally; the effective shortcut is namespaced`plugin:{pluginId}:{id}`. |
| `ui.keybindings[].default` | yes\* | string | Default combo string. `+`-separated tokens: zero or more modifiers from `mod`, `ctrl`, `cmd`, `meta`, `alt`, `option`, `shift` (`mod` = ⌘ on macOS, Ctrl elsewhere) plus exactly one non-modifier key. `shift` may not combine with a digit or symbol key — the browser reports the shifted glyph for those keys, so the combo could never match. |
| `ui.keybindings[].description` | yes\* | string | Non-empty, human-readable label shown in **Settings > Keyboard Shortcuts**. |
`ui.pages` is declarative manifest metadata only and is not currently rendered
by the frontend. A native bundle's runtime nav items, icons, routes, named
@@ -142,7 +160,7 @@ must **not** set `base_url` or an `endpoints` block (`health`/`events`/
remote/operator-hosted tier, and validation rejects a managed manifest that
sets them.
A manifest with an empty `runtime.type` still *parses* as a legacy remote
A manifest with an empty `runtime.type` still _parses_ as a legacy remote
manifest (with `base_url`/`endpoints` instead) and passes
`manifest.Validate()` on its own — but the **installer** rejects it: `pkgtar
.Install` requires `manifest.IsManaged()` to be true (`runtime.type:
@@ -228,39 +246,39 @@ matched — so a plugin subscribed to `shell.output.*` reads the session id off
`event_type` as its last segment. For an unsuffixed subject that string is
just the subject itself (`task.created`).
| Domain | Events |
|---|---|
| Tasks | `task.created`, `task.updated`, `task.state_changed`, `task.deleted`, `task.moved`, `task.tree_hold_created`, `task.tree_hold_released` |
| Workspaces | `workspace.created`, `workspace.updated`, `workspace.deleted` |
| Workflows | `workflow.created`, `workflow.updated`, `workflow.deleted` |
| Workflow steps | `workflow_step.created`, `workflow_step.updated`, `workflow_step.deleted`, `workflow.step_completion_signaled` |
| Comments / messages | `message.added`, `message.updated`, `message.deleted`, `message.queue.status_changed` |
| Task sessions | `task_session.state_changed` |
| Task plans | `task_plan.created`, `task_plan.updated`, `task_plan.deleted`, `task_plan.revision.created`, `task_plan.reverted` |
| Task walkthroughs | `task_walkthrough.created`, `task_walkthrough.updated`, `task_walkthrough.deleted` |
| Session turns | `turn.started`, `turn.completed` |
| Repositories | `repository.created`, `repository.updated`, `repository.deleted`, `repository.script.created`, `repository.script.updated`, `repository.script.deleted` |
| Executors | `executor.created`, `executor.updated`, `executor.deleted`, `executor.profile.created`, `executor.profile.updated`, `executor.profile.deleted`, `executor.prepare.progress`, `executor.prepare.completed` |
| Users | `user.settings.updated` |
| System | `system.job.update` |
| Environments | `environment.created`, `environment.updated`, `environment.deleted` |
| Agent profiles | `agent_profile.created`, `agent_profile.updated`, `agent_profile.deleted` |
| Agents | `agent.started`, `agent.running`, `agent.boot_ready`, `agent.ready`, `agent.completed`, `agent.failed`, `agent.stopped`, `agent.context_reset`, `agent.acp_session_created`, `agentctl.starting`, `agentctl.ready`, `agentctl.error` |
| Agent stream | `agent.stream` (per-session: `agent.stream.<sessionId>`), `agent.turn.message_saved` |
| Agent prompts | `permission_request.received` (per-session: `permission_request.received.<sessionId>`) |
| Clarification | `clarification.answered`, `clarification.primary_answered`, `clarification.cancelled`, `clarification.stale_dismissed` |
| Git / workspace status | `git.event` (per-session), `git.ws` (per-session), `file.change.notified` (per-session) |
| Shell I/O | `shell.output` (per-session), `shell.exit` (per-session) |
| Dev server I/O | `process.output` (per-session), `process.status` (per-session) |
| Session context | `context_window.updated` (per-session), `available_commands.updated` (per-session), `session_mode.changed` (per-session), `agent_capabilities.updated` (per-session), `session_models.updated` (per-session), `session_info.updated` (per-session), `session_todos.updated` (per-session), `session_prompt_usage.updated` (per-session) |
| Automations | `automation.triggered`, `automation.run.created` |
| GitHub | `github.pr_feedback`, `github.pr_state_changed`, `github.new_pr_to_review`, `github.new_issue`, `github.task_pr.updated`, `github.task_ci_options.updated`, `github.watch.event`, `github.rate_limit.updated` |
| GitLab | `gitlab.mr_feedback`, `gitlab.mr_state_changed`, `gitlab.new_mr_to_review`, `gitlab.new_issue`, `gitlab.task_mr.updated`, `gitlab.watch.event` |
| Jira | `jira.new_issue` |
| Linear | `linear.new_issue` |
| Sentry | `sentry.new_issue` |
| Domain | Events |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Tasks | `task.created`, `task.updated`, `task.state_changed`, `task.deleted`, `task.moved`, `task.tree_hold_created`, `task.tree_hold_released` |
| Workspaces | `workspace.created`, `workspace.updated`, `workspace.deleted` |
| Workflows | `workflow.created`, `workflow.updated`, `workflow.deleted` |
| Workflow steps | `workflow_step.created`, `workflow_step.updated`, `workflow_step.deleted`, `workflow.step_completion_signaled` |
| Comments / messages | `message.added`, `message.updated`, `message.deleted`, `message.queue.status_changed` |
| Task sessions | `task_session.state_changed` |
| Task plans | `task_plan.created`, `task_plan.updated`, `task_plan.deleted`, `task_plan.revision.created`, `task_plan.reverted` |
| Task walkthroughs | `task_walkthrough.created`, `task_walkthrough.updated`, `task_walkthrough.deleted` |
| Session turns | `turn.started`, `turn.completed` |
| Repositories | `repository.created`, `repository.updated`, `repository.deleted`, `repository.script.created`, `repository.script.updated`, `repository.script.deleted` |
| Executors | `executor.created`, `executor.updated`, `executor.deleted`, `executor.profile.created`, `executor.profile.updated`, `executor.profile.deleted`, `executor.prepare.progress`, `executor.prepare.completed` |
| Users | `user.settings.updated` |
| System | `system.job.update` |
| Environments | `environment.created`, `environment.updated`, `environment.deleted` |
| Agent profiles | `agent_profile.created`, `agent_profile.updated`, `agent_profile.deleted` |
| Agents | `agent.started`, `agent.running`, `agent.boot_ready`, `agent.ready`, `agent.completed`, `agent.failed`, `agent.stopped`, `agent.context_reset`, `agent.acp_session_created`, `agentctl.starting`, `agentctl.ready`, `agentctl.error` |
| Agent stream | `agent.stream` (per-session: `agent.stream.<sessionId>`), `agent.turn.message_saved` |
| Agent prompts | `permission_request.received` (per-session: `permission_request.received.<sessionId>`) |
| Clarification | `clarification.answered`, `clarification.primary_answered`, `clarification.cancelled`, `clarification.stale_dismissed` |
| Git / workspace status | `git.event` (per-session), `git.ws` (per-session), `file.change.notified` (per-session) |
| Shell I/O | `shell.output` (per-session), `shell.exit` (per-session) |
| Dev server I/O | `process.output` (per-session), `process.status` (per-session) |
| Session context | `context_window.updated` (per-session), `available_commands.updated` (per-session), `session_mode.changed` (per-session), `agent_capabilities.updated` (per-session), `session_models.updated` (per-session), `session_info.updated` (per-session), `session_todos.updated` (per-session), `session_prompt_usage.updated` (per-session) |
| Automations | `automation.triggered`, `automation.run.created` |
| GitHub | `github.pr_feedback`, `github.pr_state_changed`, `github.new_pr_to_review`, `github.new_issue`, `github.task_pr.updated`, `github.task_ci_options.updated`, `github.watch.event`, `github.rate_limit.updated` |
| GitLab | `gitlab.mr_feedback`, `gitlab.mr_state_changed`, `gitlab.new_mr_to_review`, `gitlab.new_issue`, `gitlab.task_mr.updated`, `gitlab.watch.event` |
| Jira | `jira.new_issue` |
| Linear | `linear.new_issue` |
| Sentry | `sentry.new_issue` |
| Office (autonomous agents) | `office.agent.created`, `office.agent.updated`, `office.agent.status_changed`, `office.skill.created`, `office.skill.updated`, `office.project.created`, `office.project.updated`, `office.approval.created`, `office.approval.resolved`, `office.comment.created`, `office.cost.recorded`, `office.run.queued`, `office.run.processed`, `office.run.event_appended` (per-run), `office.routine.triggered`, `office.inbox.item`, `office.task.status_changed`, `office.task.updated`, `office.task.decision_recorded`, `office.task.review_requested`, `office.provider.health_changed`, `office.route_attempt.appended`, `office.routing.settings_updated` |
| Cross-plugin | `plugin.<plugin_id>.<name>` — published by `Host.EmitEvent`; subscribe with `plugin.<other-plugin-id>.*` to react to another plugin's events. |
| Cross-plugin | `plugin.<plugin_id>.<name>` — published by `Host.EmitEvent`; subscribe with `plugin.<other-plugin-id>.*` to react to another plugin's events. |
> Event list current as of 2026-07-16; regenerate from
> `apps/backend/internal/events/types.go` if this page drifts from the code.
@@ -272,13 +290,13 @@ alongside the parsed manifest. These are **kandev-owned runtime state, not
author-supplied manifest fields** — do not include them in `manifest.yaml`;
they have no effect there and are overwritten on install:
| Field | Meaning |
|---|---|
| `status` | Current lifecycle state: `registered`, `active`, `error`, `disabled`, or `uninstalled`. |
| `install_path` | Absolute path the package was extracted to (`~/.kandev/plugins/<id>/<version>/`). |
| `signed` | Whether the package's `checksums.txt.sig` was cryptographically verified. Signature verification is not currently wired, so this is always false today (every package is reported unsigned). |
| `installed_at` | Install timestamp. |
| `restart_count` | Best-effort restart bookkeeping used by the supervision loop. |
| Field | Meaning |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `status` | Current lifecycle state: `registered`, `active`, `error`, `disabled`, or `uninstalled`. |
| `install_path` | Absolute path the package was extracted to (`~/.kandev/plugins/<id>/<version>/`). |
| `signed` | Whether the package's `checksums.txt.sig` was cryptographically verified. Signature verification is not currently wired, so this is always false today (every package is reported unsigned). |
| `installed_at` | Install timestamp. |
| `restart_count` | Best-effort restart bookkeeping used by the supervision loop. |
Related: [Plugins](plugins.md), [Authoring a plugin](plugins-authoring.md).
+1
View File
@@ -46,6 +46,7 @@ Product-wide capabilities that are not tied to a single feature area.
| [plugins](plugins/spec.md) | draft |
| [plugins — authoring experience](plugins/authoring-experience.md) | draft |
| [plugins — marketplace](plugins/marketplace.md) | building |
| [plugins — Voice extraction host prerequisites](plugins/voice-extraction-host.md) | draft |
| [semantic-notifications](platform/notifications.md) | shipped |
| [workspace-git-status](platform/workspace-git-status.md) | shipped |
| [git-subprocess-admission](platform/git-subprocess-admission.md) | building |
+201
View File
@@ -0,0 +1,201 @@
---
status: draft
created: 2026-08-11
owner: kandev
---
# Voice Plugin Host Prerequisites
## Why
Voice Mode can ship independently only if a plugin can participate in every native prompt composer
without bypassing its draft and submission behavior, and can invoke its own billable backend without
exposing that operation as a public webhook. The host must provide those narrow capabilities before
the existing core implementation can move to a dedicated plugin repository.
Decision: [ADR-2026-08-11-composer-access-authenticated-webhooks](../../decisions/2026-08-11-composer-access-authenticated-webhooks.md).
## What
- A plugin action rendered in a supported composer receives typed composer props owned by that
mounted composer. They report whether editing and submission are currently allowed, insert
text at the current selection, focuses the editor, and submits through the same handler as the
native submit control.
- `composer.insertText(text)` trims an empty transcript to a no-op, replaces the selected range (or
inserts at the caret), applies the native Voice Mode word-boundary spacing rule, places the caret
after the insertion, updates the authoritative local draft synchronously, and focuses the input.
- `composer.submit()` returns a typed outcome and never constructs or sends a message itself. The
native path remains responsible for all existing submission behavior and errors.
- Slot props expose live `disabled`, `submittable`, and optional `disabledReason` state. An
operation against an unmounted or superseded composer fails closed with `unavailable`; submit
against a currently blocked form returns `blocked` without discarding the draft.
- Composer action slots exist in desktop and native-mobile task chat, Quick Chat, task creation, and
new-session creation. Each slot reports its surface and presentation plus only the identifiers
meaningful on that surface.
- The existing `chat-input-actions` registration remains source-compatible. Its slot props gain the
composer capability and surface metadata; new `task-create-input-actions` and
`new-session-input-actions` slots cover creation forms. Quick Chat uses `chat-input-actions` with
`surface: "quick-chat"` and `taskId: null` when it is genuinely task-less.
- A webhook declaration chooses `access: public` or `access: authenticated`. Existing declarations
default to `public`; authenticated webhooks require the current Kandev user and same-origin browser
request checks before the existing plugin webhook RPC runs.
- A webhook declaration may lower its request cap through `max_body_bytes`. Only authenticated
webhooks may raise it above the existing 4 MiB public ceiling, up to a host ceiling of 16 MiB.
Voice transcription declares an
authenticated webhook with a limit of at least 10 MiB.
- The Voice plugin's OpenAI key belongs to its existing plugin settings and secret storage. Kandev
relays the request but does not own, expose, or select that key.
- Plugin localization is a separately scoped prerequisite. This package adds no host i18n API. The
later Voice plugin task must decide how plugin-owned catalogs follow Kandev's locale before core
Voice Mode is removed.
- Disabling, uninstalling, or reloading a plugin aborts its in-flight webhook requests, unregisters
its composer actions, and leaves native drafts intact.
- The current core Voice Mode stays present until a separately delivered Voice plugin proves parity
on all listed surfaces. Removing core Voice Mode is not part of these host prerequisites.
## API Surface
### Composer slots
```ts
type PluginComposerSurface =
| "task-chat"
| "quick-chat"
| "task-create"
| "new-session";
type PluginPresentation = "desktop" | "mobile";
type PluginComposerSubmitResult =
| { status: "submitted" }
| { status: "blocked"; reason?: string }
| { status: "unavailable" };
interface PluginComposerCapability {
insertText(text: string): { status: "inserted" | "ignored" | "unavailable" };
focus(): { status: "focused" | "unavailable" };
submit(): Promise<PluginComposerSubmitResult>;
}
interface PluginComposerSlotProps {
surface: PluginComposerSurface;
presentation: PluginPresentation;
taskId: string | null;
taskTitle?: string;
activeSessionId: string | null;
sessionIds: string[];
disabled: boolean;
submittable: boolean;
disabledReason?: string;
composer: PluginComposerCapability;
}
```
The host re-renders slot props when native state changes. `submit()` always revalidates native state
at call time. The host may replace the capability object when a surface is remounted; methods on the
old object return `unavailable`.
### Authenticated webhooks
Manifest:
```yaml
webhooks:
- key: transcribe
access: authenticated
max_body_bytes: 16777216
```
Frontend:
```ts
host.api.fetch("webhooks/transcribe", {
method: "POST",
body: formData,
signal,
});
```
HTTP and runtime wire contract:
```text
POST /api/plugins/{pluginId}/webhooks/{key}
Origin: <the configured Kandev web origin>
rpc HandleWebhook(WebhookRequest) returns (WebhookResponse)
```
`access` is enforced by the host before `HandleWebhook` runs. For `authenticated`, the normal Kandev
identity must be present and browser cookie authentication additionally requires an accepted Origin;
authentication-disabled installations use the synthetic default user. Public webhooks retain their
current external-call behavior. Request cancellation propagates through the existing RPC context.
## Permissions
- Composer capabilities exist only as props on a currently rendered slot; they are not placed in
`host.store`, exported globally, or addressable by task/session ID.
- Webhook access changes only who may invoke that declared key. It grants no Host data, state, secret,
or event capability.
- Plugin backends receive the existing webhook request shape. They never receive the Kandev session
cookie or PAT. The Voice plugin reads its OpenAI secret through existing plugin configuration.
## Failure Modes
- Empty or whitespace-only insertion is ignored. Unsupported or stale composers return
`unavailable`; they do not mutate another surface's draft.
- A blocked native submit returns `blocked`, preserves the native draft, and leaves native error
presentation authoritative. A native submission failure resolves `blocked` with its safe reason
where available and preserves the draft exactly as a native failure does.
- An unavailable plugin runtime returns `503`; cancellation stops the runtime request and returns no
synthetic success; an oversized body returns `413`; malformed multipart or metadata returns `400`;
missing authentication returns `401`; origin or declaration failure returns
`403` or `404` according to the existing hide-existence policy.
- Host logs may contain plugin ID, action, request ID, byte count, duration, status, and cancellation,
but never audio bytes, authorization material, cookies, or plugin secrets.
## Compatibility And Versioning
- These additions are backward-compatible within plugin `api_version: 1`; all new manifest and host
fields are optional, and existing slot components that ignore added props continue to work.
- A plugin using `webhooks[].access` or `max_body_bytes` must set `min_kandev_version` to the first
release containing this contract. Older hosts reject installation instead of silently treating an
authenticated webhook as public.
- Manifest types, validation, authoring docs, and handler behavior change together. The protobuf and
Go plugin SDK webhook contract remain unchanged.
## Scenarios
- **GIVEN** a task-chat selection, **WHEN** a plugin inserts a transcript and invokes submit, **THEN**
the selection is replaced and the existing native submit handler runs.
- **GIVEN** a running agent configured for steering or queueing, **WHEN** a plugin auto-submits,
**THEN** the same steering or queueing decision and error handling as the native submit button runs.
- **GIVEN** task creation or new-session creation on desktop or Pixel 5, **WHEN** a plugin inserts at
the textarea selection and submits, **THEN** the native form validation and creation handler run.
- **GIVEN** a plugin action is recording on one session, **WHEN** the task, session, or composer
changes, **THEN** the plugin cancels its request and the stale capability cannot modify the new
composer.
- **GIVEN** Quick Chat, **WHEN** a composer plugin action is rendered, **THEN** it receives the shared
capability with `surface: "quick-chat"` and completes the same insertion and submit behavior.
- **GIVEN** a plugin declares an authenticated `transcribe` webhook with a 16 MiB cap, **WHEN** its UI
sends 10 MiB of multipart audio through `host.api.fetch`, **THEN** the existing webhook RPC reaches
that plugin backend and returns its response.
- **GIVEN** an unauthenticated caller, **WHEN** it calls the authenticated transcription webhook,
**THEN** the request fails before plugin execution and cannot consume the plugin's configured key.
- **GIVEN** an in-flight transcription, **WHEN** the caller aborts or the plugin is disabled, **THEN**
the HTTP and runtime requests are cancelled and no transcript is inserted.
- **GIVEN** a plugin built for this contract, **WHEN** it is installed on an older Kandev release,
**THEN** `min_kandev_version` rejects installation with no partial registration.
## Out Of Scope
- Implementing, packaging, publishing, or removing the dedicated Voice plugin.
- Removing core Voice Mode before plugin parity and migration are separately validated.
- Exposing draft text, selection offsets, or arbitrary native composer mutation through
`host.store` or a global host API.
- General authenticated REST routing, response streaming, bidirectional browser sockets, or changing
the default limit for existing public webhooks.
- Sandboxing mutually malicious native UI plugin bundles.
- Plugin localization; it is a separate prerequisite for the later Voice plugin extraction.
## Implementation Plan
See [the implementation plan](../../plans/plugin-voice-extraction-host/plan.md).