{"_id":"@agentcorporation/plugin-sdk","_rev":"3-c8154cdec393411ca5d06c432833d71a","name":"@agentcorporation/plugin-sdk","dist-tags":{"latest":"1.0.1"},"versions":{"1.0.0":{"name":"@agentcorporation/plugin-sdk","version":"1.0.0","license":"MIT","_id":"@agentcorporation/plugin-sdk@1.0.0","maintainers":[{"name":"agentcorporation","email":"melaniecooperwork@gmail.com"}],"homepage":"https://github.com/AgenticCompany/company","bugs":{"url":"https://github.com/AgenticCompany/company/issues"},"bin":{"company-plugin-dev-server":"dist/dev-cli.js"},"dist":{"shasum":"06bbb2528aceca1ca787d3b198309aa1b81efa42","tarball":"https://registry.npmjs.org/@agentcorporation/plugin-sdk/-/plugin-sdk-1.0.0.tgz","fileCount":62,"integrity":"sha512-+S6gsqWWtUYBz3vDNz5xbYinz/UurGFrjB84cYKebmdT9jhjUnr5ibqEwDfaZAphCY8dG72QtzRHKJ/h/eKCSA==","signatures":[{"sig":"MEUCIQCv/inFK1NVKKrFspFuT2z6r723/Myh9BSMubd9Hem0XAIgaB01i/Ymh9m8lccX9rkT/seSF8nrNZxwlymM3oW44pM=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"unpackedSize":440036},"type":"module","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./ui":{"types":"./dist/ui/index.d.ts","import":"./dist/ui/index.js"},"./types":{"types":"./dist/types.d.ts","import":"./dist/types.js"},"./testing":{"types":"./dist/testing.d.ts","import":"./dist/testing.js"},"./bundlers":{"types":"./dist/bundlers.d.ts","import":"./dist/bundlers.js"},"./protocol":{"types":"./dist/protocol.d.ts","import":"./dist/protocol.js"},"./ui/hooks":{"types":"./dist/ui/hooks.d.ts","import":"./dist/ui/hooks.js"},"./ui/types":{"types":"./dist/ui/types.d.ts","import":"./dist/ui/types.js"},"./dev-server":{"types":"./dist/dev-server.d.ts","import":"./dist/dev-server.js"}},"gitHead":"b0b2a6a82af0fda218e2bd07bfb7f4b8d5ffd74d","scripts":{"build":"pnpm --filter @agentcorporation/shared build && tsc","clean":"rm -rf dist","typecheck":"pnpm --filter @agentcorporation/shared build && tsc --noEmit","dev:server":"tsx src/dev-cli.ts"},"_npmUser":{"name":"agentcorporation","email":"melaniecooperwork@gmail.com"},"repository":{"url":"git+https://github.com/AgenticCompany/company.git","type":"git","directory":"packages/plugins/sdk"},"_npmVersion":"10.9.3","description":"Stable public API for Company plugins — worker-side context and UI bridge hooks","directories":{},"_nodeVersion":"22.19.0","dependencies":{"zod":"^3.24.2","@agentcorporation/shared":"workspace:*"},"publishConfig":{"main":"./dist/index.js","types":"./dist/index.d.ts","access":"public","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./ui":{"types":"./dist/ui/index.d.ts","import":"./dist/ui/index.js"},"./types":{"types":"./dist/types.d.ts","import":"./dist/types.js"},"./testing":{"types":"./dist/testing.d.ts","import":"./dist/testing.js"},"./bundlers":{"types":"./dist/bundlers.d.ts","import":"./dist/bundlers.js"},"./protocol":{"types":"./dist/protocol.d.ts","import":"./dist/protocol.js"},"./ui/hooks":{"types":"./dist/ui/hooks.d.ts","import":"./dist/ui/hooks.js"},"./ui/types":{"types":"./dist/ui/types.d.ts","import":"./dist/ui/types.js"},"./dev-server":{"types":"./dist/dev-server.d.ts","import":"./dist/dev-server.js"}}},"_hasShrinkwrap":false,"devDependencies":{"typescript":"^5.7.3","@types/node":"^24.6.0","@types/react":"^19.0.8"},"peerDependencies":{"react":">=18"},"peerDependenciesMeta":{"react":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/plugin-sdk_1.0.0_1776095507669_0.8297822123214127","host":"s3://npm-registry-packages-npm-production"}},"1.0.1":{"name":"@agentcorporation/plugin-sdk","version":"1.0.1","description":"Stable public API for Company plugins — worker-side context and UI bridge hooks","license":"MIT","homepage":"https://github.com/AgenticCompany/company","bugs":{"url":"https://github.com/AgenticCompany/company/issues"},"repository":{"type":"git","url":"git+https://github.com/AgenticCompany/company.git","directory":"packages/plugins/sdk"},"type":"module","exports":{".":{"types":"./dist/index.d.ts","import":"./dist/index.js"},"./protocol":{"types":"./dist/protocol.d.ts","import":"./dist/protocol.js"},"./types":{"types":"./dist/types.d.ts","import":"./dist/types.js"},"./ui":{"types":"./dist/ui/index.d.ts","import":"./dist/ui/index.js"},"./ui/hooks":{"types":"./dist/ui/hooks.d.ts","import":"./dist/ui/hooks.js"},"./ui/types":{"types":"./dist/ui/types.d.ts","import":"./dist/ui/types.js"},"./testing":{"types":"./dist/testing.d.ts","import":"./dist/testing.js"},"./bundlers":{"types":"./dist/bundlers.d.ts","import":"./dist/bundlers.js"},"./dev-server":{"types":"./dist/dev-server.d.ts","import":"./dist/dev-server.js"}},"bin":{"company-plugin-dev-server":"dist/dev-cli.js"},"publishConfig":{"access":"public"},"dependencies":{"zod":"^3.24.2","@agentcorporation/shared":"0.3.2"},"devDependencies":{"@types/node":"^24.6.0","@types/react":"^19.0.8","typescript":"^5.7.3"},"peerDependencies":{"react":">=18"},"peerDependenciesMeta":{"react":{"optional":true}},"scripts":{"build":"pnpm --filter @agentcorporation/shared build && tsc","clean":"rm -rf dist","typecheck":"pnpm --filter @agentcorporation/shared build && tsc --noEmit","dev:server":"tsx src/dev-cli.ts"},"main":"./dist/index.js","types":"./dist/index.d.ts","_id":"@agentcorporation/plugin-sdk@1.0.1","_integrity":"sha512-oWi2P8+lgHnzPrW0qc+rThnqpuzhORZ6O2evDYBr++kJGFF0jYyq3J21OsQWm5mZ8borNP+BsgYgQN2i1Ynd1w==","_resolved":"/private/var/folders/m5/v30vrvps0lzccd5ps4dn8yqh0000gn/T/5953af61246569b22593f7042685e1dd/agentcorporation-plugin-sdk-1.0.1.tgz","_from":"file:agentcorporation-plugin-sdk-1.0.1.tgz","_nodeVersion":"22.19.0","_npmVersion":"10.9.3","dist":{"integrity":"sha512-oWi2P8+lgHnzPrW0qc+rThnqpuzhORZ6O2evDYBr++kJGFF0jYyq3J21OsQWm5mZ8borNP+BsgYgQN2i1Ynd1w==","shasum":"e9b2134abbda3b5b24b190fb433e821dee906a26","tarball":"https://registry.npmjs.org/@agentcorporation/plugin-sdk/-/plugin-sdk-1.0.1.tgz","fileCount":63,"unpackedSize":440099,"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEYCIQCz8M6k4EI/kiK84FMYUIrKmyPF6lHvaeMvYW2wcAC5xgIhAKTr1xn+bEJ/x4NEFpKANESeWkpZpOaJbi72JqxSSwsC"}]},"_npmUser":{"name":"agentcorporations","email":"melaniecooperwork@gmail.com"},"directories":{},"maintainers":[{"name":"agentcorporations","email":"melaniecooperwork@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/plugin-sdk_1.0.1_1776141522267_0.6540337443022208"},"_hasShrinkwrap":false}},"time":{"created":"2026-04-13T15:51:47.521Z","modified":"2026-04-14T04:38:42.534Z","1.0.0":"2026-04-13T15:51:47.835Z","1.0.1":"2026-04-14T04:38:42.406Z"},"bugs":{"url":"https://github.com/AgenticCompany/company/issues"},"license":"MIT","homepage":"https://github.com/AgenticCompany/company","repository":{"type":"git","url":"git+https://github.com/AgenticCompany/company.git","directory":"packages/plugins/sdk"},"description":"Stable public API for Company plugins — worker-side context and UI bridge hooks","maintainers":[{"name":"agentcorporations","email":"melaniecooperwork@gmail.com"}],"readme":"# `@agentcorporation/plugin-sdk`\n\nOfficial TypeScript SDK for Company plugin authors.\n\n- **Worker SDK:** `@agentcorporation/plugin-sdk` — `definePlugin`, context, lifecycle\n- **UI SDK:** `@agentcorporation/plugin-sdk/ui` — React hooks and slot props\n- **Testing:** `@agentcorporation/plugin-sdk/testing` — in-memory host harness\n- **Bundlers:** `@agentcorporation/plugin-sdk/bundlers` — esbuild/rollup presets\n- **Dev server:** `@agentcorporation/plugin-sdk/dev-server` — static UI server + SSE reload\n\nReference: `doc/plugins/PLUGIN_SPEC.md`\n\n## Package surface\n\n| Import | Purpose |\n|--------|--------|\n| `@agentcorporation/plugin-sdk` | Worker entry: `definePlugin`, `runWorker`, context types, protocol helpers |\n| `@agentcorporation/plugin-sdk/ui` | UI entry: `usePluginData`, `usePluginAction`, `usePluginStream`, `useHostContext`, slot prop types |\n| `@agentcorporation/plugin-sdk/ui/hooks` | Hooks only |\n| `@agentcorporation/plugin-sdk/ui/types` | UI types and slot prop interfaces |\n| `@agentcorporation/plugin-sdk/testing` | `createTestHarness` for unit/integration tests |\n| `@agentcorporation/plugin-sdk/bundlers` | `createPluginBundlerPresets` for worker/manifest/ui builds |\n| `@agentcorporation/plugin-sdk/dev-server` | `startPluginDevServer`, `getUiBuildSnapshot` |\n| `@agentcorporation/plugin-sdk/protocol` | JSON-RPC protocol types and helpers (advanced) |\n| `@agentcorporation/plugin-sdk/types` | Worker context and API types (advanced) |\n\n## Manifest entrypoints\n\nIn your plugin manifest you declare:\n\n- **`entrypoints.worker`** (required) — Path to the worker bundle (e.g. `dist/worker.js`). The host loads this and calls `setup(ctx)`.\n- **`entrypoints.ui`** (required if you use UI) — Path to the UI bundle directory. The host loads components from here for slots and launchers.\n\n## Install\n\n```bash\npnpm add @agentcorporation/plugin-sdk\n```\n\n## Current deployment caveats\n\nThe SDK is stable enough for local development and first-party examples, but the runtime deployment model is still early.\n\n- Plugin workers and plugin UI should both be treated as trusted code today.\n- Plugin UI bundles run as same-origin JavaScript inside the main Company app. They can call ordinary Company HTTP APIs with the board session, so manifest capabilities are not a frontend sandbox.\n- Local-path installs and the repo example plugins are development workflows. They assume the plugin source checkout exists on disk.\n- For deployed plugins, publish an npm package and install that package into the Company instance at runtime.\n- The current host runtime expects a writable filesystem, `npm` available at runtime, and network access to the package registry used for plugin installation.\n- Dynamic plugin install is currently best suited to single-node persistent deployments. Multi-instance cloud deployments still need a shared artifact/distribution model before runtime installs are reliable across nodes.\n- The host does not currently ship a real shared React component kit for plugins. Build your plugin UI with ordinary React components and CSS.\n- `ctx.assets` is not part of the supported runtime in this build. Do not depend on asset upload/read APIs yet.\n\nIf you are authoring a plugin for others to deploy, treat npm-packaged installation as the supported path and treat repo-local example installs as a development convenience.\n\n## Worker quick start\n\n```ts\nimport { definePlugin, runWorker } from \"@agentcorporation/plugin-sdk\";\n\nconst plugin = definePlugin({\n  async setup(ctx) {\n    ctx.events.on(\"issue.created\", async (event) => {\n      ctx.logger.info(\"Issue created\", { issueId: event.entityId });\n    });\n\n    ctx.data.register(\"health\", async () => ({ status: \"ok\" }));\n    ctx.actions.register(\"ping\", async () => ({ pong: true }));\n\n    ctx.tools.register(\"calculator\", {\n      displayName: \"Calculator\",\n      description: \"Basic math\",\n      parametersSchema: {\n        type: \"object\",\n        properties: { a: { type: \"number\" }, b: { type: \"number\" } },\n        required: [\"a\", \"b\"]\n      }\n    }, async (params) => {\n      const { a, b } = params as { a: number; b: number };\n      return { content: `Result: ${a + b}`, data: { result: a + b } };\n    });\n  },\n});\n\nexport default plugin;\nrunWorker(plugin, import.meta.url);\n```\n\n**Note:** `runWorker(plugin, import.meta.url)` must be called so that when the host runs your worker (e.g. `node dist/worker.js`), the RPC host starts and the process stays alive. When the file is imported (e.g. for tests), the main-module check prevents the host from starting.\n\n### Worker lifecycle and context\n\n**Lifecycle (definePlugin):**\n\n| Hook | Purpose |\n|------|--------|\n| `setup(ctx)` | **Required.** Called once at startup. Register event handlers, jobs, data/actions/tools, etc. |\n| `onHealth?()` | Optional. Return `{ status, message?, details? }` for health dashboard. |\n| `onConfigChanged?(newConfig)` | Optional. Apply new config without restart; if omitted, host restarts worker. |\n| `onShutdown?()` | Optional. Clean up before process exit (limited time window). |\n| `onValidateConfig?(config)` | Optional. Return `{ ok, warnings?, errors? }` for settings UI / Test Connection. |\n| `onWebhook?(input)` | Optional. Handle `POST /api/plugins/:pluginId/webhooks/:endpointKey`; required if webhooks declared. |\n\n**Context (`ctx`) in setup:** `config`, `events`, `jobs`, `launchers`, `http`, `secrets`, `activity`, `state`, `entities`, `projects`, `companies`, `issues`, `agents`, `goals`, `data`, `actions`, `streams`, `tools`, `metrics`, `logger`, `manifest`. Worker-side host APIs are capability-gated; declare capabilities in the manifest.\n\n**Agents:** `ctx.agents.invoke(agentId, companyId, opts)` for one-shot invocation. `ctx.agents.sessions` for two-way chat: `create`, `list`, `sendMessage` (with streaming `onEvent` callback), `close`. See the [Plugin Authoring Guide](../../doc/plugins/PLUGIN_AUTHORING_GUIDE.md#agent-sessions-two-way-chat) for details.\n\n**Jobs:** Declare in `manifest.jobs` with `jobKey`, `displayName`, `schedule` (cron). Register handler with `ctx.jobs.register(jobKey, fn)`. **Webhooks:** Declare in `manifest.webhooks` with `endpointKey`; handle in `onWebhook(input)`. **State:** `ctx.state.get/set/delete(scopeKey)`; scope kinds: `instance`, `company`, `project`, `project_workspace`, `agent`, `issue`, `goal`, `run`.\n\n## Events\n\nSubscribe in `setup` with `ctx.events.on(name, handler)` or `ctx.events.on(name, filter, handler)`. Emit plugin-scoped events with `ctx.events.emit(name, companyId, payload)` (requires `events.emit`).\n\n**Core domain events (subscribe with `events.subscribe`):**\n\n| Event | Typical entity |\n|-------|-----------------|\n| `company.created`, `company.updated` | company |\n| `project.created`, `project.updated` | project |\n| `project.workspace_created`, `project.workspace_updated`, `project.workspace_deleted` | project_workspace |\n| `issue.created`, `issue.updated`, `issue.comment.created` | issue |\n| `agent.created`, `agent.updated`, `agent.status_changed` | agent |\n| `agent.run.started`, `agent.run.finished`, `agent.run.failed`, `agent.run.cancelled` | run |\n| `goal.created`, `goal.updated` | goal |\n| `approval.created`, `approval.decided` | approval |\n| `cost_event.created` | cost |\n| `activity.logged` | activity |\n\n**Plugin-to-plugin:** Subscribe to `plugin.<pluginId>.<eventName>` (e.g. `plugin.acme.linear.sync-done`). Emit with `ctx.events.emit(\"sync-done\", companyId, payload)`; the host namespaces it automatically.\n\n**Filter (optional):** Pass a second argument to `on()`: `{ projectId?, companyId?, agentId? }` so the host only delivers matching events.\n\n**Company context:** Events still carry `companyId` for company-scoped data, but plugin installation and activation are instance-wide in the current runtime.\n\n## Scheduled (recurring) jobs\n\nPlugins can declare **scheduled jobs** that the host runs on a cron schedule. Use this for recurring tasks like syncs, digest reports, or cleanup.\n\n1. **Capability:** Add `jobs.schedule` to `manifest.capabilities`.\n2. **Declare jobs** in `manifest.jobs`: each entry has `jobKey`, `displayName`, optional `description`, and `schedule` (a 5-field cron expression).\n3. **Register a handler** in `setup()` with `ctx.jobs.register(jobKey, async (job) => { ... })`.\n\n**Cron format** (5 fields: minute, hour, day-of-month, month, day-of-week):\n\n| Field        | Values   | Example |\n|-------------|----------|---------|\n| minute      | 0–59     | `0`, `*/15` |\n| hour        | 0–23     | `2`, `*` |\n| day of month | 1–31   | `1`, `*` |\n| month       | 1–12     | `*` |\n| day of week | 0–6 (Sun=0) | `*`, `1-5` |\n\nExamples: `\"0 * * * *\"` = every hour at minute 0; `\"*/5 * * * *\"` = every 5 minutes; `\"0 2 * * *\"` = daily at 2:00.\n\n**Job handler context** (`PluginJobContext`):\n\n| Field        | Type     | Description |\n|-------------|----------|-------------|\n| `jobKey`    | string   | Matches the manifest declaration. |\n| `runId`     | string   | UUID for this run. |\n| `trigger`   | `\"schedule\" \\| \"manual\" \\| \"retry\"` | What caused this run. |\n| `scheduledAt` | string | ISO 8601 time when the run was scheduled. |\n\nRuns can be triggered by the **schedule**, **manually** from the UI/API, or as a **retry** (when an operator re-runs a job after a failure). Re-throw from the handler to mark the run as failed; the host records the failure. The host does not automatically retry—operators can trigger another run manually from the UI or API.\n\nExample:\n\n**Manifest** — include `jobs.schedule` and declare the job:\n\n```ts\n// In your manifest (e.g. manifest.ts):\nconst manifest = {\n  // ...\n  capabilities: [\"jobs.schedule\", \"plugin.state.write\"],\n  jobs: [\n    {\n      jobKey: \"heartbeat\",\n      displayName: \"Heartbeat\",\n      description: \"Runs every 5 minutes\",\n      schedule: \"*/5 * * * *\",\n    },\n  ],\n  // ...\n};\n```\n\n**Worker** — register the handler in `setup()`:\n\n```ts\nctx.jobs.register(\"heartbeat\", async (job) => {\n  ctx.logger.info(\"Heartbeat run\", { runId: job.runId, trigger: job.trigger });\n  await ctx.state.set({ scopeKind: \"instance\", stateKey: \"last-heartbeat\" }, new Date().toISOString());\n});\n```\n\n## UI slots and launchers\n\nSlots are mount points for plugin React components. Launchers are host-rendered entry points (buttons, menu items) that open plugin UI. Declare slots in `manifest.ui.slots` with `type`, `id`, `displayName`, `exportName`; for context-sensitive slots add `entityTypes`. Declare launchers in `manifest.ui.launchers` (or legacy `manifest.launchers`).\n\n### Slot types / launcher placement zones\n\nThe same set of values is used as **slot types** (where a component mounts) and **launcher placement zones** (where a launcher can appear). Hierarchy:\n\n| Slot type / placement zone | Scope | Entity types (when context-sensitive) |\n|----------------------------|-------|---------------------------------------|\n| `page` | Global | — |\n| `sidebar` | Global | — |\n| `sidebarPanel` | Global | — |\n| `settingsPage` | Global | — |\n| `dashboardWidget` | Global | — |\n| `globalToolbarButton` | Global | — |\n| `detailTab` | Entity | `project`, `issue`, `agent`, `goal`, `run` |\n| `taskDetailView` | Entity | (task/issue context) |\n| `commentAnnotation` | Entity | `comment` |\n| `commentContextMenuItem` | Entity | `comment` |\n| `projectSidebarItem` | Entity | `project` |\n| `toolbarButton` | Entity | varies by host surface |\n| `contextMenuItem` | Entity | varies by host surface |\n\n**Scope** describes whether the slot requires an entity to render. **Global** slots render without a specific entity but still receive the active `companyId` through `PluginHostContext` — use it to scope data fetches to the current company. **Entity** slots additionally require `entityId` and `entityType` (e.g. a detail tab on a specific issue).\n\n**Entity types** (for `entityTypes` on slots): `project` \\| `issue` \\| `agent` \\| `goal` \\| `run` \\| `comment`. Full list: import `PLUGIN_UI_SLOT_TYPES` and `PLUGIN_UI_SLOT_ENTITY_TYPES` from `@agentcorporation/plugin-sdk`.\n\n### Slot component descriptions\n\n#### `page`\n\nA full-page extension mounted at `/plugins/:pluginId` (global) or `/:company/plugins/:pluginId` (company-context route). Use this for rich, standalone plugin experiences such as dashboards, configuration wizards, or multi-step workflows. Receives `PluginPageProps` with `context.companyId` set to the active company. Requires the `ui.page.register` capability.\n\n#### `sidebar`\n\nAdds a navigation-style entry to the main company sidebar navigation area, rendered alongside the core nav items (Dashboard, Issues, Goals, etc.). Use this for lightweight, always-visible links or status indicators that feel native to the sidebar. Receives `PluginSidebarProps` with `context.companyId` set to the active company. Requires the `ui.sidebar.register` capability.\n\n#### `sidebarPanel`\n\nRenders richer inline content in a dedicated panel area below the company sidebar navigation sections. Use this for mini-widgets, summary cards, quick-action panels, or at-a-glance status views that need more vertical space than a nav link. Receives `context.companyId` set to the active company via `useHostContext()`. Requires the `ui.sidebar.register` capability.\n\n#### `settingsPage`\n\nReplaces the auto-generated JSON Schema settings form with a custom React component. Use this when the default form is insufficient — for example, when your plugin needs multi-step configuration, OAuth flows, \"Test Connection\" buttons, or rich input controls. Receives `PluginSettingsPageProps` with `context.companyId` set to the active company. The component is responsible for reading and writing config through the bridge (via `usePluginData` and `usePluginAction`).\n\n#### `dashboardWidget`\n\nA card or section rendered on the main dashboard. Use this for at-a-glance metrics, status indicators, or summary views that surface plugin data alongside core Company information. Receives `PluginWidgetProps` with `context.companyId` set to the active company. Requires the `ui.dashboardWidget.register` capability.\n\n#### `detailTab`\n\nAn additional tab on a project, issue, agent, goal, or run detail page. Rendered when the user navigates to that entity's detail view. Receives `PluginDetailTabProps` with `context.companyId` set to the active company and `context.entityId` / `context.entityType` guaranteed to be non-null, so you can immediately scope data fetches to the relevant entity. Specify which entity types the tab applies to via the `entityTypes` array in the manifest slot declaration. Requires the `ui.detailTab.register` capability.\n\n#### `taskDetailView`\n\nA specialized slot rendered in the context of a task or issue detail view. Similar to `detailTab` but designed for inline content within the task detail layout rather than a separate tab. Receives `context.companyId`, `context.entityId`, and `context.entityType` like `detailTab`. Requires the `ui.detailTab.register` capability.\n\n#### `projectSidebarItem`\n\nA link or small component rendered **once per project** under that project's row in the sidebar Projects list. Use this to add project-scoped navigation entries (e.g. \"Files\", \"Linear Sync\") that deep-link into a plugin detail tab: `/:company/projects/:projectRef?tab=plugin:<key>:<slotId>`. Receives `PluginProjectSidebarItemProps` with `context.companyId` set to the active company, `context.entityId` set to the project id, and `context.entityType` set to `\"project\"`. Use the optional `order` field in the manifest slot to control sort position. Requires the `ui.sidebar.register` capability.\n\n#### `globalToolbarButton`\n\nA button rendered in the global top bar (breadcrumb bar) that appears on every page. Use this for company-wide actions that are not scoped to a specific entity — for example, a universal search trigger, a global sync status indicator, or a floating action that applies across the whole workspace. Receives only `context.companyId` and `context.companyPrefix`; no entity context is available. Requires the `ui.action.register` capability.\n\n#### `toolbarButton`\n\nA button rendered in the toolbar of an entity page (e.g. project detail, issue detail). Use this for short-lived, contextual actions scoped to the current entity — like triggering a project sync, opening a picker, or running a quick command on that entity. The component can open a plugin-owned modal internally for confirmations or compact forms. Receives `context.companyId`, `context.entityId`, and `context.entityType`; declare `entityTypes` in the manifest to control which entity pages the button appears on. Requires the `ui.action.register` capability.\n\n#### `contextMenuItem`\n\nAn entry added to a right-click or overflow context menu on a host surface. Use this for secondary actions that apply to the entity under the cursor (e.g. \"Copy to Linear\", \"Re-run analysis\"). Receives `context.companyId` set to the active company; entity context varies by host surface. Requires the `ui.action.register` capability.\n\n#### `commentAnnotation`\n\nA per-comment annotation region rendered below each individual comment in the issue detail timeline. Use this to augment comments with parsed file links, sentiment badges, inline actions, or any per-comment metadata. Receives `PluginCommentAnnotationProps` with `context.entityId` set to the comment UUID, `context.entityType` set to `\"comment\"`, `context.parentEntityId` set to the parent issue UUID, `context.projectId` set to the issue's project (if any), and `context.companyPrefix` set to the active company slug. Requires the `ui.commentAnnotation.register` capability.\n\n#### `commentContextMenuItem`\n\nA per-comment context menu item rendered in the \"more\" dropdown menu (⋮) on each comment in the issue detail timeline. Use this to add per-comment actions such as \"Create sub-issue from comment\", \"Translate\", \"Flag for review\", or custom plugin actions. Receives `PluginCommentContextMenuItemProps` with `context.entityId` set to the comment UUID, `context.entityType` set to `\"comment\"`, `context.parentEntityId` set to the parent issue UUID, `context.projectId` set to the issue's project (if any), and `context.companyPrefix` set to the active company slug. Plugins can open drawers, modals, or popovers scoped to that comment. The ⋮ menu button only appears on comments where at least one plugin renders visible content. Requires the `ui.action.register` capability.\n\n### Launcher actions and render options\n\n| Launcher action | Description |\n|-----------------|-------------|\n| `navigate` | Navigate to a route (plugin or host). |\n| `openModal` | Open a modal. |\n| `openDrawer` | Open a drawer. |\n| `openPopover` | Open a popover. |\n| `performAction` | Run an action (e.g. call plugin). |\n| `deepLink` | Deep link to plugin or external URL. |\n\n| Render option | Values | Description |\n|---------------|--------|-------------|\n| `environment` | `hostInline`, `hostOverlay`, `hostRoute`, `external`, `iframe` | Container the launcher expects after activation. |\n| `bounds` | `inline`, `compact`, `default`, `wide`, `full` | Size hint for overlays/drawers. |\n\n### Capabilities\n\nDeclare in `manifest.capabilities`. Grouped by scope:\n\n| Scope | Capability |\n|-------|------------|\n| **Company** | `companies.read` |\n| | `projects.read` |\n| | `project.workspaces.read` |\n| | `issues.read` |\n| | `issue.comments.read` |\n| | `agents.read` |\n| | `goals.read` |\n| | `goals.create` |\n| | `goals.update` |\n| | `activity.read` |\n| | `costs.read` |\n| | `issues.create` |\n| | `issues.update` |\n| | `issue.comments.create` |\n| | `activity.log.write` |\n| | `metrics.write` |\n| | `telemetry.track` |\n| **Instance** | `instance.settings.register` |\n| | `plugin.state.read` |\n| | `plugin.state.write` |\n| **Runtime** | `events.subscribe` |\n| | `events.emit` |\n| | `jobs.schedule` |\n| | `webhooks.receive` |\n| | `http.outbound` |\n| | `secrets.read-ref` |\n| **Agent** | `agent.tools.register` |\n| | `agents.invoke` |\n| | `agent.sessions.create` |\n| | `agent.sessions.list` |\n| | `agent.sessions.send` |\n| | `agent.sessions.close` |\n| **UI** | `ui.sidebar.register` |\n| | `ui.page.register` |\n| | `ui.detailTab.register` |\n| | `ui.dashboardWidget.register` |\n| | `ui.commentAnnotation.register` |\n| | `ui.action.register` |\n\nFull list in code: import `PLUGIN_CAPABILITIES` from `@agentcorporation/plugin-sdk`.\n\n## UI quick start\n\n```tsx\nimport { usePluginData, usePluginAction } from \"@agentcorporation/plugin-sdk/ui\";\n\nexport function DashboardWidget() {\n  const { data } = usePluginData<{ status: string }>(\"health\");\n  const ping = usePluginAction(\"ping\");\n  return (\n    <div style={{ display: \"grid\", gap: 8 }}>\n      <strong>Health</strong>\n      <div>{data?.status ?? \"unknown\"}</div>\n      <button onClick={() => void ping()}>Ping</button>\n    </div>\n  );\n}\n```\n\n### Hooks reference\n\n#### `usePluginData<T>(key, params?)`\n\nFetches data from the worker's registered `getData` handler. Re-fetches when `params` changes. Returns `{ data, loading, error, refresh }`.\n\n```tsx\nimport { usePluginData } from \"@agentcorporation/plugin-sdk/ui\";\n\ninterface SyncStatus {\n  lastSyncAt: string;\n  syncedCount: number;\n  healthy: boolean;\n}\n\nexport function SyncStatusWidget({ context }: PluginWidgetProps) {\n  const { data, loading, error, refresh } = usePluginData<SyncStatus>(\"sync-status\", {\n    companyId: context.companyId,\n  });\n\n  if (loading) return <div>Loading…</div>;\n  if (error) return <div>Error: {error.message}</div>;\n\n  return (\n    <div>\n      <p>Status: {data!.healthy ? \"Healthy\" : \"Unhealthy\"}</p>\n      <p>Synced {data!.syncedCount} items</p>\n      <p>Last sync: {data!.lastSyncAt}</p>\n      <button onClick={refresh}>Refresh</button>\n    </div>\n  );\n}\n```\n\n#### `usePluginAction(key)`\n\nReturns an async function that calls the worker's `performAction` handler. Throws `PluginBridgeError` on failure.\n\n```tsx\nimport { useState } from \"react\";\nimport { usePluginAction, type PluginBridgeError } from \"@agentcorporation/plugin-sdk/ui\";\n\nexport function ResyncButton({ context }: PluginWidgetProps) {\n  const resync = usePluginAction(\"resync\");\n  const [busy, setBusy] = useState(false);\n  const [error, setError] = useState<string | null>(null);\n\n  async function handleClick() {\n    setBusy(true);\n    setError(null);\n    try {\n      await resync({ companyId: context.companyId });\n    } catch (err) {\n      setError((err as PluginBridgeError).message);\n    } finally {\n      setBusy(false);\n    }\n  }\n\n  return (\n    <div>\n      <button onClick={handleClick} disabled={busy}>\n        {busy ? \"Syncing...\" : \"Resync Now\"}\n      </button>\n      {error && <p style={{ color: \"red\" }}>{error}</p>}\n    </div>\n  );\n}\n```\n\n#### `useHostContext()`\n\nReads the active company, project, entity, and user context. Use this to scope data fetches and actions.\n\n```tsx\nimport { useHostContext, usePluginData } from \"@agentcorporation/plugin-sdk/ui\";\nimport type { PluginDetailTabProps } from \"@agentcorporation/plugin-sdk/ui\";\n\nexport function IssueLinearLink({ context }: PluginDetailTabProps) {\n  const { companyId, entityId, entityType } = context;\n  const { data } = usePluginData<{ url: string }>(\"linear-link\", {\n    companyId,\n    issueId: entityId,\n  });\n\n  if (!data?.url) return <p>No linked Linear issue.</p>;\n  return <a href={data.url} target=\"_blank\" rel=\"noopener\">View in Linear</a>;\n}\n```\n\n#### `usePluginStream<T>(channel, options?)`\n\nSubscribes to a real-time event stream pushed from the plugin worker via SSE. The worker pushes events using `ctx.streams.emit(channel, event)` and the hook receives them as they arrive. Returns `{ events, lastEvent, connecting, connected, error, close }`.\n\n```tsx\nimport { usePluginStream } from \"@agentcorporation/plugin-sdk/ui\";\n\ninterface ChatToken {\n  text: string;\n}\n\nexport function ChatMessages({ context }: PluginWidgetProps) {\n  const { events, connected, close } = usePluginStream<ChatToken>(\"chat-stream\", {\n    companyId: context.companyId ?? undefined,\n  });\n\n  return (\n    <div>\n      {events.map((e, i) => <span key={i}>{e.text}</span>)}\n      {connected && <span className=\"pulse\" />}\n      <button onClick={close}>Stop</button>\n    </div>\n  );\n}\n```\n\nThe SSE connection targets `GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`. The host bridge manages the EventSource lifecycle; `close()` terminates the connection.\n\n### UI authoring note\n\nThe current host does **not** provide a real shared component library to plugins yet. Use normal React components, your own CSS, or your own small design primitives inside the plugin package.\n\n### Slot component props\n\nEach slot type receives a typed props object with `context: PluginHostContext`. Import from `@agentcorporation/plugin-sdk/ui`.\n\n| Slot type | Props interface | `context` extras |\n|-----------|----------------|------------------|\n| `page` | `PluginPageProps` | — |\n| `sidebar` | `PluginSidebarProps` | — |\n| `settingsPage` | `PluginSettingsPageProps` | — |\n| `dashboardWidget` | `PluginWidgetProps` | — |\n| `globalToolbarButton` | `PluginGlobalToolbarButtonProps` | — |\n| `detailTab` | `PluginDetailTabProps` | `entityId: string`, `entityType: string` |\n| `toolbarButton` | `PluginToolbarButtonProps` | `entityId: string`, `entityType: string` |\n| `commentAnnotation` | `PluginCommentAnnotationProps` | `entityId: string`, `entityType: \"comment\"`, `parentEntityId: string`, `projectId`, `companyPrefix` |\n| `commentContextMenuItem` | `PluginCommentContextMenuItemProps` | `entityId: string`, `entityType: \"comment\"`, `parentEntityId: string`, `projectId`, `companyPrefix` |\n| `projectSidebarItem` | `PluginProjectSidebarItemProps` | `entityId: string`, `entityType: \"project\"` |\n\nExample detail tab with entity context:\n\n```tsx\nimport type { PluginDetailTabProps } from \"@agentcorporation/plugin-sdk/ui\";\nimport { usePluginData } from \"@agentcorporation/plugin-sdk/ui\";\n\nexport function AgentMetricsTab({ context }: PluginDetailTabProps) {\n  const { data, loading } = usePluginData<Record<string, string>>(\"agent-metrics\", {\n    agentId: context.entityId,\n    companyId: context.companyId,\n  });\n\n  if (loading) return <div>Loading…</div>;\n  if (!data) return <p>No metrics available.</p>;\n\n  return (\n    <dl>\n      {Object.entries(data).map(([label, value]) => (\n        <div key={label}>\n          <dt>{label}</dt>\n          <dd>{value}</dd>\n        </div>\n      ))}\n    </dl>\n  );\n}\n```\n\n## Launcher surfaces and modals\n\nV1 does not provide a dedicated `modal` slot. Plugins can either:\n\n- declare concrete UI mount points in `ui.slots`\n- declare host-rendered entry points in `ui.launchers`\n\nSupported launcher placement zones currently mirror the major host surfaces such as `projectSidebarItem`, `globalToolbarButton`, `toolbarButton`, `detailTab`, `settingsPage`, and `contextMenuItem`. Plugins may still open their own local modal from those entry points when needed.\n\nDeclarative launcher example:\n\n```json\n{\n  \"ui\": {\n    \"launchers\": [\n      {\n        \"id\": \"sync-project\",\n        \"displayName\": \"Sync\",\n        \"placementZone\": \"toolbarButton\",\n        \"entityTypes\": [\"project\"],\n        \"action\": {\n          \"type\": \"openDrawer\",\n          \"target\": \"sync-project\"\n        },\n        \"render\": {\n          \"environment\": \"hostOverlay\",\n          \"bounds\": \"wide\"\n        }\n      }\n    ]\n  }\n}\n```\n\nThe host returns launcher metadata from `GET /api/plugins/ui-contributions` alongside slot declarations.\n\nWhen a launcher opens a host-owned overlay or page, `useHostContext()`,\n`usePluginData()`, and `usePluginAction()` receive the current\n`renderEnvironment` through the bridge. Use that to tailor compact modal UI vs.\nfull-page layouts without adding custom route parsing in the plugin.\n\n## Project sidebar item\n\nPlugins can add a link under each project in the sidebar via the `projectSidebarItem` slot. This is the recommended slot-based launcher pattern for project-scoped workflows because it can deep-link into a richer plugin tab. The component is rendered once per project with that project’s id in `context.entityId`. Declare the slot and capability in your manifest:\n\n```json\n{\n  \"ui\": {\n    \"slots\": [\n      {\n        \"type\": \"projectSidebarItem\",\n        \"id\": \"files\",\n        \"displayName\": \"Files\",\n        \"exportName\": \"FilesLink\",\n        \"entityTypes\": [\"project\"]\n      }\n    ]\n  },\n  \"capabilities\": [\"ui.sidebar.register\", \"ui.detailTab.register\"]\n}\n```\n\nMinimal React component that links to the project’s plugin tab (see project detail tabs in the spec):\n\n```tsx\nimport type { PluginProjectSidebarItemProps } from \"@agentcorporation/plugin-sdk/ui\";\n\nexport function FilesLink({ context }: PluginProjectSidebarItemProps) {\n  const projectId = context.entityId;\n  const prefix = context.companyPrefix ? `/${context.companyPrefix}` : \"\";\n  const projectRef = projectId; // or resolve from host; entityId is project id\n  return (\n    <a href={`${prefix}/projects/${projectRef}?tab=plugin:your-plugin:files`}>\n      Files\n    </a>\n  );\n}\n```\n\nUse optional `order` in the slot to sort among other project sidebar items. See §19.5.1 in the plugin spec and project detail plugin tabs (§19.3) for the full flow.\n\n## Toolbar launcher with a local modal\n\nTwo toolbar slot types are available depending on where the button should appear:\n\n- **`globalToolbarButton`** — renders in the top bar on every page, scoped to the company. No entity context. Use for workspace-wide actions.\n- **`toolbarButton`** — renders on entity detail pages (project, issue, etc.). Receives `entityId` and `entityType`. Declare `entityTypes` to control which pages the button appears on.\n\nFor short-lived actions, mount the appropriate slot type and open a plugin-owned modal inside the component. Use `useHostContext()` to scope the action to the current company or entity.\n\nProject-scoped example (appears only on project detail pages):\n\n```json\n{\n  \"ui\": {\n    \"slots\": [\n      {\n        \"type\": \"toolbarButton\",\n        \"id\": \"sync-toolbar-button\",\n        \"displayName\": \"Sync\",\n        \"exportName\": \"SyncToolbarButton\",\n        \"entityTypes\": [\"project\"]\n      }\n    ]\n  },\n  \"capabilities\": [\"ui.action.register\"]\n}\n```\n\n```tsx\nimport { useState } from \"react\";\nimport {\n  useHostContext,\n  usePluginAction,\n} from \"@agentcorporation/plugin-sdk/ui\";\n\nexport function SyncToolbarButton() {\n  const context = useHostContext();\n  const syncProject = usePluginAction(\"sync-project\");\n  const [open, setOpen] = useState(false);\n  const [submitting, setSubmitting] = useState(false);\n  const [errorMessage, setErrorMessage] = useState<string | null>(null);\n\n  async function confirm() {\n    if (!context.projectId) return;\n    setSubmitting(true);\n    setErrorMessage(null);\n    try {\n      await syncProject({ projectId: context.projectId });\n      setOpen(false);\n    } catch (err) {\n      setErrorMessage(err instanceof Error ? err.message : \"Sync failed\");\n    } finally {\n      setSubmitting(false);\n    }\n  }\n\n  return (\n    <>\n      <button type=\"button\" onClick={() => setOpen(true)}>\n        Sync\n      </button>\n      {open ? (\n        <div\n          role=\"dialog\"\n          aria-modal=\"true\"\n          className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4\"\n          onClick={() => !submitting && setOpen(false)}\n        >\n          <div\n            className=\"w-full max-w-md rounded-lg bg-background p-4 shadow-xl\"\n            onClick={(event) => event.stopPropagation()}\n          >\n            <h2 className=\"text-base font-semibold\">Sync this project?</h2>\n            <p className=\"mt-2 text-sm text-muted-foreground\">\n              Queue a sync for <code>{context.projectId}</code>.\n            </p>\n            {errorMessage ? (\n              <p className=\"mt-2 text-sm text-destructive\">{errorMessage}</p>\n            ) : null}\n            <div className=\"mt-4 flex justify-end gap-2\">\n              <button type=\"button\" onClick={() => setOpen(false)}>\n                Cancel\n              </button>\n              <button type=\"button\" onClick={() => void confirm()} disabled={submitting}>\n                {submitting ? \"Running…\" : \"Run sync\"}\n              </button>\n            </div>\n          </div>\n        </div>\n      ) : null}\n    </>\n  );\n}\n```\n\nPrefer deep-linkable tabs and pages for primary workflows. Reserve plugin-owned modals for confirmations, pickers, and compact editors.\n\n## Real-time streaming (`ctx.streams`)\n\nPlugins can push real-time events from the worker to the UI using server-sent events (SSE). This is useful for streaming LLM tokens, live sync progress, or any push-based data.\n\n### Worker side\n\nIn `setup()`, use `ctx.streams` to open a channel, emit events, and close when done:\n\n```ts\nconst plugin = definePlugin({\n  async setup(ctx) {\n    ctx.actions.register(\"chat\", async (params) => {\n      const companyId = params.companyId as string;\n      ctx.streams.open(\"chat-stream\", companyId);\n\n      for await (const token of streamFromLLM(params.prompt as string)) {\n        ctx.streams.emit(\"chat-stream\", { text: token });\n      }\n\n      ctx.streams.close(\"chat-stream\");\n      return { ok: true };\n    });\n  },\n});\n```\n\n**API:**\n\n| Method | Description |\n|--------|-------------|\n| `ctx.streams.open(channel, companyId)` | Open a named stream channel and associate it with a company. Sends a `streams.open` notification to the host. |\n| `ctx.streams.emit(channel, event)` | Push an event to the channel. The `companyId` is automatically resolved from the prior `open()` call. |\n| `ctx.streams.close(channel)` | Close the channel and clear the company mapping. Sends a `streams.close` notification. |\n\nStream notifications are fire-and-forget JSON-RPC messages (no `id` field). They are sent via `notifyHost()` synchronously during handler execution.\n\n### UI side\n\nUse the `usePluginStream` hook (see [Hooks reference](#usepluginstreamtchannel-options) above) to subscribe to events from the UI.\n\n### Host-side architecture\n\nThe host maintains an in-memory `PluginStreamBus` that fans out worker notifications to connected SSE clients:\n\n1. Worker emits `streams.emit` notification via stdout\n2. Host (`plugin-worker-manager`) receives the notification and publishes to `PluginStreamBus`\n3. SSE endpoint (`GET /api/plugins/:pluginId/bridge/stream/:channel?companyId=...`) subscribes to the bus and writes events to the response\n\nThe bus is keyed by `pluginId:channel:companyId`, so multiple UI clients can subscribe to the same stream independently.\n\n### Streaming agent responses to the UI\n\n`ctx.streams` and `ctx.agents.sessions` are complementary. The worker sits between them, relaying agent events to the browser in real time:\n\n```\nUI ──usePluginAction──▶ Worker ──sessions.sendMessage──▶ Agent\nUI ◀──usePluginStream── Worker ◀──onEvent callback────── Agent\n```\n\nThe agent doesn't know about streams — the worker decides what to relay. Encode the agent ID in the channel name to scope streams per agent.\n\n**Worker:**\n\n```ts\nctx.actions.register(\"ask-agent\", async (params) => {\n  const { agentId, companyId, prompt } = params as {\n    agentId: string; companyId: string; prompt: string;\n  };\n\n  const channel = `agent:${agentId}`;\n  ctx.streams.open(channel, companyId);\n\n  const session = await ctx.agents.sessions.create(agentId, companyId);\n\n  await ctx.agents.sessions.sendMessage(session.sessionId, companyId, {\n    prompt,\n    onEvent: (event) => {\n      ctx.streams.emit(channel, {\n        type: event.eventType,       // \"chunk\" | \"done\" | \"error\"\n        text: event.message ?? \"\",\n      });\n    },\n  });\n\n  ctx.streams.close(channel);\n  return { sessionId: session.sessionId };\n});\n```\n\n**UI:**\n\n```tsx\nimport { useState } from \"react\";\nimport { usePluginAction, usePluginStream } from \"@agentcorporation/plugin-sdk/ui\";\n\ninterface AgentEvent {\n  type: \"chunk\" | \"done\" | \"error\";\n  text: string;\n}\n\nexport function AgentChat({ agentId, companyId }: { agentId: string; companyId: string }) {\n  const askAgent = usePluginAction(\"ask-agent\");\n  const { events, connected, close } = usePluginStream<AgentEvent>(`agent:${agentId}`, { companyId });\n  const [prompt, setPrompt] = useState(\"\");\n\n  async function send() {\n    setPrompt(\"\");\n    await askAgent({ agentId, companyId, prompt });\n  }\n\n  return (\n    <div>\n      <div>{events.filter(e => e.type === \"chunk\").map((e, i) => <span key={i}>{e.text}</span>)}</div>\n      <input value={prompt} onChange={(e) => setPrompt(e.target.value)} />\n      <button onClick={send}>Send</button>\n      {connected && <button onClick={close}>Stop</button>}\n    </div>\n  );\n}\n```\n\n## Agent sessions (two-way chat)\n\nPlugins can hold multi-turn conversational sessions with agents:\n\n```ts\n// Create a session\nconst session = await ctx.agents.sessions.create(agentId, companyId);\n\n// Send a message and stream the response\nawait ctx.agents.sessions.sendMessage(session.sessionId, companyId, {\n  prompt: \"Help me triage this issue\",\n  onEvent: (event) => {\n    if (event.eventType === \"chunk\") console.log(event.message);\n    if (event.eventType === \"done\") console.log(\"Stream complete\");\n  },\n});\n\n// List active sessions\nconst sessions = await ctx.agents.sessions.list(agentId, companyId);\n\n// Close when done\nawait ctx.agents.sessions.close(session.sessionId, companyId);\n```\n\nRequires capabilities: `agent.sessions.create`, `agent.sessions.list`, `agent.sessions.send`, `agent.sessions.close`.\n\nExported types: `AgentSession`, `AgentSessionEvent`, `AgentSessionSendResult`, `PluginAgentSessionsClient`.\n\n## Testing utilities\n\n```ts\nimport { createTestHarness } from \"@agentcorporation/plugin-sdk/testing\";\nimport plugin from \"../src/worker.js\";\nimport manifest from \"../src/manifest.js\";\n\nconst harness = createTestHarness({ manifest });\nawait plugin.definition.setup(harness.ctx);\nawait harness.emit(\"issue.created\", { issueId: \"iss_1\" }, { entityId: \"iss_1\", entityType: \"issue\" });\n```\n\n## Bundler presets\n\n```ts\nimport { createPluginBundlerPresets } from \"@agentcorporation/plugin-sdk/bundlers\";\n\nconst presets = createPluginBundlerPresets({ uiEntry: \"src/ui/index.tsx\" });\n// presets.esbuild.worker / presets.esbuild.manifest / presets.esbuild.ui\n// presets.rollup.worker / presets.rollup.manifest / presets.rollup.ui\n```\n\n## Local dev server (hot-reload events)\n\n```bash\ncompany-plugin-dev-server --root . --ui-dir dist/ui --port 4177\n```\n\nOr programmatically:\n\n```ts\nimport { startPluginDevServer } from \"@agentcorporation/plugin-sdk/dev-server\";\nconst server = await startPluginDevServer({ rootDir: process.cwd() });\n```\n\nDev server endpoints:\n- `GET /__company__/health` returns `{ ok, rootDir, uiDir }`\n- `GET /__company__/events` streams `reload` SSE events on UI build changes\n","readmeFilename":"README.md"}