5.8 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/requirements/plugins.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.