ADR 0003 — Architecture, data model and events
- Status: accepted
- Date: 2026-10-29
- Issue: #5 — Définir l'architecture et le modèle de données
- Builds on: ADR 0001 — ACP, ADR 0002 — UI stack, configuration spec
Context
Several features overlap: an agent process serves several channels, the same ACP session feeds the conversation and the Agents panel, and the configuration file can change while agents run. Without a clear split, the frontend ends up driving agent processes directly. This ADR fixes the packages, the data model, what is stored where, and the events between Go and the UI, before the first production code (milestone M1).
Packages

Source: docs/diagrams/packages.drawio. On 2026-10-29 only schema exists; the others are the target of M1–M4 and the diagram is updated whenever a package is added, removed or re-wired.
| Package | Responsibility | Depends on |
|---|---|---|
cmd/sidekicks |
main: flags (--config), wiring, application.Run(). No logic. |
internal/app, Wails |
internal/app |
The Wails services bound to the frontend (ChannelService, AgentService, SettingsService, WorkspaceService) and the events sent to it. Translates between UI calls and the domain; the only package the UI sees. |
domain packages, Wails |
internal/channels |
Channels, members, messages, routing of a user message to the agents it addresses, @mention and /agent:command parsing, context re-injection. |
agents, store |
internal/agents |
One manager owning every agent process: lazy start, idle stop, maxRunningAgents, state machine, stderr logs. Opens ACP sessions on behalf of channels. |
acp, config |
internal/acp |
Wraps coder/acp-go-sdk: connection, initialize, sessions, prompt turns, permission requests. Exposes Sidekicks types; SDK types never leave it. |
ACP SDK |
internal/config |
Load, validate (schema + cross-entry rules), resolve paths, watch, save keeping comments. | schema, yaml |
schema |
The configuration JSON Schema and ValidateConfig. Public (not internal/) because the editor's validation and external tools may use it. |
jsonschema, yaml |
internal/store |
Pebble: channels, memberships, messages. Knows nothing about ACP. | Pebble v2 |
internal/workspace |
Workspace interface (list, read, write, remove, watch, exec with PTY) and its local, container, sbx implementations. |
creack/pty, docker, sbx |
Rules:
- Dependencies point downwards only; no cycle.
channelsusesagents, never the opposite: an agent does not know about channels. - Each domain package is unit-tested with fakes of the package below it (a fake ACP agent built with the SDK's agent side, an in-memory store, a fake workspace). Tests with real agents carry the
realagentsbuild tag and go throughscripts/agents.sh. - Errors are returned with context (
fmt.Errorf("start agent %s: %w", …));internal/appturns them into messages for people.
Configuration vs. stored state
| Where | Edited by | Examples | |
|---|---|---|---|
| What exists | the YAML file (spec) | the user (Settings panel, any editor) | agents, declared channels, limits, UI preferences |
| What happens | Pebble, under <user data dir>/sidekicks/<config id>/ |
the application | channels created in the UI, members, messages, ACP session ids |
| Application preferences | a small JSON file next to the Pebble folder | the application | last configuration opened, window layout |
<config id> is a short hash of the configuration file's absolute path: two configurations never mix their histories (issue #19).
Channels have one home: Pebble. A channel declared in YAML is created in Pebble at startup if no channel of that name exists; afterwards it is an ordinary channel. Removing it from YAML does not delete it. This keeps the YAML a description, never a database.
Data model
Agent (from the configuration — not stored)
name, title, icon, description, command, args, env, workspace, runtime
Channel id (ULID), name, description, createdAt, updatedAt
Membership channelId, agentName, joinedAt, acpSessionId?, lastSeenMessageId?
Message id (ULID), channelId, author {kind: user|agent|system, agentName?},
parts[], createdAt, turn {stopReason?, error?, usage?}
Part text (Markdown) | toolCall {id, kind, title, status, input, output}
| permission {toolCallId, options, choice} | plan {entries}
- ULIDs sort by creation time, so a key range scan returns messages in order without an index.
lastSeenMessageIdis how routing knows which messages an agent has not seen yet (decision 3 of ADR 0001, issue #23): when it is mentioned, the messages after it are added to its prompt.acpSessionIdis stored but not reusable with mm (noloadSession); after a restart a new session is opened and the context is re-injected (issue #25). It is kept for agents that supportsession/load.
Pebble keys
v → schema version of the store (1)
channel/<channelId> → Channel (JSON)
channel-name/<name> → channelId (uniqueness, lookup)
member/<channelId>/<agentName> → Membership (JSON)
msg/<channelId>/<messageId> → Message (JSON)
A message is written when the turn ends (stop reason or error), not for each streamed chunk; while it streams it lives in memory only. Losing an unfinished answer on a crash is acceptable; writing to disk 50 times per second is not.
Events from Go to the UI
Typed with application.RegisterEvent[T] (ADR 0002), emitted only by internal/app.
| Event | Payload | When |
|---|---|---|
config:changed |
the agents and channels summary, or validation errors | the file was reloaded |
agent:state |
`{agent, state: stopped | starting |
agent:log |
{agent, line} |
a stderr line (only while the Agents panel shows it) |
channel:changed |
Channel + members | created, renamed, deleted, a member joined |
message:started |
{channelId, messageId, author} |
a turn starts |
message:delta |
{channelId, messageId, text?, part?} |
a chunk, a tool call or its update, a plan |
message:finished |
the complete Message | stop reason or error |
permission:requested |
{requestId, channelId, agent, toolCall, options} |
the agent asks (the UI answers with ChannelService.AnswerPermission) |
commands:changed |
{channelId, agent, commands} |
available_commands_update |
usage:changed |
{channelId, agent, used, size} |
usage_update |
terminal:data |
{terminalId, data} |
PTY output (issue #31) |
workspace:changed |
{agent, path} |
a file changed in a watched workspace |
The frontend renders Markdown at most once per animation frame (ADR 0002), so message:delta can be emitted for every ACP chunk without batching on the Go side; batching is added only if measurements on macOS require it (issue #13).
Flow of a user message
UI ─ ChannelService.Send(channel, "@garfield /idioms Il pleut") ─▶ internal/app
─▶ channels.Route: mentions → [garfield], command → "/idioms Il pleut"
─▶ agents.Session(channel, garfield): start mm if needed (limit!), session/new if needed
─▶ prompt = unseen channel messages + "/idioms Il pleut"
─▶ acp.Prompt … session/update → message:delta events … stopReason
─▶ store: Message saved, lastSeenMessageId updated ─▶ message:finished
Consequences
- M1 creates
cmd/sidekicks,internal/app,internal/config,internal/acp,internal/agentsin this layout (issues #8–#13). - The diagram and this table are part of the definition of done of any change that adds or re-wires a package.
- Pebble v2 (
github.com/cockroachdb/pebble/v2) is the store; its use is confined tointernal/store.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 |
|