The previous three posts covered how events flow from the SDK to the UI, how the timeline renders, and how tool cards visualize. This final post looks at SwiftWork's infrastructure — how data is stored, how state is restored, how Markdown is rendered, how code is highlighted, and how API keys are managed.
These components are independent, but all essential to making the app usable.
SwiftData Model Layer
SwiftWork uses SwiftData for persistence, registering four models:
// SwiftWorkApp.swift
.modelContainer(for: [
Session.self,
Event.self,
AppConfiguration.self,
PermissionRule.self
])
Session
@Model
final class Session {
@Attribute(.unique) var id: UUID
var title: String
var createdAt: Date
var updatedAt: Date
var workspacePath: String?
@Relationship(deleteRule: .cascade, inverse: \Event.session)
var events: [Event]
}
@Relationship(deleteRule: .cascade) means deleting a Session automatically deletes all its Events. workspacePath is optional — users can assign different working directories to each session.
Event
@Model
final class Event {
@Attribute(.unique) var id: UUID
var sessionID: UUID
var eventType: String
var rawData: Data // JSON serialized AgentEvent
var timestamp: Date
var order: Int
var session: Session?
}
This design was covered in the first post — rawData is the entire AgentEvent serialized as a JSON blob. The reason for not splitting it into separate fields is that the metadata structure varies by event type. Splitting into fields would result in many empty columns and frequent schema changes.
AppConfiguration
@Model
final class AppConfiguration {
@Attribute(.unique) var id: UUID
var key: String
var value: Data
var updatedAt: Date
}
A generic key-value store. It uses SwiftData instead of UserDefaults because SwiftData supports async access, data migration, and iCloud sync (which may be needed in the future). The stored values include:
-
hasCompletedOnboarding— whether the initial onboarding is complete -
selectedModel— the user's chosen model -
lastActiveSessionID— the last active session ID -
windowFrame— window position and size -
inspectorVisible— whether the Inspector panel is visible
AppStateManager: App State Restoration
AppStateManager is responsible for restoring the user's working state after the app restarts — the previously open session, window position, and Inspector panel visibility.
@MainActor
@Observable
final class AppStateManager {
var lastActiveSessionID: UUID?
var windowFrame: NSRect?
var isInspectorVisible: Bool = false
func loadAppState() {
lastActiveSessionID = loadUUID(key: "lastActiveSessionID")
windowFrame = loadNSRect(key: "windowFrame")
isInspectorVisible = loadBool(key: "inspectorVisible")
}
func saveLastActiveSessionID(_ id: UUID?) { ... }
func saveWindowFrame(_ frame: NSRect) { ... }
func saveInspectorVisibility(_ visible: Bool) { ... }
}
Under the hood, it uses AppConfiguration's key-value storage:
private func saveString(_ string: String, forKey key: String) {
let descriptor = FetchDescriptor<AppConfiguration>(
predicate: #Predicate { $0.key == key }
)
if let existing = try? modelContext.fetch(descriptor).first {
existing.value = Data(string.utf8)
} else {
let config = AppConfiguration(key: key, value: Data(string.utf8))
modelContext.insert(config)
}
try? modelContext.save()
}
This is an upsert pattern — first check if it exists, update if found, insert if not. loadNSRect converts the string back to NSRect (using NSRectFromString), and loadBool compares against the string "true".
Save Timing
State saving is not done all at once when the app exits, but distributed across various trigger points:
| State | Save trigger |
|---|---|
lastActiveSessionID |
When the user switches sessions (SessionViewModel.selectSession) |
windowFrame |
On window move/resize (500ms throttle) + on app exit |
inspectorVisible |
When the Inspector panel is toggled |
Window position saving is throttled — didMoveNotification and didResizeNotification fire very frequently, and writing to SwiftData on every event is wasteful. A 500ms Task.sleep acts as a debounce, so only the final move/resize is actually saved:
// ContentView.swift
let saveWindowFrameThrottled: (Notification) -> Void = { _ in
saveTask?.cancel()
saveTask = Task { @MainActor in
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
if let window = mainWindow {
appStateManager.saveWindowFrame(window.frame)
}
}
}
Restoration Flow
When the app launches, ContentView.task triggers the restoration:
.task {
settingsViewModel.configure(modelContext: modelContext)
hasCompletedOnboarding = settingsViewModel.isAPIKeyConfigured
&& !settingsViewModel.isFirstLaunch
if hasCompletedOnboarding == true {
configureAndRestoreState()
}
}
configureAndRestoreState restores state in order:
- Initialize
AppStateManager, loading saved state - Initialize
SessionViewModel, fetching the session list - Select the session matching
lastActiveSessionID - Restore
isInspectorVisible - Restore window position (if the window reference has arrived)
Window position restoration has a timing issue — WindowAccessor's callback is asynchronous, so the window reference may arrive after task completes. That's why onChange(of: mainWindow) also handles restoration:
.onChange(of: mainWindow) { _, newWindow in
if let newWindow {
restoreWindowFrame(in: newWindow)
}
}
MarkdownRenderer: Visitor Pattern for Markdown Rendering
Agent responses are in Markdown format — headings, lists, code blocks, bold text, links. SwiftWork uses Apple's swift-markdown library to parse Markdown, then traverses the AST with the Visitor pattern to generate SwiftUI views.
Why Not Use an Existing Markdown Rendering Component
There aren't many Markdown rendering components on macOS. AttributedString(markdown:) only supports basic formatting (bold, links) and doesn't support code blocks, tables, or block quotes. WebView-based solutions (rendering Markdown to HTML via Markdown.js) introduce WebKit dependencies and memory overhead. Writing a custom Visitor gives precise control over how each element renders, without adding extra dependencies.
Visitor Implementation
private struct MarkdownToViewsVisitor: @preconcurrency MarkupVisitor {
private(set) var views: [AnyView] = []
mutating func visitHeading(_ heading: Heading) -> Result { ... }
mutating func visitParagraph(_ paragraph: Paragraph) -> Result { ... }
mutating func visitCodeBlock(_ codeBlock: CodeBlock) -> Result { ... }
mutating func visitUnorderedList(_ unorderedList: UnorderedList) -> Result { ... }
mutating func visitOrderedList(_ orderedList: OrderedList) -> Result { ... }
mutating func visitBlockQuote(_ blockQuote: BlockQuote) -> Result { ... }
mutating func visitTable(_ table: Table) -> Result { ... }
mutating func visitThematicBreak(_ thematicBreak: ThematicBreak) -> Result { ... }
}
Each visit method handles one Markdown node type, appending the generated view to the views array. Finally, MarkdownRenderer.render() returns this array, and MarkdownContentView renders it with ForEach.
Inline Format Handling
Inline formatting within paragraphs and list items (bold, italic, inline code, links) is handled by collectAttributedString. It recursively traverses child nodes to build an AttributedString:
private mutating func collectAttributedString(from markup: any Markup) -> AttributedString {
var result = AttributedString()
for child in markup.children {
if let strong = child as? Strong {
var s = collectAttributedString(from: strong)
s.font = .body.bold()
result.append(s)
} else if let emphasis = child as? Emphasis {
var e = collectAttributedString(from: emphasis)
e.font = .body.italic()
result.append(e)
} else if let inlineCode = child as? InlineCode {
var codeAttr = AttributedString(inlineCode.code)
codeAttr.backgroundColor = Color.primary.opacity(0.06)
codeAttr.font = .system(.body, design: .monospaced)
result.append(codeAttr)
} else if let link = child as? MarkdownLink {
var linkAttr = AttributedString(collectInlineText(from: link))
linkAttr.foregroundColor = Color.accentColor
linkAttr.underlineStyle = .single
linkAttr.link = URL(string: link.destination)
result.append(linkAttr)
}
// ... SoftBreak, LineBreak, Strikethrough
}
return result
}
AttributedString is a rich text type natively supported by SwiftUI. Passing it to SwiftUI.Text(attributed) causes SwiftUI to render with the specified font, color, and backgroundColor. Inline code gets a gray-background monospaced font, and links get blue underlines.
Type Name Conflicts
swift-markdown and SwiftUI have type name conflicts — both define Text, Link, and other types. The solution uses typealiases:
private typealias MarkdownText = Markdown.Text
private typealias MarkdownLink = Markdown.Link
Inside the visitor, MarkdownText and MarkdownLink reference swift-markdown types, while SwiftUI.Text references SwiftUI types.
CodeHighlighter: Splash Code Highlighting
Code block highlighting uses John Sundell's Splash library. Currently only Swift syntax highlighting is supported; other languages fall back to monospaced plain text:
enum CodeHighlighter {
static func highlight(code: String, language: String?) -> AnyView {
let trimmedLanguage = language?.lowercased()
if trimmedLanguage == "swift" {
return highlightedSwiftView(code: code)
} else {
return plainCodeView(code: code)
}
}
private static func highlightedSwiftView(code: String) -> AnyView {
let theme = Theme.sundellsColors(withFont: Splash.Font(size: 13))
let format = AttributedStringOutputFormat(theme: theme)
let highlighter = SyntaxHighlighter(format: format)
let attributed = try? AttributedString(highlighter.highlight(code), including: \.appKit)
return AnyView(Text(attributed ?? AttributedString(code)))
}
}
Splash's pipeline: source code string -> SyntaxHighlighter -> AttributedStringOutputFormat -> NSAttributedString -> AttributedString -> SwiftUI.Text.
Why only Swift? Because Splash only supports Swift. To support Python/JavaScript/Bash, you'd need a multi-language highlighting library (like a Swift wrapper for Highlight.js) or Tree-sitter. Currently, Swift code blocks are highlighted most frequently (SwiftWork itself is a Swift project), so Swift-only support is sufficient for now.
KeychainManager: Secure API Key Storage
API keys should not be stored in plaintext in SwiftData or UserDefaults. SwiftWork uses the macOS Keychain:
struct KeychainManager: KeychainManaging, Sendable {
func save(key: String, data: Data) throws {
let query = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key
]
let status = SecItemAdd(query.merging([kSecValueData: data]), nil)
if status == errSecDuplicateItem {
SecItemUpdate(query, [kSecValueData: data])
}
}
func load(key: String) throws -> Data? {
let query = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var result: AnyObject?
let status = SecItemCopyMatching(query, &result)
if status == errSecItemNotFound { return nil }
return result as? Data
}
}
The KeychainManaging protocol abstracts the underlying implementation, making it easy to mock during testing. Protocol extensions provide convenience methods like saveAPIKey/getAPIKey/deleteAPIKey.
Keychain storage has two advantages: data is encrypted at the system level, and it is not subject to App Sandbox file access restrictions.
TitleGenerator: Auto-Generating Session Titles
Newly created sessions have the title "New Session". After the Agent completes its first execution, TitleGenerator uses an LLM to generate a short title based on the conversation content:
enum TitleGenerator {
static func generate(events: [AgentEvent], apiKey: String, ...) async -> String? {
guard !apiKey.isEmpty else { return nil }
let messages = events
.filter { $0.type == .userMessage || $0.type == .assistant }
.suffix(10) // Only take the last 10 messages
.map { ["role": ..., "content": String($0.content.prefix(500))] }
let body = [
"model": model,
"max_tokens": 50,
"system": "Generate a short title based on the following conversation (maximum 20 characters). Output only the title.",
"messages": messages
]
// Call LLM API, return title text
}
}
The trigger lives in WorkspaceView.setupTitleGeneration — via the AgentBridge.onResult callback, it fires when the Agent finishes execution and the session title is still "New Session":
agentBridge.onResult = { [weak session] _ in
guard let session, session.title == "New Session" else { return }
if let title = await TitleGenerator.generate(events: events, ...) {
sessionViewModel.updateSessionTitle(session, title: title)
}
}
This is a lightweight LLM call — only a 50-token output limit, a short system prompt, and it takes the last 10 messages with each truncated to 500 characters. In practice, latency is 1-2 seconds and doesn't affect the user experience.
Summary
SwiftWork's data layer and service components each have their own responsibilities:
| Component | Responsibility |
|---|---|
| SwiftData | Session/Event/AppConfiguration persistence |
| AppStateManager | App state restoration (session, window, panel) |
| EventStore | Event persistence protocol, SwiftData implementation |
| MarkdownRenderer | swift-markdown AST -> SwiftUI views |
| CodeHighlighter | Splash syntax highlighting (Swift) |
| KeychainManager | Secure API key storage |
| TitleGenerator | LLM auto-generated session titles |
They form the "support layer" beneath the core pipeline (AgentBridge -> EventMapper -> TimelineView) covered in previous posts. The app would still run without them, but the user experience would be significantly worse — no persistence means starting from scratch on every restart, no Markdown rendering means Agent responses are raw text, and no Keychain management means API keys are stored in plaintext.
Deep Dive into SwiftWork Series:
- Part 0: Building a macOS Agent Workbench with SwiftUI
- Part 1: SDK Integration — Bridging AsyncStream to SwiftUI
- Part 2: Event Timeline — Visualizing 18 Event Types
- Part 3: Tool Card — An Extensible Tool Visualization System
- Part 4: Data Layer and Services — SwiftData, State Restore, Markdown Rendering
GitHub: SwiftWork | Open Agent SDK
Top comments (0)