10 KiB
status, created, owner
| status | created | owner |
|---|---|---|
| shipped | 2026-08-11 | 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.
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 optionaldisabledReasonstate. An operation against an unmounted or superseded composer fails closed withunavailable; submit against a currently blocked form returnsblockedwithout 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-actionsregistration remains source-compatible. Its slot props gain the composer capability and surface metadata; newtask-create-input-actionsandnew-session-input-actionsslots cover creation forms. Quick Chat useschat-input-actionswithsurface: "quick-chat"andtaskId: nullwhen it is genuinely task-less. - A webhook declaration chooses
access: publicoraccess: authenticated. Existing declarations default topublic; 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-owned copy uses the scoped catalog and reactive locale API established by ADR-2026-08-12-plugin-localization-contract. The later Voice plugin registers its English fallback and supported-locale catalogs before any composer contribution.
- 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. That removal has since happened; see Voice Mode leaves core.
API Surface
Composer slots
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, reading the live draft rather than the last render's snapshot, so a plugin that inserts
and submits in one callback is not told blocked for text it just inserted.
The host replaces the capability object whenever the composer's identity changes: its surface, task
or session. Methods on the superseded object return unavailable. Identity matters separately from
mounting because a composer is re-rendered, not remounted, when the user switches task or session, so
without it a handle captured while recording on one conversation would act on the next.
Authenticated webhooks
Manifest:
webhooks:
- key: transcribe
access: authenticated
max_body_bytes: 16777216
Frontend:
host.api.fetch("webhooks/transcribe", {
method: "POST",
body: formData,
signal,
});
HTTP and runtime wire contract:
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 resolvesblockedwith 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 returns413; malformed multipart or metadata returns400; missing authentication returns401; origin or declaration failure returns403or404according 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[].accessormax_body_bytesmust setmin_kandev_versionto 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
transcribewebhook with a 16 MiB cap, WHEN its UI sends 10 MiB of multipart audio throughhost.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_versionrejects 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.storeor 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.