@yaklabs-ai/plugin-sdk 0.8.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,180 @@
1
+ // packages/plugin-protocol/dist/phase-shift.js
2
+ import { z } from "zod";
3
+
4
+ // packages/plugin-protocol/dist/transcript.js
5
+ function branchEntryIds(entries) {
6
+ const last = entries[entries.length - 1];
7
+ if (last === void 0)
8
+ return EMPTY_BRANCH_IDS;
9
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
10
+ const onBranch = /* @__PURE__ */ new Set();
11
+ let cursor = last.type === "leaf" ? last.targetId ?? null : last.id;
12
+ while (cursor !== null && !onBranch.has(cursor)) {
13
+ onBranch.add(cursor);
14
+ cursor = byId.get(cursor)?.parentId ?? null;
15
+ }
16
+ return onBranch;
17
+ }
18
+ var EMPTY_BRANCH_IDS = /* @__PURE__ */ new Set();
19
+
20
+ // packages/plugin-protocol/dist/phase-shift.js
21
+ var PHASE_SHIFT_EXTENSION_ID = "kay.phase-shift";
22
+ var PHASE_SHIFT_ENTRY_NAME = "observation";
23
+ var PHASE_SHIFT_CUSTOM_TYPE = `ext:${PHASE_SHIFT_EXTENSION_ID}:${PHASE_SHIFT_ENTRY_NAME}`;
24
+ var PHASE_SHIFT_TIMELINE_CUSTOM_TYPE = `ext:${PHASE_SHIFT_EXTENSION_ID}:timeline`;
25
+ var PHASE_SHIFT_DETECTOR_VERSION = "activity-memory-v1";
26
+ var entryId = z.string().min(1).max(256);
27
+ var purpose = z.string().trim().min(1).max(320);
28
+ var sourceTime = z.iso.datetime();
29
+ var phaseShiftMemorySchema = z.object({
30
+ startEntryId: entryId,
31
+ purpose,
32
+ anchors: z.array(z.object({ entryId, excerpt: z.string().max(250) })).min(1).max(3)
33
+ });
34
+ var phaseShiftCostSchema = z.object({
35
+ amountUsd: z.number().nonnegative(),
36
+ amountBasis: z.enum(["measured", "list-estimate", "unknown"]),
37
+ inputTokens: z.number().int().nonnegative(),
38
+ outputTokens: z.number().int().nonnegative(),
39
+ /**
40
+ * The highest provider-attempt ordinal this check accounted for; recovery
41
+ * after a failure makes it more than one. An attempt that threw before
42
+ * reporting usage is invisible to accounting, so this is a lower bound on
43
+ * what the provider was actually asked to do.
44
+ */
45
+ attempts: z.number().int().positive()
46
+ });
47
+ var observationFields = {
48
+ detectorVersion: z.literal(PHASE_SHIFT_DETECTOR_VERSION),
49
+ modelReference: z.string().min(1).max(256),
50
+ observedAt: z.iso.datetime(),
51
+ throughEntryId: entryId,
52
+ windowEntryIds: z.array(entryId).min(1).max(40),
53
+ memory: phaseShiftMemorySchema
54
+ };
55
+ var changeFields = {
56
+ startEntryId: entryId,
57
+ relation: z.enum(["outcome", "phase"]),
58
+ reason: z.string().trim().min(1).max(2e3),
59
+ purposeAfter: purpose
60
+ };
61
+ var phaseShiftObservationV1Schema = z.object({
62
+ version: z.literal(1),
63
+ ...observationFields,
64
+ changes: z.array(z.object(changeFields)).max(39)
65
+ });
66
+ var phaseShiftObservationV2Schema = z.object({
67
+ version: z.literal(2),
68
+ ...observationFields,
69
+ /** Timestamp of the `windowEntryIds[0]` entry: where this check's coverage starts. */
70
+ windowStartAt: sourceTime,
71
+ /** Timestamp of the `throughEntryId` entry: where this check's coverage ends. */
72
+ throughAt: sourceTime,
73
+ /** What this check cost to run; absent on a record written before it was accounted. */
74
+ cost: phaseShiftCostSchema.optional(),
75
+ changes: z.array(z.object({
76
+ ...changeFields,
77
+ /** Timestamp of the `startEntryId` entry: when the new work began. */
78
+ startedAt: sourceTime
79
+ })).max(39)
80
+ });
81
+ var phaseShiftObservationSchema = z.discriminatedUnion("version", [
82
+ phaseShiftObservationV1Schema,
83
+ phaseShiftObservationV2Schema
84
+ ]);
85
+ function acceptedPhaseShiftObservationEntries(branch, options) {
86
+ const positions = new Map(branch.flatMap((entry, index) => entry.type === "message" || entry.type === "custom_message" ? [[entry.id, index]] : []));
87
+ const accepted = [];
88
+ let through = -1;
89
+ let start = -1;
90
+ for (let index = 0; index < branch.length; index += 1) {
91
+ const entry = branch[index];
92
+ if (entry.type !== "custom" || entry.customType !== PHASE_SHIFT_CUSTOM_TYPE)
93
+ continue;
94
+ const parsed = phaseShiftObservationSchema.safeParse(entry.data);
95
+ if (!parsed.success) {
96
+ options?.onRefusedRecord?.({
97
+ entryId: entry.id,
98
+ issues: issueMarkers(parsed.error)
99
+ });
100
+ continue;
101
+ }
102
+ const observation = parsed.data;
103
+ const cutoff = positions.get(observation.throughEntryId) ?? -1;
104
+ const memoryStart = positions.get(observation.memory.startEntryId) ?? -1;
105
+ const window = observation.windowEntryIds.map((id) => positions.get(id) ?? -1);
106
+ const onsets = observation.changes.map((change) => positions.get(change.startEntryId) ?? -1);
107
+ if (cutoff <= through || cutoff >= index || memoryStart < 0 || memoryStart < start || memoryStart > cutoff || window.at(-1) !== cutoff || window.some((position, i) => position < 0 || i > 0 && position <= window[i - 1]) || onsets.some((position, i) => !window.includes(position) || position <= window[0] || position <= start || i > 0 && position <= onsets[i - 1]) || observation.memory.anchors.some((anchor) => {
108
+ const position = positions.get(anchor.entryId) ?? -1;
109
+ return position < memoryStart || position > cutoff;
110
+ }))
111
+ continue;
112
+ const lastChange = observation.changes.at(-1);
113
+ if (lastChange !== void 0) {
114
+ if (observation.memory.startEntryId !== lastChange.startEntryId || observation.memory.purpose !== lastChange.purposeAfter)
115
+ continue;
116
+ } else {
117
+ const previous = accepted.at(-1)?.observation;
118
+ if (previous !== void 0 && JSON.stringify(observation.memory) !== JSON.stringify(previous.memory)) {
119
+ continue;
120
+ }
121
+ }
122
+ accepted.push({ entryId: entry.id, observation });
123
+ through = cutoff;
124
+ start = memoryStart;
125
+ }
126
+ return accepted;
127
+ }
128
+ function acceptedPhaseShiftObservations(branch, options) {
129
+ return acceptedPhaseShiftObservationEntries(branch, options).map((entry) => entry.observation);
130
+ }
131
+ function issueMarkers(error) {
132
+ return error.issues.slice(0, 3).map((issue) => {
133
+ const record = issue;
134
+ const code = typeof record.code === "string" ? record.code : "issue";
135
+ const path = Array.isArray(record.path) ? record.path.join(".") : "";
136
+ return `${code}@${path}`;
137
+ }).join(";").slice(0, 120);
138
+ }
139
+ function phaseShiftTimelineRecord(observation) {
140
+ return {
141
+ version: 1,
142
+ detectorVersion: observation.detectorVersion,
143
+ modelReference: observation.modelReference,
144
+ observedAt: observation.observedAt,
145
+ windowStartEntryId: observation.windowEntryIds[0],
146
+ windowStartAt: observation.windowStartAt,
147
+ throughEntryId: observation.throughEntryId,
148
+ throughAt: observation.throughAt,
149
+ purpose: observation.memory.purpose,
150
+ ...observation.cost === void 0 ? {} : { cost: observation.cost },
151
+ changes: observation.changes.map((change) => ({
152
+ startEntryId: change.startEntryId,
153
+ startedAt: change.startedAt,
154
+ relation: change.relation,
155
+ reason: change.reason,
156
+ purposeAfter: change.purposeAfter
157
+ }))
158
+ };
159
+ }
160
+ function phaseShiftTimelineFromEntries(entries) {
161
+ const onBranch = branchEntryIds(entries);
162
+ const branch = entries.filter((entry) => onBranch.has(entry.id));
163
+ const timestamps = new Map(branch.map((entry) => [entry.id, entry.timestamp]));
164
+ const at = (id) => timestamps.get(id) ?? "";
165
+ return acceptedPhaseShiftObservations(branch).map((observation) => observation.version === 2 ? phaseShiftTimelineRecord(observation) : phaseShiftTimelineRecord({
166
+ ...observation,
167
+ version: 2,
168
+ windowStartAt: at(observation.windowEntryIds[0]),
169
+ throughAt: at(observation.throughEntryId),
170
+ changes: observation.changes.map((change) => ({
171
+ ...change,
172
+ startedAt: at(change.startEntryId)
173
+ }))
174
+ }));
175
+ }
176
+ export {
177
+ PHASE_SHIFT_CUSTOM_TYPE,
178
+ PHASE_SHIFT_TIMELINE_CUSTOM_TYPE,
179
+ phaseShiftTimelineFromEntries
180
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The ids on a session's active branch: the leaf chain walked parent-ward from
3
+ * the tip, which a trailing `leaf` marker moves. Empty for an empty log.
4
+ *
5
+ * One walk shared by every reader of "is this entry still live", so a rewind
6
+ * means the same thing to every projection that calls it. Self-guarding
7
+ * against a cyclic or otherwise malformed parent chain: the loop stops the
8
+ * moment it revisits an id, so a cycle yields the cycle's own ids rather than
9
+ * looping forever.
10
+ */
11
+ export declare function branchEntryIds(entries: readonly TranscriptTreeEntry[]): ReadonlySet<string>;
12
+
13
+ /**
14
+ * Every `type: "custom"` entry of one `customType`, oldest→newest, with every
15
+ * field ({@link TranscriptSourceEntry}'s id/timestamp/tree links plus the
16
+ * entry's own `data`) preserved unchanged — `data` is returned exactly as it
17
+ * arrived, opaque and unvalidated, never coerced toward a schema this module
18
+ * cannot know.
19
+ *
20
+ * `options.branch` is required, deliberately: `"active"` filters to
21
+ * {@link branchEntryIds}; `"all"` returns every matching entry in the flat
22
+ * log, rewind-abandoned or not. A silent default would let a caller reach
23
+ * for whichever one it did not mean to — pick `"all"` only when the caller
24
+ * itself needs to reason about abandoned entries (an audit, a recovery
25
+ * tool); an ordinary reader states `"active"` explicitly.
26
+ */
27
+ export declare function customEntriesFromEntries<T extends TranscriptSourceEntry = TranscriptSourceEntry>(entries: readonly T[], customType: string, options: {
28
+ readonly branch: "active" | "all";
29
+ }): readonly T[];
30
+
31
+ /** The fields the customType selector needs off a session entry. */
32
+ export declare interface TranscriptEntry {
33
+ readonly id: string;
34
+ readonly type: string;
35
+ readonly customType?: string;
36
+ /** Opaque. Never parsed or validated here — a feature's own schema owns that. */
37
+ readonly data?: unknown;
38
+ }
39
+
40
+ /**
41
+ * A tree entry plus the ISO timestamp the timeline projections read alongside
42
+ * the walk result (`turnSegments`, `phaseShiftTimelineFromEntries`).
43
+ */
44
+ export declare interface TranscriptSourceEntry extends TranscriptTreeEntry {
45
+ readonly timestamp: string;
46
+ }
47
+
48
+ /**
49
+ * A session entry as {@link branchEntryIds} reads it: the fields above plus the
50
+ * tree links the walk follows to find the active branch. No timestamp: the walk
51
+ * never reads one, and a live v2 entry carries epoch milliseconds where a raw
52
+ * v3 log carries an ISO string, so requiring either shape here would force one
53
+ * class of caller to convert entries it only wants walked.
54
+ */
55
+ declare interface TranscriptTreeEntry extends TranscriptEntry {
56
+ readonly parentId?: string | null;
57
+ readonly targetId?: string | null;
58
+ }
59
+
60
+ export { }
@@ -0,0 +1,23 @@
1
+ // packages/plugin-protocol/dist/transcript.js
2
+ function branchEntryIds(entries) {
3
+ const last = entries[entries.length - 1];
4
+ if (last === void 0)
5
+ return EMPTY_BRANCH_IDS;
6
+ const byId = new Map(entries.map((entry) => [entry.id, entry]));
7
+ const onBranch = /* @__PURE__ */ new Set();
8
+ let cursor = last.type === "leaf" ? last.targetId ?? null : last.id;
9
+ while (cursor !== null && !onBranch.has(cursor)) {
10
+ onBranch.add(cursor);
11
+ cursor = byId.get(cursor)?.parentId ?? null;
12
+ }
13
+ return onBranch;
14
+ }
15
+ var EMPTY_BRANCH_IDS = /* @__PURE__ */ new Set();
16
+ function customEntriesFromEntries(entries, customType, options) {
17
+ const onBranch = options.branch === "all" ? null : branchEntryIds(entries);
18
+ return entries.filter((entry) => entry.type === "custom" && entry.customType === customType && (onBranch === null || onBranch.has(entry.id)));
19
+ }
20
+ export {
21
+ branchEntryIds,
22
+ customEntriesFromEntries
23
+ };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@yaklabs-ai/plugin-sdk",
3
+ "version": "0.8.4",
4
+ "description": "The Kay plugin SDK — types and helpers for authoring a Kay directory plugin.",
5
+ "author": "YakLabs",
6
+ "type": "module",
7
+ "engines": {
8
+ "node": ">=24"
9
+ },
10
+ "sideEffects": false,
11
+ "exports": {
12
+ "./kay-agent": {
13
+ "types": "./dist/kay-agent.d.ts",
14
+ "default": "./dist/kay-agent.js"
15
+ },
16
+ "./kay-app": {
17
+ "types": "./dist/kay-app.d.ts",
18
+ "default": "./dist/kay-app.js"
19
+ },
20
+ "./kay-agent/testing": {
21
+ "types": "./dist/kay-agent-testing.d.ts",
22
+ "default": "./dist/kay-agent-testing.js"
23
+ },
24
+ "./disclosure": {
25
+ "types": "./dist/disclosure.d.ts",
26
+ "default": "./dist/disclosure.js"
27
+ },
28
+ "./phase-shift": {
29
+ "types": "./dist/phase-shift.d.ts",
30
+ "default": "./dist/phase-shift.js"
31
+ },
32
+ "./transcript": {
33
+ "types": "./dist/transcript.d.ts",
34
+ "default": "./dist/transcript.js"
35
+ }
36
+ },
37
+ "files": [
38
+ "dist",
39
+ "README.md",
40
+ "LICENSE",
41
+ "THIRD-PARTY-NOTICES.md",
42
+ "THIRD-PARTY-INVENTORY.json",
43
+ "skills"
44
+ ],
45
+ "dependencies": {
46
+ "zod": "^4.0.0",
47
+ "@typesafe-ai/sdk": "0.6.0"
48
+ },
49
+ "publishConfig": {
50
+ "registry": "https://registry.npmjs.org",
51
+ "access": "public"
52
+ },
53
+ "repository": {
54
+ "type": "git",
55
+ "url": "git+https://github.com/yaklabs-ai/kay.git",
56
+ "directory": "packages/plugin-sdk"
57
+ },
58
+ "license": "MIT"
59
+ }
@@ -0,0 +1,252 @@
1
+ ---
2
+ name: directory-plugin-authoring
3
+ description: "Author, scaffold, debug, or iterate on a Kay directory plugin (one the user writes in ~/.kay/plugins/<name>). Use when creating a new local plugin, when a plugin does not appear in Kay after installation, when enabling it fails with 'pluginActivation names a plugin that is not installed', when Settings → Plugins shows 'failed to load' with no toggle, when @kay/plugin-sdk imports fail to resolve, when a plugin declares a ui.tsx that nothing typechecks, or when changes to a plugin do not take effect. For installing plugins from a git repository source, use the manage-plugins skill instead; for first-party bundled plugins under plugins/ in the kay repo, use the bundled-plugin guidance there."
4
+ ---
5
+
6
+ # Directory plugin authoring
7
+
8
+ A directory plugin is a plugin the user authors in `~/.kay/plugins/<name>/`, outside the Kay
9
+ repo. The full authoring guide is [`docs/directory-plugins.md`](../../docs/directory-plugins.md)
10
+ and the SDK reference is `packages/plugin-sdk/README.md`; this skill is the routing layer that
11
+ keeps an agent on the paved path and out of the known pits.
12
+
13
+ ## Confirm the checkout is current before you scaffold
14
+
15
+ `pnpm create-plugin` writes absolute type paths (`tsconfig.json`, `package.json`) pointing at
16
+ whichever Kay checkout invoked it, with no staleness warning. A checkout far behind `origin/main`
17
+ — one was 752 commits behind — typechecks the new plugin against stale `@kay/plugin-sdk` types,
18
+ and it looks clean: every wrong shape it produced (a `content` object instead of
19
+ `run`/`openBeside` on `PaneActionDescriptor`, `SearchField`'s `onChange` instead of
20
+ `onValueChange`, wrong `PageState` props) typechecked fine against the old types it was
21
+ scaffolded from.
22
+
23
+ So preflight the checkout, before scaffolding rather than after a typecheck already looked green:
24
+
25
+ ```
26
+ git -C <kay-checkout> fetch origin && git -C <kay-checkout> rev-list --count HEAD..origin/main
27
+ ```
28
+
29
+ A nonzero count means update that checkout first, or scaffold from a fresh `origin/main` worktree
30
+ and rebuild `@kay/plugin-sdk` there. A green typecheck against stale types is evidence of nothing.
31
+ The scaffold does the second half of this for you: it prints a warning when the checkout trails
32
+ its last-fetched `origin/main`, and stamps the checkout's commit into the generated
33
+ `tsconfig.json` header. It never fetches, so the `fetch` above is still yours to run; a checkout
34
+ that has not fetched in a week reads as current to the scaffold.
35
+
36
+ ## Scaffold, never hand-roll
37
+
38
+ ```
39
+ pnpm --filter @kay/plugin-sdk build # once per checkout, if not built
40
+ pnpm create-plugin --id com.example.widgets
41
+ ```
42
+
43
+ Run from the root of a Kay checkout. It writes a working plugin (manifest, entrypoint, tsconfig,
44
+ README) that already typechecks and loads; `--tier starter` adds a URI module, a bundled skill,
45
+ and a standalone `node --test` test to copy from. Add `--ui` (either tier) when the plugin needs a
46
+ page: it writes `ui.tsx`, declares the `kay.ui` entrypoint, and generates the UI typecheck config
47
+ described below. `--ui` also needs `pnpm --filter @kay/ui build` once per checkout; the scaffold
48
+ stops and says so when that build is missing.
49
+
50
+ Never hand-build a plugin as a tsc-compiled package and copy the output into `~/.kay/plugins/`.
51
+ That path produces a plugin whose built `dist/` imports `@kay/plugin-sdk` through ordinary
52
+ `node_modules` resolution, which cannot work from outside the monorepo — it shipped a
53
+ failed-to-load plugin and a misfiled bug (#7732). The load fails with `ERR_MODULE_NOT_FOUND`,
54
+ and symlinking `node_modules/@kay` into the copy does not rescue it either: pnpm's `.pnpm`
55
+ layout does not expose workspace packages that way. The scaffold exists precisely so this
56
+ cannot happen.
57
+
58
+ ## The SDK resolution contract
59
+
60
+ - Plugin source imports `@kay/plugin-sdk/<subpath>` as a **bare specifier**
61
+ (`@kay/plugin-sdk/kay-agent`, `/kay-agent/testing`, `/kay-app`, `/disclosure` — only declared
62
+ exports exist).
63
+ - The daemon's chord loader bundles each declared TypeScript entrypoint and its relative imports.
64
+ It keeps `@kay/plugin-sdk` imports external and resolves them to the running app's own built SDK.
65
+ Multi-file plugins are supported, and no plugin-local build is needed for Kay to load them.
66
+ - Corollary: **bare `node` outside the daemon cannot resolve `@kay/plugin-sdk`** from the plugin
67
+ directory unless the plugin has its own SDK dependency. That failure says nothing about whether
68
+ the plugin will load in Kay. The `starter` tier's test setup creates the symlink needed for its
69
+ standalone test; a published SDK dependency is the other standalone option.
70
+ - Typecheck with `npm run typecheck` inside the plugin directory; the scaffold's generated
71
+ tsconfig maps the SDK's types via `paths`.
72
+ - Chord compiles TypeScript syntax that native type stripping cannot execute, including `enum`,
73
+ `namespace` blocks, constructor parameter properties, and decorators. Typecheck before enabling
74
+ the plugin; a bundle error is reported as a target-load diagnostic.
75
+
76
+ ## A UI target: three files, its own typecheck, generated
77
+
78
+ A plugin with a UI has three targets. Each declared daemon target is a bundle root, so its
79
+ entrypoint may import other files in the plugin; `kay.agent` runs in the daemon per session and
80
+ `kay.app` runs in the daemon once. `kay.ui` runs in the renderer through its own UI bundle, which is a single
81
+ self-contained module: unlike the daemon targets, `ui.tsx` cannot import sibling files. A
82
+ plugin UI contributes through `api.slots` (`registerPage`, `registerSidebarItem`,
83
+ `registerPaneAction`, and the rest of `buildSlots` in
84
+ `apps/kay/src/renderer/src/extension-loader/build-extension-api.ts`). A page gets its own
85
+ activity-rail button when a `registerSidebarItem({ pageId, placement: "activityRail" })` from the
86
+ same plugin points at it.
87
+
88
+ **Icons: declare one plugin icon, override only where you mean it.** Set the manifest `icon` (a
89
+ package-relative SVG) and every surface the plugin contributes inherits it: reference pills, its
90
+ rail button, and its pages' tabs, pane headers and palette entries (shown as your full-colour mark
91
+ on a small white tile). Set `icon` on a `registerSidebarItem` or `registerPage` descriptor only to
92
+ show a monochrome glyph there instead (a glyph name such as `"git-branch"`, or `{ svg }`); a page
93
+ and its rail item borrow each other's glyph, so one declaration covers the destination. Full
94
+ precedence: `packages/kay-frontend-sdk/ARCHITECTURE.md`, "Plugin icon".
95
+
96
+ `ui.tsx` has no `node_modules` of its own. The renderer bundles it and rewrites its `react` and
97
+ `@kay/*` imports to the host's `__KAY_UI_RUNTIME__` singletons, so the agent `tsconfig.json`
98
+ cannot cover it, and a plugin whose UI nothing typechecks has shipped three wrong API shapes at
99
+ once. `pnpm create-plugin --ui` generates `tsconfig.ui.json` for exactly this: its `paths` are
100
+ derived from the bundler's own shim set (`uiRuntimeShimModules` in `@kay/ui-app-bundler`), each
101
+ specifier mapped to the type entry the renderer binds it to, and `npm run typecheck` runs both
102
+ configs. Do not hand-write the map; when the bundler's set changes, the scaffold's test fails and
103
+ the generated config follows.
104
+
105
+ Two facts about that config worth knowing when you read errors from it:
106
+
107
+ - The bare `@kay/frontend-sdk` is typed against the SDK's `ui-runtime` facade, because that is
108
+ what the renderer binds it to. The facade carries the SDK's whole type surface, so
109
+ `KayUiExtensionApi` and every descriptor type import fine; a **value** only the package's main
110
+ entry exports fails to compile there (TS1362, "exported using export type"), which is the
111
+ compiler telling you the running extension would receive `undefined`.
112
+ - It typechecks `ui.tsx` alone, as a browser module: `Bundler` resolution, automatic JSX, DOM
113
+ libs, no Node globals. The agent config keeps `**/*.ts`, so the two programs never overlap.
114
+
115
+ For a plugin scaffolded before `--ui` existed, scaffold a throwaway `--ui` plugin from the same
116
+ checkout and copy its `tsconfig.ui.json` and the second `typecheck` command into yours; `--force`
117
+ rewrites every generated file, including `entrypoint.ts`, so do not point it at a plugin you have
118
+ edited.
119
+
120
+ ## The iteration loop
121
+
122
+ 1. **Write the directory under the local plugin root, and back it with git.** A local plugin
123
+ IS a git repository: a version is a commit, and a directory without git backing fails to
124
+ load (the plugin's row says exactly that, with the fix). So after writing the files, commit
125
+ them: `git init && git add -A && git commit -m "adopt plugin"` inside the plugin directory.
126
+ The daemon watches the root, so the plugin appears by itself within a couple of seconds, no
127
+ verb called and no restart; the source refresh verb forces the walk if you want it now.
128
+
129
+ The root is `<home>/.kay/plugins`, and **`<home>` is the running instance's home**: a dev
130
+ app launched against its own state root has its own plugins root, not your real
131
+ `~/.kay/plugins`. Ask the daemon you are actually talking to rather than assuming — `sources
132
+ list` names the exact local root — because writing into the wrong one produces a plugin that
133
+ never appears and no diagnostic anywhere.
134
+
135
+ 2. **Enable it once.** Directory plugins are disabled on arrival (fail-closed: writing files in
136
+ your own plugin folder is how Kay DISCOVERS it, not consent to run it). Flip the toggle in
137
+ Settings → Plugins → your plugin's detail pane, or ask Kay to write the `installs` row's
138
+ `enabled` field. Enabling reconciles live, and contributions reach the agent's **next**
139
+ session, not the current one.
140
+ 3. **Edit, then commit.** `git add -A && git commit` makes the new code available.
141
+ Kay loads an isolated snapshot of HEAD and activates it live; no push or restart
142
+ is needed. Uncommitted edits, staged additions, deleted working files and runtime
143
+ writes do not execute, including at cold boot. The detail pane's **Running
144
+ commit** identifies the serving snapshot. Try new tools in a fresh session;
145
+ an existing session may still hold its previous generation.
146
+
147
+ A failed committed update keeps the last working revision and shows the load
148
+ diagnostic. Fix and commit the repair. Explicit source refresh retries discovery
149
+ or a failed read; it never activates uncommitted files. Keep runtime data in
150
+ host-owned plugin storage, not beside the snapshot's source files.
151
+
152
+ 4. **Grant what the manifest asks for, or activation is refused.** Anything declared under
153
+ `contributes` that carries a capability — a `contextContributors` entry needs
154
+ `dynamic-context.register` for that id, a data source needs `app-data.register`, a foreign
155
+ config section needs `config.declare-section` — must ALSO be granted before the plugin can
156
+ use it. Until then `activate()` throws at the first call, the plugin reads
157
+ `activation-failed`, its row carries a `grant` need naming the exact claim, and the page
158
+ shows the refusal text. The answer is the grant verb with that same claim shape (target,
159
+ slug, envelope), after which the activation is retried for you:
160
+
161
+ ```json
162
+ {
163
+ "target": "kay.app",
164
+ "slug": "dynamic-context.register",
165
+ "envelope": { "contributor": "<your-contributor-id>" }
166
+ }
167
+ ```
168
+
169
+ Granting is the human-approval gate, so an agent writing the row's `grants` raises a diff for
170
+ the user to accept; enabling and disabling do not.
171
+
172
+ 5. **Iterate in a fresh session.** A typecheck or direct tool call cannot show whether an agent
173
+ can discover and use the surface; try the user-goal case in a real new session.
174
+
175
+ ## Before you commit, verify what the commit will activate
176
+
177
+ The commit is the activation boundary, so run these against the working tree first. Each catches
178
+ a failure class the load would otherwise be the first thing to surface:
179
+
180
+ 1. **Validate `kay-plugin.json` against `KayPluginManifestV1`** (from `@kay/plugin-protocol`),
181
+ not just "it is valid JSON" — that schema is the gate the scan actually runs. Check every
182
+ declared target's endpoints are namespaced correctly.
183
+ 2. **Run the disclosure audit yourself**: `auditPluginDisclosure` from `@kay/plugin-sdk/disclosure`.
184
+ It is not redundant with typecheck. A plugin shipped a `search` verb with no `verbDocs` entry
185
+ that typechecked clean and would have failed the whole `kay.agent` target.
186
+ 3. **Typecheck every target**: `npm run typecheck` runs the agent config and, for a `--ui`
187
+ scaffold, `tsconfig.ui.json` after it. A plugin with a `ui.tsx` and only one config has an
188
+ unchecked target.
189
+ 4. **Round-trip the targets against each other.** They share no imports, so a typecheck proves
190
+ the shapes compile, not that `kay.app` and `kay.agent` agree on the data they share: write
191
+ through the UI's daemon endpoint, read it back through the agent's `scheme://` module, then
192
+ the reverse.
193
+
194
+ Then commit — that is what activates it — and confirm the serving snapshot is the commit you
195
+ just made: `read plugin://<id>` for the expected tools and skills with no load diagnostic, and
196
+ the detail pane's **Running commit** row for the revision.
197
+
198
+ One thing no commit proves: whether the UI entrypoint bundles in a **packaged** build. esbuild's
199
+ native binary resolution can fail silently inside `app.asar` (`ui_entrypoint_bundle_failed`), and
200
+ the only trace is `ui-app.directory-load-failed` in the day's `kay-<date>.jsonl` log —
201
+ `plugin://<id>` still reads enabled with no failure noted, and the pane simply has no overflow
202
+ menu. Current builds carry the fix (#8014). A dev app runs unpackaged and never exercises that
203
+ path, so report the packaged case as unverified instead of assuming it either way.
204
+
205
+ Dev-instance warning: a worktree dev app (`pnpm dev:kay`) is a **separate daemon** from the
206
+ user's installed Kay, with its own config store, consent records, and plugin roster. A plugin
207
+ visible in one is invisible to the other, and a `config://settings/pluginActivation` write from
208
+ a session in one daemon validates against that daemon's roster only. #7732 was two daemons'
209
+ outputs conflated into one impossible-looking bug; always ask "which daemon am I looking at?"
210
+
211
+ ## When it does not load
212
+
213
+ Read the Plugins page before the logs; every rejection stage produces a named diagnostic shown
214
+ on the plugin's row (or on the plugin source's row, when the whole source found nothing).
215
+
216
+ - **"failed to load" with no toggle** is the synthesized row for an install that failed before
217
+ producing a working plugin — it is untogglable by design. Fix the load failure; the toggle
218
+ belongs to the healthy row.
219
+ - **"names a plugin that is not installed"** means exactly that in this daemon: the plugin is
220
+ not in the composed roster. Check a scan actually saw it (`plugin-directory-scan.finished`
221
+ logs on every walk, not only at boot — an unconsented root skips the scan entirely), check
222
+ the root you wrote into is the one `sources list` names for THIS daemon, then check you are
223
+ talking to the daemon you think you are.
224
+ - **A plugin that reads `activation-failed` has usually been refused a capability**, not broken
225
+ its code: read the reason on its row (the failed target carries the refusal text) and grant
226
+ the claim it names.
227
+ - **A symlinked plugin directory may be skipped by the scan without a diagnostic.** If the scan
228
+ accepts N-1 of your N directories with zero diagnostics, look for a symlink.
229
+ - **The disclosure audit is fail-closed** and runs on every directory plugin; a violation fails
230
+ the whole `kay.agent` target. Run `auditPluginDisclosure` yourself before committing, per the
231
+ pre-commit checks above.
232
+
233
+ ## Declaring a scheduled workflow
234
+
235
+ A directory plugin does not request workflows in its manifest. During `kay.app` activation, call
236
+ `app.workflows.define(localId, { name, trigger, run })` to put a durable, daemon-scheduled row on
237
+ the Automations page. The host raises the `workflows.define` claim for every `kay.app` plugin; a
238
+ bundled install holds every grant, while a directory plugin needs the user to add that grant to its
239
+ `installs` row. Source class no longer decides workflow authority, and the capability is ABSENT
240
+ rather than refused when ungranted, so guard on `app.workflows === undefined`.
241
+
242
+ The product-agent procedure is the builtin skill `create-workflow`
243
+ (`packages/agent-runtime/src/kay/builtin-skills/`), and the API reference is the "Durable Workflows
244
+ In A Plugin" section of `apps/kay/agent-docs/extensions-api.md`. Keep those two in step with this
245
+ file.
246
+
247
+ ## Reference map
248
+
249
+ - [`docs/directory-plugins.md`](../../docs/directory-plugins.md) — the authoring guide:
250
+ manifest shape, the enable gate and what it does not gate, trust tiers, diagnostics.
251
+ - `packages/plugin-sdk/README.md` — the SDK surface.
252
+ - `packages/plugin-sdk/PUBLISHED.md` — installing the published SDK outside a Kay checkout.