* feat(plugins): add capability-gated Host reader for conversation content Plugins can now read historical user/agent message content over the Host data API, gated by a new `api_read:messages` capability (ADR 0047). This unblocks "summarize yesterday"-style plugins that the live `message.added` bus event alone cannot serve. - proto: `ListMessages` RPC + `Message`/`MessageFilter` on `service Host`. - SDK: `Host.Messages() MessageReader`, Go-native `Message`/`MessageFilter`. - host: `api_read:messages` accessor + denied reader; content sanitized via `sysprompt.StripSystemContent` (no raw system prompts); RFC3339 `since`/ `until` → `InvalidArgument` on bad input. - data: new `ListMessagesForPlugin` filtered query (session/task ids, type, created_at range) through the task service, never the DB file. - tests: in-process + wire capability gating, content stripping, filter/time passthrough, pagination, and the SQLite query. - docs: ADR 0047, spec, plugins-manifest, plugins-authoring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(plugins): add Host.InvokeUtilityAgent for settings-selectable utility agent Plugins can now delegate a one-shot LLM step to an operator-configured agent, gated by a new `agent_invoke` capability (ADR 0048), so a plugin needs no provider API key of its own. - setting: new `utility_agent_profile_id` user setting (Settings > System > Utility Agent), a profile dropdown cloned from the existing utility-agent path; selectable in the UI. - capability: boolean `agent_invoke`, enforced like `state`/`secrets`. - proto/SDK: `InvokeUtilityAgent(prompt) -> text` RPC and `Host.InvokeUtilityAgent(ctx, prompt)`. - backend: reuses the sessionless host-utility inference tier (ADR 0002) via a thin adapter — no agent loop, no task/session/workspace; resolves the configured profile to (agent type, model). Unconfigured / deleted profile -> gRPC FailedPrecondition. - tests: capability gating, completion path (asserts the configured profile is used and text returned), no-utility-agent and stale-profile error paths, SDK/wire round-trips, and the settings apply/trim behavior. - docs: ADR 0048, spec, plugins-manifest, plugins-authoring. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): resolve utility-agent deps live and bound message filter size Address CI + review feedback on the host plugin capabilities: - docs: register the new /settings/system/utility-agent route in docs/public/coverage.json (fixes the "Validate public docs" CI failure). - utility agent (ADR 0048): hostUtilityMgr is wired late in boot — after StartActivePlugins has already spawned boot-active plugins — so snapshotting the utility deps in hostForPlugin stranded those hosts with nil deps and made InvokeUtilityAgent return Unimplemented for their lifetime. Read the deps live from the Service (mutex-guarded) at call time instead; add a regression test for late wiring reaching an already-spawned host. - messages (ADR 0047): cap the combined session/task/type filter values at 400 so an oversized filter fails fast with InvalidArgument instead of hitting SQLite's host-parameter limit at query execution; add a test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5.7 KiB
0047 — Plugins read conversation content via a capability-gated Host RPC
- Status: accepted
- Date: 2026-07-21
- Area: backend, protocol
- Related: 0043 — Plugin host data API (the pattern this extends), docs/specs/plugins/spec.md ("Host data API")
Context
The Host data API (ADR 0043) lets a plugin read kandev's domain data — tasks,
sessions, workspaces, workflows, agent profiles, repositories — over a
capability-gated Host gRPC channel. It deliberately stopped short of message
content: Sessions() exposes session metadata (identity, agent, timestamps,
acp_session_id, code stats) but no transcript.
A downstream "My Daily Standup" plugin needs to summarize yesterday's user/agent conversations and surface issues worth mentioning. Today a plugin cannot read historical message content at all:
- The only place message content is exposed is the
message.addedbus event (internal/task/service/service_events.gopublishMessageEvent). Live event capture is a poor fit for "summarize yesterday" — a plugin would have to be running and recording continuously, and gets nothing for the window before it was installed. - The message repository has no task-scoped or time-range query.
ListMessagesis session-scoped only; nothing filters by task id or acreated_atwindow.
Reusing the ADR 0043 escape hatch (open the SQLite file directly) is the exact failure ADR 0043 exists to close: schema coupling, breaks on Postgres, no scoping, only possible because the subprocess is unsandboxed.
Decision
Add one read RPC to the existing service Host, following ADR 0043 exactly.
-
New RPC + DTO.
ListMessages(ListMessagesRequest) returns (ListMessagesResponse)onservice Hostinapps/backend/proto/kandev/plugin/v1/plugin.proto.MessageDTO fields:id,session_id,task_id,turn_id,author_type,content,type,created_at(RFC3339).MessageFilter:session_ids,task_ids,since,until(RFC3339,sinceinclusive /untilexclusive),types. Opaque cursor pagination (Page/PageInfo) like every other reader. -
SDK accessor.
Host.Messages() MessageReaderwithList(ctx, MessageFilter, Page) ([]Message, *PageInfo, error), plus Go-nativeMessage/MessageFiltermirrors inpkg/pluginsdk/data_types.goand anUnimplementedHostData.Messages()default. No proto types leak past the package boundary. -
Capability gate
api_read:messages. A newresourceMessagesconst and aMessages()accessor ininternal/plugins/host_data.gothat returns a real, service-backed reader when the manifest declaresapi_read:messages, else a denied reader whoseListreturns gRPCPermissionDeniedwithcapability 'api_read:messages' not declared— identical to the other resources. A non-RFC3339since/untilreturns gRPCInvalidArgument. -
Content is sanitized; system prompts never leave kandev. The reader maps
models.Message→ DTO throughmessageModelToDTO, which runssysprompt.StripSystemContentoncontent— the same<kandev-system>stripping themessage.addedevent applies. Raw system content and the event path'sraw_content/has_hidden_promptsare not exposed.author_typeis only everuseroragent: there is nosystemauthor in kandev's model — system context is inline markup inside an otherwise user/agent message, and it is stripped at read time. -
Reads go through the service layer. A new
Service.ListMessagesForPlugin(ctx, models.PluginMessageFilter)on the task service delegates to a newMessageRepository.ListMessagesForPlugin/ SQLiteListMessagesForPlugin— a single filtered query (task_session_id IN,task_id IN,type IN,created_at >= since,created_at < until), orderedcreated_at ASC, id ASC, with SQLLIMIT/OFFSET. The reader requestspage-limit+1rows to deriveHasMorewithout a second count query;NextCursorisoffset+limit, matching the other readers' opaque-offset cursor. Theinternal/pluginspackage reaches this via a narrowmessageDataSourceinterface wired byService.SetDataSources, never importing the task service directly (the same import-cycle avoidance the existing readers use).
Consequences
- A plugin can read a window of conversation content (e.g. "everything on this workspace's tasks since yesterday 00:00") without touching the DB file, and without the plugin having to be running when the messages were created.
- The
Messageproto is a public contract: fields are additive-only thereafter. - The new query is the one net-new piece of data access; every other layer is the established ADR 0043 pattern (accessor + denied reader + narrow data source + DTO mapper + in-process/wire capability tests).
- Scoping stays ADR 0043 v1: reads are global to the instance, filters narrow results, and the single server-side data-source hook remains the place a future per-plugin/per-user restriction would attach.
Alternatives considered
- Subscribe to
message.addedand accumulate. Rejected: only captures messages created while the plugin runs, needs durable plugin-side storage, and gives nothing for the pre-install / "yesterday" window the feature is about. - Expose the raw message row (including
raw_content). Rejected: leaks kandev's injected system prompts to plugins. The event path already strips them; the read path must match. - A session-only reader (reuse
ListMessages). Rejected: the standup use case is task/time scoped ("yesterday, across these tasks"), which the session-only query cannot express without an N+1 fan-out and client-side time filtering. One filtered query is simpler and cheaper.