This chapter documents the decoded protocol surface in
Spettro/ACP/ACPProtocol.swift — the Swift types that mirror the
acp-go-sdk wire types for the subset
of ACP this app actually drives.
A note on how these types are shaped: the ACP discriminated unions
(session updates, config options, permission outcomes) carry their variant
tag in a sibling string field, not in a shape Swift's Codable can
synthesize. So every union here is decoded by hand from
JSONValue via a static parse(_:)
function, while the flat structs (ACPImplementation, ACPInitializeResult,
ACPPromptResult) are plain Codable. Everything in the file is
nonisolated and Sendable so decoded values can cross from the
connection's reader queue to @MainActor consumers.
Contents:
- Initialize
- Content blocks
- Stop reasons and prompt results
- Config options
- Available commands
- Tool calls
- Session updates
- Usage updates
- Plan entries
- Permission requests
- Update routing cheat sheet
Two plain structs carry the agent's answer to the initialize handshake:
nonisolated struct ACPImplementation: Codable, Hashable, Sendable {
var name: String
var title: String?
var version: String?
}
nonisolated struct ACPInitializeResult: Decodable, Sendable {
var protocolVersion: Int
var agentInfo: ACPImplementation?
}ACPImplementation does double duty: the app encodes its own identity into
initialize params (clientInfo) and decodes the agent's identity from
the result (agentInfo). Representative wire JSON for the result:
{
"protocolVersion": 1,
"agentInfo": { "name": "spettro", "version": "0.42.0" }
}ACPContentBlock is the only outbound union in the file — the content
variants of a session/prompt call. The app currently produces exactly two:
text, and base64 images (from ImageAttachment). The variant tag is
encoded by hand into a type field:
nonisolated enum ACPContentBlock: Encodable, Sendable {
case text(String)
case image(base64: String, mimeType: String)
}{ "type": "text", "text": "Fix the failing test." }
{ "type": "image", "data": "/9j/4AAQSkZJRg…", "mimeType": "image/jpeg" }There is deliberately no decoder — the app never reads prompt content
back. ACPAgent.prompt(sessionId:blocks:) wraps the array in
PromptParams and sends it as session/prompt.
The result of session/prompt is one field, the stop reason. The raw
values the CLI sends are snake_case where multi-word; refusal,
cancelled, and unknown use the default (the case name itself):
nonisolated enum ACPStopReason: String, Decodable, Sendable {
case endTurn = "end_turn"
case maxTokens = "max_tokens"
case maxTurnRequests = "max_turn_requests"
case refusal
case cancelled
case unknown
}Note the custom init(from:): it decodes the string, falls back to
.unknown on any unrecognized raw value, and never throws. A CLI that
invents a new stop reason degrades gracefully instead of failing the whole
prompt decode.
nonisolated struct ACPPromptResult: Decodable, Sendable {
var stopReason: ACPStopReason
}{ "stopReason": "end_turn" }ACPConfigOption models one agent-advertised session setting — the
mode/model/permission/thinking selectors and boolean toggles like "ultra"
that appear in the chat toolbar (ConfigBar). Everything is data-driven:
the app has no hardcoded knowledge of which options exist.
nonisolated struct ACPConfigOption: Identifiable, Hashable, Sendable, Codable {
enum Kind {
case select(current: String, groups: [OptionGroup])
case boolean(current: Bool)
}
struct Option { var name, value: String; var description: String? }
struct OptionGroup { var name: String?; var options: [Option] }
var id, name: String
var description, category: String?
var kind: Kind
}Two shapes on the wire:
- Boolean —
"type": "boolean", withcurrentValuea JSON bool. - Select (the default when
typeis anything else) —currentValueis the currently-selectedvalue, andoptionsis either a flat array of options or a grouped array whose elements each carry a nestedoptionsarray.parse(_:)distinguishes the two by looking at whether the first element has anoptionskey.
A grouped select (e.g. the model picker):
{
"id": "model",
"name": "Model",
"type": "select",
"category": "model",
"currentValue": "claude-sonnet-4",
"options": [
{
"name": "Anthropic",
"options": [
{ "name": "Sonnet 4", "value": "claude-sonnet-4", "description": "Fast and capable" }
]
}
]
}A flat, ungrouped select parses into a single OptionGroup(name: nil, …) —
that's what the nil group name means throughout, "ungrouped". A boolean:
{
"id": "ultra",
"name": "Ultrathink",
"type": "boolean",
"currentValue": true
}The derived currentLabel property resolves the selected value back to its
display name (scanning all groups for a matching value, falling back to
the raw current string), and renders booleans as "On" / "Off" — that
is what compact toolbar buttons show.
Changing an option goes through
ACPAgent.setConfigOption(sessionId:configId:value:) (overloaded for
String and Bool), which returns the refreshed full option set; the
agent also pushes unsolicited changes via
config_option_update.
ACPCommand is one slash-command the agent advertises — the source of the
composer's command palette:
nonisolated struct ACPCommand: Identifiable, Hashable, Sendable, Codable {
var name: String
var description: String
var hint: String?
var id: String { name }
}The wire shape nests the hint under input:
{ "name": "commit", "description": "Commit staged changes", "input": { "hint": "optional message" } }The type also owns a small UserDefaults cache (spettro.cachedCommands):
saveCache(_:) persists the last non-empty command list any agent
advertised (called by AppModel.handleUpdate whenever an
available_commands_update arrives), and cached loads it back, so the
palette works before — or without — a live ACP connection.
Tool-call state arrives split across two session-update variants
(tool_call at start, tool_call_update thereafter), but both parse into
the same ACPToolCallEvent — every field except toolCallId is optional,
so a sparse delta and a full snapshot decode identically:
nonisolated struct ACPToolCallEvent: Sendable {
var toolCallId: String
var title: String?
var kind: String? // raw string, e.g. "execute", "edit"
var status: ACPToolStatus?
var rawInput: JSONValue? // kept verbatim for display/debugging
var content: [ACPToolContent]
var locations: [String] // flattened from location objects' "path"
}{
"sessionUpdate": "tool_call",
"toolCallId": "call_7f3a",
"title": "Run swift test",
"kind": "execute",
"status": "in_progress",
"rawInput": { "command": "swift test" },
"content": [],
"locations": [{ "path": "/Users/me/project/Package.swift" }]
}kind is kept as a raw String, not an enum — views compare against
string literals (e.g. "execute"), and unknown kinds pass through. Status,
by contrast, is a closed enum:
nonisolated enum ACPToolStatus: String, Sendable {
case pending, inProgress = "in_progress", completed, failed, unknown
}Like ACPStopReason, the custom init(_ raw: String?) maps anything
unrecognized (including a missing field) to .unknown rather than failing.
Note status on the event is ACPToolStatus? — absent on a start event,
present once the tool begins reporting — whereas a parsed-but-unrecognized
value is .unknown.
ACPToolContent covers what a tool call emits as it runs:
nonisolated enum ACPToolContent: Sendable {
case text(String)
case diff(path: String, oldText: String?, newText: String)
}{ "type": "content", "content": { "type": "text", "text": "Tests passed." } }
{ "type": "diff", "path": "Sources/main.swift", "oldText": "a", "newText": "b" }The parser is forgiving about text: it accepts the standard
{ "type": "content", "content": { "text": … } } wrapper or a bare
text field on the content object itself. For a diff, path and newText
are required (parse fails otherwise); oldText is optional — a newly
created file has none. The plainText derived property gives a one-line
summary ("Edited <path>" for diffs, the text itself otherwise), used for
compact rendering.
ACPSessionUpdate is the inbound heart of the protocol — the decoded
variants of session/update notifications the app acts on. The tag lives
in the sessionUpdate string field of params.update:
nonisolated enum ACPSessionUpdate: Sendable {
case agentMessageChunk(String)
case agentThoughtChunk(String)
case toolCall(ACPToolCallEvent)
case toolCallUpdate(ACPToolCallEvent)
case availableCommands([ACPCommand])
case configOptions([ACPConfigOption])
case plan([ACPPlanEntry])
case usage(ACPUsageUpdate)
case other(String)
}All variants, by wire tag:
sessionUpdate tag |
Swift case | Payload extracted |
|---|---|---|
agent_message_chunk |
.agentMessageChunk |
content.text string (streamed reply text) |
agent_thought_chunk |
.agentThoughtChunk |
content.text string (reasoning stream) |
tool_call |
.toolCall |
full ACPToolCallEvent (tool start) |
tool_call_update |
.toolCallUpdate |
sparse ACPToolCallEvent (progress delta) |
available_commands_update |
.availableCommands |
availableCommands array |
config_option_update |
.configOptions |
configOptions array |
plan |
.plan |
entries array |
usage_update |
.usage |
ACPUsageUpdate (fails to parse if used/size missing or size <= 0) |
| (anything else) | .other(tag) |
just the tag string — logged-and-ignored |
A representative chunk and a config push:
{
"sessionId": "sess_123",
"update": {
"sessionUpdate": "agent_message_chunk",
"content": { "type": "text", "text": "Looking at the failing test…" }
}
}{
"sessionId": "sess_123",
"update": {
"sessionUpdate": "config_option_update",
"configOptions": [ { "id": "mode", "name": "Mode", "currentValue": "code", "options": [] } ]
}
}Three parser details worth knowing:
- Chunk text for both streaming variants is pulled from
update.content.textvia the sharedchunkText(_:)helper; a missing or non-objectcontentyields the empty string, not a parse failure. - Unknown tags are not dropped.
parsereturns.other(tag)so the enum is total;AppModel.handleUpdateexplicitly ignores.other, meaning new CLI-side update kinds never break older app builds. - Sublists parse element-wise with
compactMap— one malformed option, command, or plan entry doesn't poison the whole update.
ACPConnection.handleNotification guards on method == "session/update",
pulls sessionId and update from the params, and only then calls
parse — so ACPSessionUpdate.parse never sees the envelope, just the
inner update object.
ACPUsageUpdate is live context-window accounting, shown as the context
chip in the chat header:
nonisolated struct ACPUsageUpdate: Hashable, Sendable {
var used: Int // tokens currently in context
var size: Int // total window size in tokens
var totalTokens: Int? // cumulative tokens this session (Spettro extension)
}{
"sessionUpdate": "usage_update",
"used": 41230,
"size": 200000,
"_meta": { "spettro.app/tokensUsed": 182004 }
}Two quirks to note: the parse requires size > 0 (a zero window would
make every percentage computation divide by zero, so the update is dropped
entirely), and totalTokens is a Spettro-specific extension smuggled
through the standard _meta escape hatch under the key
spettro.app/tokensUsed — stock ACP agents won't send it, hence the
optionality.
ACPPlanEntry is one line of the agent's live task plan:
nonisolated struct ACPPlanEntry: Identifiable, Hashable, Sendable {
var content: String
var status: String // raw string; "pending" when absent
var priority: String?
var id: String { content }
}{
"sessionUpdate": "plan",
"entries": [
{ "content": "Reproduce the failure", "status": "completed", "priority": "high" },
{ "content": "Fix the off-by-one", "status": "in_progress" }
]
}Like tool-call kind, status and priority are kept as raw strings so
the CLI can introduce new values without a client update; the identity of
an entry is its content, which is how the plan chip diffs successive
plan updates.
ACPPermissionRequest is the odd one out: it is not a session update
but an agent→client JSON-RPC request (session/request_permission) that
the app must answer. ACPConnection.handleIncomingRequest parses it,
routes it to AppModel.onPermissionRequest, and refuses everything
agent→client except _spettro/question/ask with -32601 (the app
advertises no fs/terminal client capabilities, so the agent should never
call those). Agent questions have their own types in ACP/ACPQuestion.swift
— see Question sheet.
A permission request whose _meta carries spettro.app/question is an
ask-user question an older CLI is walking over this transport; AppModel
routes it to the question sheet rather than the permission sheet.
nonisolated struct ACPPermissionRequest: Identifiable, Sendable {
struct Option: Identifiable, Hashable, Sendable {
var optionId, name, kind: String
var id: String { optionId }
}
var id = UUID()
var rpcID: RPCID // the JSON-RPC id the reply must echo back
var sessionId: String
var title: String
var toolKind: String?
var rawInput: JSONValue?
var options: [Option]
}{
"jsonrpc": "2.0",
"id": 42,
"method": "session/request_permission",
"params": {
"sessionId": "sess_123",
"toolCall": {
"title": "Run `rm -rf build/ && swift build`?",
"kind": "execute",
"rawInput": { "command": "rm -rf build/ && swift build" }
},
"options": [
{ "optionId": "allow-once", "name": "Allow once", "kind": "allow_once" },
{ "optionId": "reject", "name": "Reject", "kind": "reject_once" }
]
}
}Parser details:
sessionIdand atoolCallobject are required; a malformed request gets a-32602error reply fromACPConnection.titlefalls back to"Permission requested"when the tool call carries none.- Option
kinddefaults to the empty string when absent; thePermissionSheetuses it only for tint and keyboard shortcut, never for logic — the reply carriesoptionId, notkind. idis a fresh localUUID()(for SwiftUI sheet identity) whilerpcIDis the protocol id — the two are deliberately different, and the reply path (AppModel.resolvePermission/dismissPermission→ACPAgent.replyPermission/cancelPermission) uses onlyrpcID.
The reply encoding lives in ACPAgent.PermissionResponse: the outcome is
a nested object, either
{ "outcome": { "outcome": "selected", "optionId": "allow-once" } } or
{ "outcome": { "outcome": "cancelled" } }.
Where each decoded value lands, per AppModel.handleUpdate(sessionId:update:):
| Case | Routed to |
|---|---|
.agentMessageChunk |
session.appendAssistant(_:) |
.agentThoughtChunk |
session.appendReasoning(_:) |
.toolCall |
session.applyToolEvent(_:isStart: true) |
.toolCallUpdate |
session.applyToolEvent(_:isStart: false) |
.availableCommands |
session.commands + ACPCommand.saveCache(_:) |
.configOptions |
session.configOptions + remember + persist (non-empty only) |
.plan |
session.plan |
.usage |
session.usage |
.other |
ignored |
- ACP agent façade — the calls that send these types and the param encodings around them.
- JSONValue / RPCID — the dynamic JSON type
every
parse(_:)here consumes. - Chat session model — what
applyToolEvent,appendAssistant, and friends do with the decoded values.