diff --git a/.claude/skills/agentmail b/.claude/skills/agentmail
new file mode 120000
index 0000000..63f635b
--- /dev/null
+++ b/.claude/skills/agentmail
@@ -0,0 +1 @@
+../../.agents/skills/agentmail
\ No newline at end of file
diff --git a/apps/omega-mac/OmegaMac/Assets.xcassets/AccentColor.colorset/Contents.json b/apps/omega-mac/OmegaMac/Assets.xcassets/AccentColor.colorset/Contents.json
new file mode 100644
index 0000000..5695f71
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Assets.xcassets/AccentColor.colorset/Contents.json
@@ -0,0 +1,20 @@
+{
+ "colors": [
+ {
+ "color": {
+ "color-space": "srgb",
+ "components": {
+ "alpha": "1.000",
+ "blue": "0.996",
+ "green": "0.475",
+ "red": "0.325"
+ }
+ },
+ "idiom": "universal"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/omega-mac/OmegaMac/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 0000000..3d50046
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,58 @@
+{
+ "images": [
+ {
+ "idiom": "mac",
+ "scale": "1x",
+ "size": "16x16"
+ },
+ {
+ "idiom": "mac",
+ "scale": "2x",
+ "size": "16x16"
+ },
+ {
+ "idiom": "mac",
+ "scale": "1x",
+ "size": "32x32"
+ },
+ {
+ "idiom": "mac",
+ "scale": "2x",
+ "size": "32x32"
+ },
+ {
+ "idiom": "mac",
+ "scale": "1x",
+ "size": "128x128"
+ },
+ {
+ "idiom": "mac",
+ "scale": "2x",
+ "size": "128x128"
+ },
+ {
+ "idiom": "mac",
+ "scale": "1x",
+ "size": "256x256"
+ },
+ {
+ "idiom": "mac",
+ "scale": "2x",
+ "size": "256x256"
+ },
+ {
+ "idiom": "mac",
+ "scale": "1x",
+ "size": "512x512"
+ },
+ {
+ "idiom": "mac",
+ "scale": "2x",
+ "size": "512x512"
+ }
+ ],
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Assets.xcassets/Contents.json b/apps/omega-mac/OmegaMac/Assets.xcassets/Contents.json
new file mode 100644
index 0000000..74d6a72
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info": {
+ "author": "xcode",
+ "version": 1
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Models/Conversation.swift b/apps/omega-mac/OmegaMac/Models/Conversation.swift
new file mode 100644
index 0000000..608ff97
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Models/Conversation.swift
@@ -0,0 +1,41 @@
+import Foundation
+import SwiftData
+
+@Model
+final class Conversation {
+ var id: UUID
+ var title: String?
+ var createdAt: Date
+ var updatedAt: Date
+ var executionState: String // "idle" | "running"
+ var inputTokensTotal: Int
+ var outputTokensTotal: Int
+
+ @Relationship(deleteRule: .cascade, inverse: \Message.conversation)
+ var messages: [Message]
+
+ @Relationship(deleteRule: .cascade, inverse: \ToolCallRecord.conversation)
+ var toolRuns: [ToolCallRecord]
+
+ init(
+ title: String? = nil
+ ) {
+ self.id = UUID()
+ self.title = title
+ self.createdAt = Date()
+ self.updatedAt = Date()
+ self.executionState = "idle"
+ self.inputTokensTotal = 0
+ self.outputTokensTotal = 0
+ self.messages = []
+ self.toolRuns = []
+ }
+
+ var displayTitle: String {
+ title ?? "New Conversation"
+ }
+
+ var sortedMessages: [Message] {
+ messages.sorted { $0.createdAt < $1.createdAt }
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Models/EnvVar.swift b/apps/omega-mac/OmegaMac/Models/EnvVar.swift
new file mode 100644
index 0000000..0a8ba5f
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Models/EnvVar.swift
@@ -0,0 +1,18 @@
+import Foundation
+import SwiftData
+
+@Model
+final class EnvVar {
+ var id: UUID
+ var keyName: String
+ /// Last 4 characters of the value (for display hint)
+ var valueHint: String
+ var createdAt: Date
+
+ init(keyName: String, valueHint: String) {
+ self.id = UUID()
+ self.keyName = keyName
+ self.valueHint = valueHint
+ self.createdAt = Date()
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Models/Message.swift b/apps/omega-mac/OmegaMac/Models/Message.swift
new file mode 100644
index 0000000..8e37f2e
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Models/Message.swift
@@ -0,0 +1,141 @@
+import Foundation
+import SwiftData
+
+enum MessageRole: String, Codable {
+ case user = "USER"
+ case assistant = "ASSISTANT"
+ case tool = "TOOL"
+ case system = "SYSTEM"
+}
+
+@Model
+final class Message {
+ var id: UUID
+ var role: MessageRole
+ var content: String
+ var createdAt: Date
+ var inputTokens: Int?
+ var outputTokens: Int?
+
+ /// JSON-encoded array of tool calls (for assistant messages)
+ var toolCallsJSON: Data?
+
+ var conversation: Conversation?
+
+ init(
+ role: MessageRole,
+ content: String,
+ conversation: Conversation? = nil,
+ inputTokens: Int? = nil,
+ outputTokens: Int? = nil,
+ toolCalls: [ToolCallData]? = nil
+ ) {
+ self.id = UUID()
+ self.role = role
+ self.content = content
+ self.createdAt = Date()
+ self.inputTokens = inputTokens
+ self.outputTokens = outputTokens
+ self.conversation = conversation
+ if let toolCalls {
+ self.toolCallsJSON = try? JSONEncoder().encode(toolCalls)
+ }
+ }
+
+ var toolCalls: [ToolCallData] {
+ get {
+ guard let data = toolCallsJSON else { return [] }
+ return (try? JSONDecoder().decode([ToolCallData].self, from: data)) ?? []
+ }
+ set {
+ toolCallsJSON = try? JSONEncoder().encode(newValue)
+ }
+ }
+}
+
+/// Serializable tool call data stored in messages
+struct ToolCallData: Codable, Identifiable {
+ var id: String { toolCallId }
+ let toolCallId: String
+ let toolName: String
+ let args: JSONValue?
+ let output: JSONValue?
+
+ init(toolCallId: String, toolName: String, args: JSONValue? = nil, output: JSONValue? = nil) {
+ self.toolCallId = toolCallId
+ self.toolName = toolName
+ self.args = args
+ self.output = output
+ }
+}
+
+/// A type-erased JSON value for encoding/decoding arbitrary JSON
+enum JSONValue: Codable, Equatable, Sendable {
+ case string(String)
+ case number(Double)
+ case bool(Bool)
+ case object([String: JSONValue])
+ case array([JSONValue])
+ case null
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.singleValueContainer()
+ if container.decodeNil() {
+ self = .null
+ } else if let b = try? container.decode(Bool.self) {
+ self = .bool(b)
+ } else if let n = try? container.decode(Double.self) {
+ self = .number(n)
+ } else if let s = try? container.decode(String.self) {
+ self = .string(s)
+ } else if let arr = try? container.decode([JSONValue].self) {
+ self = .array(arr)
+ } else if let obj = try? container.decode([String: JSONValue].self) {
+ self = .object(obj)
+ } else {
+ self = .null
+ }
+ }
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch self {
+ case .string(let s): try container.encode(s)
+ case .number(let n): try container.encode(n)
+ case .bool(let b): try container.encode(b)
+ case .object(let o): try container.encode(o)
+ case .array(let a): try container.encode(a)
+ case .null: try container.encodeNil()
+ }
+ }
+
+ /// Convert any Codable/Sendable value to JSONValue
+ static func from(_ value: Any) -> JSONValue {
+ if let s = value as? String { return .string(s) }
+ if let n = value as? NSNumber {
+ if CFBooleanGetTypeID() == CFGetTypeID(n) {
+ return .bool(n.boolValue)
+ }
+ return .number(n.doubleValue)
+ }
+ if let b = value as? Bool { return .bool(b) }
+ if let i = value as? Int { return .number(Double(i)) }
+ if let d = value as? Double { return .number(d) }
+ if let arr = value as? [Any] { return .array(arr.map { from($0) }) }
+ if let obj = value as? [String: Any] {
+ return .object(obj.mapValues { from($0) })
+ }
+ return .null
+ }
+
+ /// Pretty-print JSON
+ var prettyString: String {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ guard let data = try? encoder.encode(self),
+ let str = String(data: data, encoding: .utf8) else {
+ return "null"
+ }
+ return str
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Models/ToolCallRecord.swift b/apps/omega-mac/OmegaMac/Models/ToolCallRecord.swift
new file mode 100644
index 0000000..19ab8a6
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Models/ToolCallRecord.swift
@@ -0,0 +1,51 @@
+import Foundation
+import SwiftData
+
+@Model
+final class ToolCallRecord {
+ var id: UUID
+ var toolName: String
+ var toolCallId: String
+ var status: String // "running" | "success" | "error"
+ var inputJSON: Data?
+ var outputJSON: Data?
+ var errorMessage: String?
+ var executionTimeMs: Int?
+ var createdAt: Date
+ var completedAt: Date?
+
+ var conversation: Conversation?
+
+ init(
+ toolName: String,
+ toolCallId: String,
+ conversation: Conversation? = nil
+ ) {
+ self.id = UUID()
+ self.toolName = toolName
+ self.toolCallId = toolCallId
+ self.status = "running"
+ self.createdAt = Date()
+ self.conversation = conversation
+ }
+
+ var input: JSONValue? {
+ get {
+ guard let data = inputJSON else { return nil }
+ return try? JSONDecoder().decode(JSONValue.self, from: data)
+ }
+ set {
+ inputJSON = try? JSONEncoder().encode(newValue)
+ }
+ }
+
+ var output: JSONValue? {
+ get {
+ guard let data = outputJSON else { return nil }
+ return try? JSONDecoder().decode(JSONValue.self, from: data)
+ }
+ set {
+ outputJSON = try? JSONEncoder().encode(newValue)
+ }
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Models/UserSettings.swift b/apps/omega-mac/OmegaMac/Models/UserSettings.swift
new file mode 100644
index 0000000..63b54f5
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Models/UserSettings.swift
@@ -0,0 +1,17 @@
+import Foundation
+import SwiftData
+
+@Model
+final class UserSettings {
+ var id: UUID
+ var systemPrompt: String?
+ var selectedModel: String
+ var pinnedToolIds: [String]
+
+ init() {
+ self.id = UUID()
+ self.systemPrompt = nil
+ self.selectedModel = "gpt-4.1-mini"
+ self.pinnedToolIds = []
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/OmegaMac.entitlements b/apps/omega-mac/OmegaMac/OmegaMac.entitlements
new file mode 100644
index 0000000..97aec89
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/OmegaMac.entitlements
@@ -0,0 +1,14 @@
+
+
+
+
+ com.apple.security.app-sandbox
+
+ com.apple.security.network.client
+
+ keychain-access-groups
+
+ $(AppIdentifierPrefix)com.tpmjs.omega-mac
+
+
+
diff --git a/apps/omega-mac/OmegaMac/OmegaMacApp.swift b/apps/omega-mac/OmegaMac/OmegaMacApp.swift
new file mode 100644
index 0000000..c14feef
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/OmegaMacApp.swift
@@ -0,0 +1,44 @@
+import SwiftData
+import SwiftUI
+
+@main
+struct OmegaMacApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .preferredColorScheme(.dark)
+ }
+ .modelContainer(for: [
+ Conversation.self,
+ Message.self,
+ ToolCallRecord.self,
+ EnvVar.self,
+ UserSettings.self,
+ ])
+ .defaultSize(width: 1100, height: 750)
+ .commands {
+ CommandGroup(replacing: .newItem) {
+ Button("New Conversation") {
+ NotificationCenter.default.post(
+ name: .newConversation, object: nil)
+ }
+ .keyboardShortcut("n", modifiers: .command)
+ }
+ }
+
+ #if os(macOS)
+ Settings {
+ SettingsView()
+ .modelContainer(for: [
+ EnvVar.self,
+ UserSettings.self,
+ ])
+ .preferredColorScheme(.dark)
+ }
+ #endif
+ }
+}
+
+extension Notification.Name {
+ static let newConversation = Notification.Name("newConversation")
+}
diff --git a/apps/omega-mac/OmegaMac/Services/ChatOrchestrator.swift b/apps/omega-mac/OmegaMac/Services/ChatOrchestrator.swift
new file mode 100644
index 0000000..fbbf5e0
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Services/ChatOrchestrator.swift
@@ -0,0 +1,613 @@
+import Foundation
+import SwiftData
+
+/// Represents a live tool call being displayed during streaming
+struct LiveToolCall: Identifiable, Sendable {
+ let id: String // toolCallId
+ let toolName: String
+ var arguments: String
+ var status: String // "running" | "success" | "error"
+ var output: JSONValue?
+}
+
+/// Main orchestrator for the Omega agentic chat loop.
+/// Coordinates between OpenAI, TPMJS registry, and SwiftData persistence.
+@MainActor
+@Observable
+final class ChatOrchestrator {
+ // MARK: - Published State
+
+ var streamingContent: String = ""
+ var isStreaming: Bool = false
+ var liveToolCalls: [LiveToolCall] = []
+ var error: String?
+
+ // MARK: - Private State
+
+ private let openAI = OpenAIService()
+ private let registry = TPMJSRegistryService()
+
+ /// Dynamically loaded tools for the current conversation (sanitizedName -> ToolMeta)
+ private var loadedTools: [String: ToolMeta] = [:]
+
+ /// Maximum agentic loop iterations (search -> execute -> respond)
+ private let maxIterations = 10
+
+ // MARK: - Public API
+
+ /// Send a user message and run the full agentic loop.
+ /// Streams the response, handles tool calls, and persists everything to SwiftData.
+ func sendMessage(
+ _ text: String,
+ conversation: Conversation,
+ modelContext: ModelContext
+ ) async {
+ // Reset state
+ streamingContent = ""
+ isStreaming = true
+ liveToolCalls = []
+ error = nil
+
+ // Get API key
+ guard let apiKey = KeychainService.load(key: "OPENAI_API_KEY"), !apiKey.isEmpty else {
+ error = "No OpenAI API key set. Open Settings (Cmd+,) to add your key."
+ isStreaming = false
+ return
+ }
+
+ // Load user settings
+ let settingsDescriptor = FetchDescriptor()
+ let settings = (try? modelContext.fetch(settingsDescriptor))?.first
+
+ let model = settings?.selectedModel ?? "gpt-4.1-mini"
+ let customPrompt = settings?.systemPrompt
+ let pinnedToolIds = settings?.pinnedToolIds ?? []
+
+ // Save user message
+ let userMessage = Message(role: .user, content: text, conversation: conversation)
+ modelContext.insert(userMessage)
+ conversation.updatedAt = Date()
+ conversation.executionState = "running"
+ try? modelContext.save()
+
+ // Load env vars from Keychain
+ let envVarDescriptor = FetchDescriptor()
+ let envVarRecords = (try? modelContext.fetch(envVarDescriptor)) ?? []
+ let envVars = KeychainService.loadAllEnvVars(keyNames: envVarRecords.map(\.keyName))
+
+ // Auto-discover tools via BM25 search
+ do {
+ let relevantTools = try await registry.searchTools(query: text, limit: 10)
+ for toolMeta in relevantTools {
+ let sanitized = sanitizeToolName(toolMeta.toolId)
+ if loadedTools[sanitized] == nil {
+ loadedTools[sanitized] = toolMeta
+ }
+ }
+ } catch {
+ // Non-fatal: continue without auto-discovered tools
+ print("Auto-discovery failed: \(error)")
+ }
+
+ // Build messages array from conversation history
+ var chatMessages = buildChatMessages(
+ conversation: conversation,
+ customPrompt: customPrompt,
+ pinnedToolIds: pinnedToolIds
+ )
+
+ // Add the new user message
+ chatMessages.append(.user(text))
+
+ // Build tools list
+ let tools = buildToolsList()
+
+ // Agentic loop
+ var iteration = 0
+ var allToolCallData: [ToolCallData] = []
+ var allToolResultData: [ToolCallData] = []
+ var totalInputTokens = 0
+ var totalOutputTokens = 0
+
+ while iteration < maxIterations {
+ iteration += 1
+
+ var currentContent = ""
+ var pendingToolCalls: [ChatToolCall] = []
+ var receivedDone = false
+
+ do {
+ let stream = await openAI.streamCompletion(
+ apiKey: apiKey,
+ model: model,
+ messages: chatMessages,
+ tools: tools.isEmpty ? nil : tools
+ )
+
+ for try await event in stream {
+ switch event {
+ case .contentDelta(let delta):
+ currentContent += delta
+ streamingContent = currentContent
+
+ case .toolCallStarted(_, let id, let name):
+ let liveTC = LiveToolCall(
+ id: id,
+ toolName: name,
+ arguments: "",
+ status: "running"
+ )
+ liveToolCalls.append(liveTC)
+
+ case .toolCallArgumentDelta(let index, let delta):
+ if index < liveToolCalls.count {
+ liveToolCalls[index].arguments += delta
+ }
+
+ case .toolCallComplete(let toolCall):
+ pendingToolCalls.append(toolCall)
+
+ case .usage(let input, let output):
+ totalInputTokens += input
+ totalOutputTokens += output
+
+ case .done:
+ receivedDone = true
+
+ case .error(let msg):
+ self.error = msg
+ }
+ }
+ } catch {
+ self.error = error.localizedDescription
+ break
+ }
+
+ // If we got content with no tool calls, we're done
+ if pendingToolCalls.isEmpty {
+ streamingContent = currentContent
+ break
+ }
+
+ // Process tool calls
+ // Add assistant message with tool calls to chat history
+ chatMessages.append(.assistant(
+ content: currentContent.isEmpty ? nil : currentContent,
+ toolCalls: pendingToolCalls
+ ))
+
+ // Execute each tool call
+ for toolCall in pendingToolCalls {
+ let tcData = ToolCallData(
+ toolCallId: toolCall.id,
+ toolName: toolCall.toolName,
+ args: .object(toolCall.parsedArguments)
+ )
+ allToolCallData.append(tcData)
+
+ // Record tool run
+ let record = ToolCallRecord(
+ toolName: toolCall.toolName,
+ toolCallId: toolCall.id,
+ conversation: conversation
+ )
+ record.input = .object(toolCall.parsedArguments)
+ modelContext.insert(record)
+
+ let result = await executeToolCall(
+ toolCall: toolCall,
+ envVars: envVars
+ )
+
+ // Update live tool call status
+ if let idx = liveToolCalls.firstIndex(where: { $0.id == toolCall.id }) {
+ liveToolCalls[idx].status = result.isError ? "error" : "success"
+ liveToolCalls[idx].output = result.output
+ }
+
+ // Update record
+ record.output = result.output
+ record.status = result.isError ? "error" : "success"
+ record.completedAt = Date()
+
+ // Add tool result to chat messages
+ let resultJSON: String
+ if let data = try? JSONEncoder().encode(result.output) {
+ resultJSON = String(data: data, encoding: .utf8) ?? "{}"
+ } else {
+ resultJSON = "{}"
+ }
+
+ chatMessages.append(.toolResult(
+ toolCallId: toolCall.id,
+ name: toolCall.toolName,
+ content: resultJSON
+ ))
+
+ let trData = ToolCallData(
+ toolCallId: toolCall.id,
+ toolName: toolCall.toolName,
+ args: .object(toolCall.parsedArguments),
+ output: result.output
+ )
+ allToolResultData.append(trData)
+ }
+
+ // Reset streaming for next iteration
+ streamingContent = ""
+ liveToolCalls = []
+ }
+
+ // Save assistant message
+ let assistantMessage = Message(
+ role: .assistant,
+ content: streamingContent,
+ conversation: conversation,
+ inputTokens: totalInputTokens,
+ outputTokens: totalOutputTokens,
+ toolCalls: allToolCallData.isEmpty ? nil : allToolCallData
+ )
+ modelContext.insert(assistantMessage)
+
+ // Save tool results as a TOOL message if we had tool calls
+ if !allToolResultData.isEmpty {
+ let toolMessage = Message(
+ role: .tool,
+ content: "Tool results",
+ conversation: conversation,
+ toolCalls: allToolResultData
+ )
+ modelContext.insert(toolMessage)
+ }
+
+ // Update conversation
+ conversation.executionState = "idle"
+ conversation.inputTokensTotal += totalInputTokens
+ conversation.outputTokensTotal += totalOutputTokens
+ conversation.updatedAt = Date()
+
+ // Auto-title from first message
+ if conversation.title == nil {
+ let title = text.count > 50 ? String(text.prefix(50)) + "..." : text
+ conversation.title = title
+ }
+
+ try? modelContext.save()
+
+ isStreaming = false
+ }
+
+ /// Clear loaded tools (when switching conversations)
+ func resetConversation() {
+ loadedTools = [:]
+ streamingContent = ""
+ isStreaming = false
+ liveToolCalls = []
+ error = nil
+ }
+
+ // MARK: - Private Helpers
+
+ private struct ToolResult {
+ let output: JSONValue
+ let isError: Bool
+ }
+
+ private func executeToolCall(
+ toolCall: ChatToolCall,
+ envVars: [String: String]
+ ) async -> ToolResult {
+ let name = toolCall.toolName
+ let args = toolCall.parsedArguments
+
+ // Handle registrySearch
+ if name == "registrySearch" {
+ return await handleRegistrySearch(args: args)
+ }
+
+ // Handle registryExecute
+ if name == "registryExecute" {
+ return await handleRegistryExecute(args: args, envVars: envVars)
+ }
+
+ // Handle dynamic tools (loaded from search)
+ if let toolMeta = loadedTools[name] {
+ return await handleDynamicTool(meta: toolMeta, args: args, envVars: envVars)
+ }
+
+ // Also check by finding the tool ID from the sanitized name
+ if let toolId = findToolId(sanitizedName: name, in: loadedTools),
+ let toolMeta = loadedTools.values.first(where: { $0.toolId == toolId }) {
+ return await handleDynamicTool(meta: toolMeta, args: args, envVars: envVars)
+ }
+
+ return ToolResult(
+ output: .object([
+ "error": .bool(true),
+ "message": .string("Unknown tool: \(name)"),
+ ]),
+ isError: true
+ )
+ }
+
+ private func handleRegistrySearch(args: [String: JSONValue]) async -> ToolResult {
+ guard case .string(let query) = args["query"] else {
+ return ToolResult(
+ output: .object(["error": .bool(true), "message": .string("Missing 'query' parameter")]),
+ isError: true
+ )
+ }
+
+ let limit: Int
+ if case .number(let n) = args["limit"] {
+ limit = Int(n)
+ } else {
+ limit = 5
+ }
+
+ do {
+ let tools = try await registry.searchTools(query: query, limit: limit)
+
+ // Inject found tools into loaded tools
+ for toolMeta in tools {
+ let sanitized = sanitizeToolName(toolMeta.toolId)
+ if loadedTools[sanitized] == nil {
+ loadedTools[sanitized] = toolMeta
+ }
+ }
+
+ let toolsJSON: [JSONValue] = tools.map { t in
+ .object([
+ "toolId": .string(t.toolId),
+ "name": .string(t.name),
+ "package": .string(t.packageName),
+ "description": .string(t.description),
+ ])
+ }
+
+ return ToolResult(
+ output: .object([
+ "query": .string(query),
+ "matchCount": .number(Double(tools.count)),
+ "tools": .array(toolsJSON),
+ ]),
+ isError: false
+ )
+ } catch {
+ return ToolResult(
+ output: .object([
+ "error": .bool(true),
+ "message": .string(error.localizedDescription),
+ ]),
+ isError: true
+ )
+ }
+ }
+
+ private func handleRegistryExecute(
+ args: [String: JSONValue],
+ envVars: [String: String]
+ ) async -> ToolResult {
+ guard case .string(let toolId) = args["toolId"] else {
+ return ToolResult(
+ output: .object(["error": .bool(true), "message": .string("Missing 'toolId' parameter")]),
+ isError: true
+ )
+ }
+
+ let params = args["params"] ?? .object([:])
+
+ do {
+ let response = try await registry.executeByToolId(
+ toolId: toolId,
+ params: params,
+ env: envVars
+ )
+
+ if response.success {
+ return ToolResult(
+ output: .object([
+ "toolId": .string(toolId),
+ "executionTimeMs": .number(Double(response.executionTimeMs ?? 0)),
+ "output": response.output ?? .null,
+ ]),
+ isError: false
+ )
+ } else {
+ return ToolResult(
+ output: .object([
+ "error": .bool(true),
+ "message": .string(response.error ?? "Tool execution failed"),
+ "toolId": .string(toolId),
+ ]),
+ isError: true
+ )
+ }
+ } catch {
+ return ToolResult(
+ output: .object([
+ "error": .bool(true),
+ "message": .string(error.localizedDescription),
+ "toolId": .string(toolId),
+ ]),
+ isError: true
+ )
+ }
+ }
+
+ private func handleDynamicTool(
+ meta: ToolMeta,
+ args: [String: JSONValue],
+ envVars: [String: String]
+ ) async -> ToolResult {
+ do {
+ let response = try await registry.executeTool(
+ packageName: meta.packageName,
+ name: meta.name,
+ version: meta.version,
+ importUrl: meta.importUrl,
+ params: .object(args),
+ env: envVars
+ )
+
+ if response.success {
+ return ToolResult(
+ output: response.output ?? .null,
+ isError: false
+ )
+ } else {
+ return ToolResult(
+ output: .object([
+ "error": .bool(true),
+ "message": .string(response.error ?? "Tool execution failed"),
+ "toolId": .string(meta.toolId),
+ ]),
+ isError: true
+ )
+ }
+ } catch {
+ return ToolResult(
+ output: .object([
+ "error": .bool(true),
+ "message": .string(error.localizedDescription),
+ "toolId": .string(meta.toolId),
+ ]),
+ isError: true
+ )
+ }
+ }
+
+ /// Build chat messages from conversation history
+ private func buildChatMessages(
+ conversation: Conversation,
+ customPrompt: String?,
+ pinnedToolIds: [String]
+ ) -> [ChatMessage] {
+ var messages: [ChatMessage] = []
+
+ // System prompt
+ let systemPrompt = SystemPromptBuilder.build(
+ customSystemPrompt: customPrompt,
+ pinnedToolIds: pinnedToolIds,
+ loadedTools: loadedTools
+ )
+ messages.append(.system(systemPrompt))
+
+ // Last 20 messages from conversation history
+ let sorted = conversation.sortedMessages
+ let recent = sorted.suffix(20)
+
+ for msg in recent {
+ switch msg.role {
+ case .user:
+ messages.append(.user(msg.content))
+
+ case .assistant:
+ let toolCalls = msg.toolCalls
+ if !toolCalls.isEmpty {
+ let chatToolCalls = toolCalls.map { tc in
+ ChatToolCall(
+ id: tc.toolCallId,
+ type: "function",
+ function: ChatToolCallFunction(
+ name: tc.toolName,
+ arguments: {
+ if let args = tc.args,
+ let data = try? JSONEncoder().encode(args) {
+ return String(data: data, encoding: .utf8) ?? "{}"
+ }
+ return "{}"
+ }()
+ )
+ )
+ }
+ messages.append(.assistant(content: msg.content, toolCalls: chatToolCalls))
+ } else {
+ messages.append(.assistant(content: msg.content, toolCalls: nil))
+ }
+
+ case .tool:
+ for tc in msg.toolCalls {
+ let outputJSON: String
+ if let output = tc.output,
+ let data = try? JSONEncoder().encode(output) {
+ outputJSON = String(data: data, encoding: .utf8) ?? "{}"
+ } else {
+ outputJSON = "{}"
+ }
+ messages.append(.toolResult(
+ toolCallId: tc.toolCallId,
+ name: tc.toolName,
+ content: outputJSON
+ ))
+ }
+
+ case .system:
+ break
+ }
+ }
+
+ return messages
+ }
+
+ /// Build the OpenAI tools array from static + dynamic tools
+ private func buildToolsList() -> [ChatTool] {
+ var tools: [ChatTool] = []
+
+ // Static: registrySearch
+ tools.append(ChatTool(
+ function: ChatFunction(
+ name: "registrySearch",
+ description: "Search the TPMJS tool registry to find AI SDK tools. Use this to discover tools for any task. Returns toolIds that can be executed with registryExecute.",
+ parameters: JSONSchemaObject(
+ type: "object",
+ properties: [
+ "query": JSONSchemaProperty(
+ type: "string",
+ description: "Search query (keywords, tool names, descriptions)"
+ ),
+ "limit": JSONSchemaProperty(
+ type: "number",
+ description: "Maximum number of results (1-20, default 5)",
+ minimum: 1,
+ maximum: 20
+ ),
+ ],
+ required: ["query"],
+ additionalProperties: false
+ )
+ )
+ ))
+
+ // Static: registryExecute
+ tools.append(ChatTool(
+ function: ChatFunction(
+ name: "registryExecute",
+ description: "Execute a tool from the TPMJS registry. Use registrySearch first to find the toolId. Tools run in a secure sandbox.",
+ parameters: JSONSchemaObject(
+ type: "object",
+ properties: [
+ "toolId": JSONSchemaProperty(
+ type: "string",
+ description: "Tool identifier from registrySearch (format: 'package::name')"
+ ),
+ "params": JSONSchemaProperty(
+ type: "object",
+ description: "Parameters to pass to the tool",
+ additionalProperties: .bool(true)
+ ),
+ ],
+ required: ["toolId", "params"],
+ additionalProperties: false
+ )
+ )
+ ))
+
+ // Dynamic tools
+ for (_, meta) in loadedTools {
+ tools.append(meta.toChatTool())
+ }
+
+ return tools
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Services/KeychainService.swift b/apps/omega-mac/OmegaMac/Services/KeychainService.swift
new file mode 100644
index 0000000..02627d9
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Services/KeychainService.swift
@@ -0,0 +1,124 @@
+import Foundation
+import Security
+
+/// Wrapper around macOS Keychain for storing API keys and env var values securely.
+enum KeychainService {
+ private static let serviceName = "com.tpmjs.omega-mac"
+
+ /// Save or update a value in the Keychain
+ static func save(key: String, value: String) throws {
+ guard let data = value.data(using: .utf8) else {
+ throw KeychainError.encodingFailed
+ }
+
+ // Check if item exists
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: serviceName,
+ kSecAttrAccount as String: key,
+ ]
+
+ let status = SecItemCopyMatching(query as CFDictionary, nil)
+
+ if status == errSecSuccess {
+ // Update existing
+ let attributes: [String: Any] = [
+ kSecValueData as String: data,
+ ]
+ let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
+ guard updateStatus == errSecSuccess else {
+ throw KeychainError.unhandledError(updateStatus)
+ }
+ } else if status == errSecItemNotFound {
+ // Add new
+ var addQuery = query
+ addQuery[kSecValueData as String] = data
+ let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
+ guard addStatus == errSecSuccess else {
+ throw KeychainError.unhandledError(addStatus)
+ }
+ } else {
+ throw KeychainError.unhandledError(status)
+ }
+ }
+
+ /// Retrieve a value from the Keychain
+ static func load(key: String) -> String? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: serviceName,
+ kSecAttrAccount as String: key,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne,
+ ]
+
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+
+ guard status == errSecSuccess,
+ let data = result as? Data,
+ let value = String(data: data, encoding: .utf8) else {
+ return nil
+ }
+ return value
+ }
+
+ /// Delete a value from the Keychain
+ static func delete(key: String) throws {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: serviceName,
+ kSecAttrAccount as String: key,
+ ]
+
+ let status = SecItemDelete(query as CFDictionary)
+ guard status == errSecSuccess || status == errSecItemNotFound else {
+ throw KeychainError.unhandledError(status)
+ }
+ }
+
+ /// Load all stored keys (returns key names only, not values)
+ static func allKeys() -> [String] {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: serviceName,
+ kSecReturnAttributes as String: true,
+ kSecMatchLimit as String: kSecMatchLimitAll,
+ ]
+
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+
+ guard status == errSecSuccess,
+ let items = result as? [[String: Any]] else {
+ return []
+ }
+
+ return items.compactMap { $0[kSecAttrAccount as String] as? String }
+ }
+
+ /// Convenience: load all env vars as a dictionary
+ static func loadAllEnvVars(keyNames: [String]) -> [String: String] {
+ var result: [String: String] = [:]
+ for key in keyNames {
+ if let value = load(key: key) {
+ result[key] = value
+ }
+ }
+ return result
+ }
+}
+
+enum KeychainError: LocalizedError {
+ case encodingFailed
+ case unhandledError(OSStatus)
+
+ var errorDescription: String? {
+ switch self {
+ case .encodingFailed:
+ return "Failed to encode value for Keychain"
+ case .unhandledError(let status):
+ return "Keychain error: \(status)"
+ }
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Services/OpenAIService.swift b/apps/omega-mac/OmegaMac/Services/OpenAIService.swift
new file mode 100644
index 0000000..2042707
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Services/OpenAIService.swift
@@ -0,0 +1,141 @@
+import Foundation
+
+/// Actor that handles all communication with the OpenAI Chat Completions API.
+/// Supports streaming via Server-Sent Events (SSE).
+actor OpenAIService {
+ private let session: URLSession
+
+ init() {
+ let config = URLSessionConfiguration.default
+ config.timeoutIntervalForRequest = 300
+ config.timeoutIntervalForResource = 300
+ self.session = URLSession(configuration: config)
+ }
+
+ /// Stream a chat completion, yielding parsed events as they arrive.
+ func streamCompletion(
+ apiKey: String,
+ model: String,
+ messages: [ChatMessage],
+ tools: [ChatTool]?
+ ) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ Task {
+ do {
+ let request = try buildRequest(
+ apiKey: apiKey,
+ model: model,
+ messages: messages,
+ tools: tools,
+ stream: true
+ )
+
+ let (bytes, response) = try await session.bytes(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ continuation.yield(.error("Invalid response type"))
+ continuation.finish()
+ return
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ // Try to read error body
+ var errorBody = ""
+ for try await line in bytes.lines {
+ errorBody += line
+ }
+ continuation.yield(.error("API error \(httpResponse.statusCode): \(errorBody)"))
+ continuation.finish()
+ return
+ }
+
+ // Track accumulated tool calls
+ var toolCallAccumulators: [Int: StreamParser.ToolCallAccumulator] = [:]
+
+ for try await line in bytes.lines {
+ let events = StreamParser.parseLine(line)
+ for event in events {
+ switch event {
+ case .toolCallStarted(let index, let id, let name):
+ toolCallAccumulators[index] = StreamParser.ToolCallAccumulator(
+ id: id,
+ name: name,
+ arguments: ""
+ )
+ continuation.yield(event)
+
+ case .toolCallArgumentDelta(let index, let delta):
+ toolCallAccumulators[index]?.arguments += delta
+ continuation.yield(event)
+
+ case .done:
+ // Emit completed tool calls
+ for (_, acc) in toolCallAccumulators.sorted(by: { $0.key < $1.key }) {
+ let toolCall = ChatToolCall(
+ id: acc.id,
+ type: "function",
+ function: ChatToolCallFunction(
+ name: acc.name,
+ arguments: acc.arguments
+ )
+ )
+ continuation.yield(.toolCallComplete(toolCall))
+ }
+ continuation.yield(.done)
+ continuation.finish()
+
+ default:
+ continuation.yield(event)
+ }
+ }
+ }
+
+ // If we reach here without [DONE], still emit completed tool calls
+ if !toolCallAccumulators.isEmpty {
+ for (_, acc) in toolCallAccumulators.sorted(by: { $0.key < $1.key }) {
+ let toolCall = ChatToolCall(
+ id: acc.id,
+ type: "function",
+ function: ChatToolCallFunction(
+ name: acc.name,
+ arguments: acc.arguments
+ )
+ )
+ continuation.yield(.toolCallComplete(toolCall))
+ }
+ }
+ continuation.finish()
+
+ } catch {
+ continuation.yield(.error(error.localizedDescription))
+ continuation.finish(throwing: error)
+ }
+ }
+ }
+ }
+
+ private func buildRequest(
+ apiKey: String,
+ model: String,
+ messages: [ChatMessage],
+ tools: [ChatTool]?,
+ stream: Bool
+ ) throws -> URLRequest {
+ var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
+ request.httpMethod = "POST"
+ request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
+ request.addValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let body = ChatCompletionRequest(
+ model: model,
+ messages: messages,
+ tools: tools?.isEmpty == true ? nil : tools,
+ stream: stream,
+ maxTokens: 4096,
+ streamOptions: stream ? StreamOptions(includeUsage: true) : nil
+ )
+
+ request.httpBody = try JSONEncoder().encode(body)
+ return request
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Services/OpenAITypes.swift b/apps/omega-mac/OmegaMac/Services/OpenAITypes.swift
new file mode 100644
index 0000000..522c75c
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Services/OpenAITypes.swift
@@ -0,0 +1,209 @@
+import Foundation
+
+// MARK: - Request Types
+
+struct ChatCompletionRequest: Encodable {
+ let model: String
+ let messages: [ChatMessage]
+ let tools: [ChatTool]?
+ let stream: Bool
+ let maxTokens: Int?
+ let streamOptions: StreamOptions?
+
+ enum CodingKeys: String, CodingKey {
+ case model, messages, tools, stream
+ case maxTokens = "max_tokens"
+ case streamOptions = "stream_options"
+ }
+}
+
+struct StreamOptions: Encodable {
+ let includeUsage: Bool
+
+ enum CodingKeys: String, CodingKey {
+ case includeUsage = "include_usage"
+ }
+}
+
+struct ChatMessage: Codable {
+ let role: String
+ let content: String?
+ let toolCalls: [ChatToolCall]?
+ let toolCallId: String?
+ let name: String?
+
+ enum CodingKeys: String, CodingKey {
+ case role, content, name
+ case toolCalls = "tool_calls"
+ case toolCallId = "tool_call_id"
+ }
+
+ static func system(_ content: String) -> ChatMessage {
+ ChatMessage(role: "system", content: content, toolCalls: nil, toolCallId: nil, name: nil)
+ }
+
+ static func user(_ content: String) -> ChatMessage {
+ ChatMessage(role: "user", content: content, toolCalls: nil, toolCallId: nil, name: nil)
+ }
+
+ static func assistant(content: String?, toolCalls: [ChatToolCall]?) -> ChatMessage {
+ ChatMessage(role: "assistant", content: content, toolCalls: toolCalls, toolCallId: nil, name: nil)
+ }
+
+ static func toolResult(toolCallId: String, name: String, content: String) -> ChatMessage {
+ ChatMessage(role: "tool", content: content, toolCalls: nil, toolCallId: toolCallId, name: name)
+ }
+}
+
+struct ChatTool: Encodable {
+ let type: String = "function"
+ let function: ChatFunction
+}
+
+struct ChatFunction: Encodable {
+ let name: String
+ let description: String
+ let parameters: JSONSchemaObject
+}
+
+struct JSONSchemaObject: Encodable {
+ let type: String
+ let properties: [String: JSONSchemaProperty]
+ let required: [String]?
+ let additionalProperties: Bool?
+}
+
+struct JSONSchemaProperty: Encodable {
+ let type: String
+ let description: String?
+ let minimum: Int?
+ let maximum: Int?
+ let additionalProperties: JSONSchemaAdditional?
+
+ init(type: String, description: String? = nil, minimum: Int? = nil, maximum: Int? = nil, additionalProperties: JSONSchemaAdditional? = nil) {
+ self.type = type
+ self.description = description
+ self.minimum = minimum
+ self.maximum = maximum
+ self.additionalProperties = additionalProperties
+ }
+}
+
+enum JSONSchemaAdditional: Encodable {
+ case bool(Bool)
+ case typed(JSONSchemaProperty)
+
+ func encode(to encoder: Encoder) throws {
+ var container = encoder.singleValueContainer()
+ switch self {
+ case .bool(let b): try container.encode(b)
+ case .typed(let p): try container.encode(p)
+ }
+ }
+}
+
+struct ChatToolCall: Codable, Identifiable {
+ var id: String
+ let type: String?
+ let function: ChatToolCallFunction?
+
+ var toolName: String { function?.name ?? "" }
+ var arguments: String { function?.arguments ?? "{}" }
+
+ var parsedArguments: [String: JSONValue] {
+ guard let data = arguments.data(using: .utf8),
+ let obj = try? JSONDecoder().decode([String: JSONValue].self, from: data) else {
+ return [:]
+ }
+ return obj
+ }
+}
+
+struct ChatToolCallFunction: Codable {
+ let name: String?
+ let arguments: String?
+}
+
+// MARK: - Response Types (non-streaming)
+
+struct ChatCompletionResponse: Decodable {
+ let id: String
+ let choices: [ChatChoice]
+ let usage: ChatUsage?
+}
+
+struct ChatChoice: Decodable {
+ let index: Int
+ let message: ChatResponseMessage
+ let finishReason: String?
+
+ enum CodingKeys: String, CodingKey {
+ case index, message
+ case finishReason = "finish_reason"
+ }
+}
+
+struct ChatResponseMessage: Decodable {
+ let role: String
+ let content: String?
+ let toolCalls: [ChatToolCall]?
+
+ enum CodingKeys: String, CodingKey {
+ case role, content
+ case toolCalls = "tool_calls"
+ }
+}
+
+struct ChatUsage: Decodable {
+ let promptTokens: Int?
+ let completionTokens: Int?
+ let totalTokens: Int?
+
+ enum CodingKeys: String, CodingKey {
+ case promptTokens = "prompt_tokens"
+ case completionTokens = "completion_tokens"
+ case totalTokens = "total_tokens"
+ }
+}
+
+// MARK: - Streaming Types
+
+struct ChatCompletionChunk: Decodable {
+ let id: String?
+ let choices: [ChunkChoice]?
+ let usage: ChatUsage?
+}
+
+struct ChunkChoice: Decodable {
+ let index: Int?
+ let delta: ChunkDelta?
+ let finishReason: String?
+
+ enum CodingKeys: String, CodingKey {
+ case index, delta
+ case finishReason = "finish_reason"
+ }
+}
+
+struct ChunkDelta: Decodable {
+ let role: String?
+ let content: String?
+ let toolCalls: [ChunkToolCall]?
+
+ enum CodingKeys: String, CodingKey {
+ case role, content
+ case toolCalls = "tool_calls"
+ }
+}
+
+struct ChunkToolCall: Decodable {
+ let index: Int?
+ let id: String?
+ let type: String?
+ let function: ChunkToolCallFunction?
+}
+
+struct ChunkToolCallFunction: Decodable {
+ let name: String?
+ let arguments: String?
+}
diff --git a/apps/omega-mac/OmegaMac/Services/StreamParser.swift b/apps/omega-mac/OmegaMac/Services/StreamParser.swift
new file mode 100644
index 0000000..32ee631
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Services/StreamParser.swift
@@ -0,0 +1,105 @@
+import Foundation
+
+/// Parses Server-Sent Events (SSE) from OpenAI's streaming API.
+/// Handles `data: {...}` lines and `data: [DONE]` termination.
+struct StreamParser {
+
+ /// Accumulated tool call state during streaming
+ struct ToolCallAccumulator {
+ var id: String = ""
+ var name: String = ""
+ var arguments: String = ""
+ }
+
+ /// Result of parsing the stream - yields content deltas and complete tool calls
+ enum StreamEvent: Sendable {
+ case contentDelta(String)
+ case toolCallStarted(index: Int, id: String, name: String)
+ case toolCallArgumentDelta(index: Int, delta: String)
+ case toolCallComplete(ChatToolCall)
+ case usage(inputTokens: Int, outputTokens: Int)
+ case done
+ case error(String)
+ }
+
+ /// Parse a single SSE line and return events
+ static func parseLine(_ line: String) -> [StreamEvent] {
+ let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ // Skip empty lines and comments
+ guard !trimmed.isEmpty, !trimmed.hasPrefix(":") else {
+ return []
+ }
+
+ // Must start with "data: "
+ guard trimmed.hasPrefix("data: ") else {
+ return []
+ }
+
+ let payload = String(trimmed.dropFirst(6))
+
+ // Check for stream end
+ if payload == "[DONE]" {
+ return [.done]
+ }
+
+ // Parse JSON chunk
+ guard let data = payload.data(using: .utf8) else {
+ return [.error("Invalid UTF-8 in SSE payload")]
+ }
+
+ do {
+ let chunk = try JSONDecoder().decode(ChatCompletionChunk.self, from: data)
+ return processChunk(chunk)
+ } catch {
+ return [.error("Failed to parse chunk: \(error.localizedDescription)")]
+ }
+ }
+
+ private static func processChunk(_ chunk: ChatCompletionChunk) -> [StreamEvent] {
+ var events: [StreamEvent] = []
+
+ if let choices = chunk.choices {
+ for choice in choices {
+ guard let delta = choice.delta else { continue }
+
+ // Content delta
+ if let content = delta.content, !content.isEmpty {
+ events.append(.contentDelta(content))
+ }
+
+ // Tool calls
+ if let toolCalls = delta.toolCalls {
+ for tc in toolCalls {
+ let idx = tc.index ?? 0
+ if let id = tc.id, !id.isEmpty {
+ events.append(.toolCallStarted(
+ index: idx,
+ id: id,
+ name: tc.function?.name ?? ""
+ ))
+ }
+ if let args = tc.function?.arguments, !args.isEmpty {
+ events.append(.toolCallArgumentDelta(index: idx, delta: args))
+ }
+ }
+ }
+
+ // Finish reason
+ if choice.finishReason == "stop" || choice.finishReason == "tool_calls" {
+ // Will be handled by [DONE]
+ }
+ }
+ }
+
+ // Usage info (sometimes included in last chunk)
+ if let usage = chunk.usage {
+ events.append(.usage(
+ inputTokens: usage.promptTokens ?? 0,
+ outputTokens: usage.completionTokens ?? 0
+ ))
+ }
+
+ return events
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift b/apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift
new file mode 100644
index 0000000..4a92312
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift
@@ -0,0 +1,156 @@
+import Foundation
+
+/// Actor that handles communication with the TPMJS tool registry API
+/// and the remote executor service.
+actor TPMJSRegistryService {
+ private let session: URLSession
+ private let registryBaseURL: String
+ private let executorBaseURL: String
+
+ init(
+ registryBaseURL: String = "https://tpmjs.com",
+ executorBaseURL: String = "https://executor.tpmjs.com"
+ ) {
+ let config = URLSessionConfiguration.default
+ config.timeoutIntervalForRequest = 60
+ self.session = URLSession(configuration: config)
+ self.registryBaseURL = registryBaseURL
+ self.executorBaseURL = executorBaseURL
+ }
+
+ // MARK: - Search
+
+ /// Search for tools matching a query using BM25
+ func searchTools(query: String, limit: Int = 10) async throws -> [ToolMeta] {
+ var components = URLComponents(string: "\(registryBaseURL)/api/tools/search")!
+ components.queryItems = [
+ URLQueryItem(name: "q", value: query),
+ URLQueryItem(name: "limit", value: String(limit)),
+ ]
+
+ guard let url = components.url else {
+ throw TPMJSError.invalidURL
+ }
+
+ let (data, response) = try await session.data(from: url)
+
+ guard let httpResponse = response as? HTTPURLResponse,
+ httpResponse.statusCode == 200 else {
+ return []
+ }
+
+ let searchResponse = try JSONDecoder().decode(TPMJSSearchResponse.self, from: data)
+ let tools = searchResponse.results?.tools ?? []
+
+ return tools.map { tool in
+ ToolMeta(
+ toolId: "\(tool.package.npmPackageName)::\(tool.name)",
+ packageName: tool.package.npmPackageName,
+ name: tool.name,
+ description: tool.description ?? "Tool: \(tool.name)",
+ version: tool.package.npmVersion,
+ importUrl: "https://esm.sh/\(tool.package.npmPackageName)@\(tool.package.npmVersion)",
+ inputSchema: tool.inputSchema,
+ env: tool.package.env
+ )
+ }
+ }
+
+ // MARK: - Execute via Executor
+
+ /// Execute a tool via the TPMJS remote sandbox executor
+ func executeTool(
+ packageName: String,
+ name: String,
+ version: String,
+ importUrl: String,
+ params: JSONValue,
+ env: [String: String]
+ ) async throws -> TPMJSExecuteResponse {
+ guard let url = URL(string: "\(executorBaseURL)/execute-tool") else {
+ throw TPMJSError.invalidURL
+ }
+
+ var request = URLRequest(url: url)
+ request.httpMethod = "POST"
+ request.addValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ let body = TPMJSExecuteRequest(
+ packageName: packageName,
+ name: name,
+ version: version,
+ importUrl: importUrl,
+ params: params,
+ env: env
+ )
+
+ request.httpBody = try JSONEncoder().encode(body)
+
+ let (data, response) = try await session.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse,
+ httpResponse.statusCode == 200 else {
+ let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
+ throw TPMJSError.httpError(statusCode)
+ }
+
+ return try JSONDecoder().decode(TPMJSExecuteResponse.self, from: data)
+ }
+
+ // MARK: - Registry Execute (uses search first to find metadata)
+
+ /// Execute a tool by its toolId (package::name format).
+ /// Fetches metadata first via search, then executes via executor.
+ func executeByToolId(
+ toolId: String,
+ params: JSONValue,
+ env: [String: String]
+ ) async throws -> TPMJSExecuteResponse {
+ // Parse toolId format: "package::name"
+ guard let separatorIndex = toolId.range(of: "::", options: .backwards) else {
+ throw TPMJSError.invalidToolId(toolId)
+ }
+
+ let packageName = String(toolId[toolId.startIndex.. ChatTool {
+ let properties: [String: JSONSchemaProperty]
+ let required: [String]?
+
+ if case .object(let schemaObj) = inputSchema {
+ // Extract properties from schema
+ var props: [String: JSONSchemaProperty] = [:]
+ var reqs: [String] = []
+
+ if case .object(let propsObj) = schemaObj["properties"] {
+ for (key, value) in propsObj {
+ if case .object(let propDef) = value {
+ let typeStr: String
+ if case .string(let t) = propDef["type"] {
+ typeStr = t
+ } else {
+ typeStr = "string"
+ }
+ let desc: String?
+ if case .string(let d) = propDef["description"] {
+ desc = d
+ } else {
+ desc = nil
+ }
+ props[key] = JSONSchemaProperty(type: typeStr, description: desc)
+ }
+ }
+ }
+
+ if case .array(let reqArr) = schemaObj["required"] {
+ for item in reqArr {
+ if case .string(let s) = item {
+ reqs.append(s)
+ }
+ }
+ }
+
+ properties = props
+ required = reqs.isEmpty ? nil : reqs
+ } else {
+ properties = [:]
+ required = nil
+ }
+
+ return ChatTool(
+ function: ChatFunction(
+ name: sanitizeToolName(toolId),
+ description: description,
+ parameters: JSONSchemaObject(
+ type: "object",
+ properties: properties,
+ required: required,
+ additionalProperties: true
+ )
+ )
+ )
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Utilities/SanitizeToolName.swift b/apps/omega-mac/OmegaMac/Utilities/SanitizeToolName.swift
new file mode 100644
index 0000000..391d806
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Utilities/SanitizeToolName.swift
@@ -0,0 +1,40 @@
+import Foundation
+
+/// Sanitize a tool ID to be a valid OpenAI function name.
+/// Port of the web's sanitizeToolName logic.
+/// OpenAI requires tool names to be <= 64 characters and match [a-zA-Z0-9_-].
+func sanitizeToolName(_ name: String) -> String {
+ var sanitized = name
+ .replacingOccurrences(of: "@", with: "")
+ .replacingOccurrences(of: "/", with: "_")
+ .replacingOccurrences(of: "-", with: "_")
+ .replacingOccurrences(of: "::", with: "_")
+
+ // Remove any remaining invalid characters
+ sanitized = String(sanitized.unicodeScalars.filter { scalar in
+ CharacterSet.alphanumerics.contains(scalar) || scalar == "_"
+ })
+
+ // OpenAI API requires tool names <= 64 characters
+ if sanitized.count <= 64 {
+ return sanitized
+ }
+
+ // Truncate but try to keep the meaningful part (tool name at the end)
+ let last64 = String(sanitized.suffix(64))
+ if let first = last64.first, first.isLetter {
+ return last64
+ }
+ return String(sanitized.prefix(64))
+}
+
+/// Reverse lookup: find the original toolId from a sanitized name
+/// by checking against loaded tool metadata.
+func findToolId(sanitizedName: String, in tools: [String: ToolMeta]) -> String? {
+ for (_, meta) in tools {
+ if sanitizeToolName(meta.toolId) == sanitizedName {
+ return meta.toolId
+ }
+ }
+ return nil
+}
diff --git a/apps/omega-mac/OmegaMac/Utilities/SystemPromptBuilder.swift b/apps/omega-mac/OmegaMac/Utilities/SystemPromptBuilder.swift
new file mode 100644
index 0000000..0177b07
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Utilities/SystemPromptBuilder.swift
@@ -0,0 +1,134 @@
+import Foundation
+
+/// Builds the system prompt for the Omega agent.
+/// Port of the web's buildSystemPrompt logic from system-prompt.ts.
+enum SystemPromptBuilder {
+
+ static let basePrompt = """
+ You are Omega, an AI assistant powered by the TPMJS tool registry - a collection of 1M+ AI-ready tools.
+
+ ## Core Tools
+
+ You have access to two powerful meta-tools that give you access to the entire TPMJS registry:
+
+ 1. **registrySearch** - Search for tools by keyword, category, or description
+ 2. **registryExecute** - Execute any tool by its toolId
+
+ These tools are importable by users into their own AI agents via:
+ ```typescript
+ import { registrySearchTool } from '@tpmjs/registry-search';
+ import { registryExecuteTool } from '@tpmjs/registry-execute';
+ ```
+
+ ## How It Works
+
+ 1. When the user asks for something, relevant tools are automatically discovered and loaded
+ 2. You can also explicitly search using registrySearch
+ 3. Once tools are found, you have two options:
+ - Use registryExecute with the toolId to execute any tool
+ - Call dynamically loaded tools directly by their sanitized name
+
+ ## Workflow Examples
+
+ ### Example 1: User wants weather data
+ 1. Call registrySearch({ query: "weather api" })
+ 2. Review the results (toolIds like "@weather-api/sdk::getWeather")
+ 3. Call registryExecute({ toolId: "@weather-api/sdk::getWeather", params: { city: "Tokyo" } })
+ 4. Explain the result to the user
+
+ ### Example 2: Tool already loaded
+ If you see a tool like "weatherapi_sdk_getWeather" in the dynamically loaded tools list, call it directly instead of using registryExecute.
+
+ ## Best Practices
+
+ - **Search first** - If you don't see a relevant tool loaded, use registrySearch
+ - **Execute don't describe** - Actually call tools to get real results
+ - **Handle errors** - If a tool fails, explain and try an alternative
+ - **Be efficient** - If a tool is already loaded, call it directly
+
+ ## Response Style
+
+ - Keep responses concise and helpful
+ - Present tool outputs in a clear, readable format
+ - Tell the user which tool you used
+ - Offer to do more if the user might need it
+
+ Remember: Your value is in EXECUTING tools to get real results, not describing what tools could do.
+ """
+
+ /// Build the complete system prompt with tool listings and user customizations
+ static func build(
+ customSystemPrompt: String?,
+ pinnedToolIds: [String],
+ loadedTools: [String: ToolMeta]
+ ) -> String {
+ var parts: [String] = [basePrompt]
+
+ // Pinned tools
+ if !pinnedToolIds.isEmpty {
+ let pinned = pinnedToolIds.map { "- Tool ID: \($0)" }.joined(separator: "\n")
+ parts.append("""
+ ## Pinned Tools
+
+ The user has pinned the following tools as favorites. Consider using these first when they match the task:
+ \(pinned)
+ """)
+ }
+
+ // Custom system prompt
+ if let custom = customSystemPrompt, !custom.isEmpty {
+ parts.append("""
+ ## User Instructions
+
+ The user has provided the following custom instructions:
+
+ \(custom)
+ """)
+ }
+
+ // Static tools
+ let staticToolsList = """
+ - registrySearch: Search the TPMJS registry to find AI SDK tools by keyword. Returns toolIds for registryExecute.
+ - registryExecute: Execute any tool from the TPMJS registry by toolId. Use registrySearch first to find tools.
+ """
+
+ parts.append("""
+ ## Static Tools (Always Available)
+
+ These tools let you access the entire TPMJS registry of 1M+ tools:
+
+ \(staticToolsList)
+ """)
+
+ // Dynamic tools
+ let dynamicToolsList: String
+ if loadedTools.isEmpty {
+ dynamicToolsList = "No tools loaded yet. Use registrySearch to find tools, or they will be auto-loaded based on your requests."
+ } else {
+ dynamicToolsList = loadedTools.map { (name, meta) in
+ "- \(name): \(meta.description)"
+ }.joined(separator: "\n")
+ }
+
+ parts.append("""
+ ## Dynamically Loaded Tools
+
+ These tools have been discovered and loaded for this conversation. Call them directly:
+
+ \(dynamicToolsList)
+ """)
+
+ // Usage instructions
+ parts.append("""
+ ## How to Use Tools
+
+ 1. **To find a tool**: Use registrySearch with a keyword (e.g., "weather", "web scraping", "database")
+ 2. **To execute a found tool**: Use registryExecute with the toolId returned from search
+ 3. **Direct execution**: If a tool is already loaded above, call it directly by name
+
+ Remember: Your value is in EXECUTING tools to get real results, not just describing what tools could do.
+ """)
+
+ return parts.joined(separator: "\n\n")
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift b/apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift
new file mode 100644
index 0000000..8697733
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift
@@ -0,0 +1,54 @@
+import SwiftUI
+
+struct ChatInputBar: View {
+ @Binding var text: String
+ let isStreaming: Bool
+ let onSend: () -> Void
+
+ var body: some View {
+ VStack(spacing: 4) {
+ Divider()
+
+ HStack(alignment: .bottom, spacing: 8) {
+ TextEditor(text: $text)
+ .font(.body)
+ .scrollContentBackground(.hidden)
+ .padding(8)
+ .background(.quaternary.opacity(0.3))
+ .clipShape(RoundedRectangle(cornerRadius: 10))
+ .frame(minHeight: 40, maxHeight: 160)
+ .fixedSize(horizontal: false, vertical: true)
+ .onKeyPress(.return, phases: .down) { keyPress in
+ if keyPress.modifiers.contains(.shift) {
+ return .ignored // Let shift+enter add newline
+ }
+ if canSend {
+ onSend()
+ return .handled
+ }
+ return .ignored
+ }
+
+ Button(action: onSend) {
+ Image(systemName: "arrow.up.circle.fill")
+ .font(.title2)
+ .foregroundStyle(canSend ? .accent : .tertiary)
+ }
+ .buttonStyle(.borderless)
+ .disabled(!canSend)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+
+ Text("Enter to send, Shift+Enter for new line")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ .padding(.bottom, 4)
+ }
+ .background(.background)
+ }
+
+ private var canSend: Bool {
+ !isStreaming && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift b/apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift
new file mode 100644
index 0000000..6a3707a
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift
@@ -0,0 +1,68 @@
+import SwiftData
+import SwiftUI
+
+struct ChatView: View {
+ @Bindable var conversation: Conversation
+ var orchestrator: ChatOrchestrator
+ @Environment(\.modelContext) private var modelContext
+
+ @State private var inputText: String = ""
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Messages
+ MessageList(
+ conversation: conversation,
+ streamingContent: orchestrator.streamingContent,
+ isStreaming: orchestrator.isStreaming,
+ liveToolCalls: orchestrator.liveToolCalls
+ )
+
+ // Error banner
+ if let error = orchestrator.error {
+ HStack {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.red)
+ Text(error)
+ .font(.callout)
+ .foregroundStyle(.red)
+ Spacer()
+ Button("Dismiss") {
+ orchestrator.error = nil
+ }
+ .buttonStyle(.borderless)
+ .font(.callout)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 8)
+ .background(.red.opacity(0.1))
+ }
+
+ // Input bar
+ ChatInputBar(
+ text: $inputText,
+ isStreaming: orchestrator.isStreaming,
+ onSend: sendMessage
+ )
+ }
+ .navigationTitle(conversation.displayTitle)
+ .toolbar {
+ ToolbarItem(placement: .automatic) {
+ if orchestrator.isStreaming {
+ ProgressView()
+ .controlSize(.small)
+ }
+ }
+ }
+ }
+
+ private func sendMessage() {
+ let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !text.isEmpty else { return }
+ inputText = ""
+
+ Task {
+ await orchestrator.sendMessage(text, conversation: conversation, modelContext: modelContext)
+ }
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Views/Chat/MessageBubble.swift b/apps/omega-mac/OmegaMac/Views/Chat/MessageBubble.swift
new file mode 100644
index 0000000..0e61f81
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Views/Chat/MessageBubble.swift
@@ -0,0 +1,69 @@
+import SwiftUI
+
+struct MessageBubble: View {
+ let message: Message
+
+ var body: some View {
+ switch message.role {
+ case .user:
+ userBubble
+ case .assistant:
+ assistantBubble
+ case .tool:
+ toolResultsBubble
+ case .system:
+ EmptyView()
+ }
+ }
+
+ private var userBubble: some View {
+ HStack(alignment: .top) {
+ Spacer(minLength: 60)
+ Text(message.content)
+ .font(.body)
+ .foregroundStyle(.white)
+ .padding(12)
+ .background(.accent)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ .textSelection(.enabled)
+ }
+ .padding(.horizontal, 16)
+ }
+
+ private var assistantBubble: some View {
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 4) {
+ if !message.content.isEmpty {
+ MarkdownView(content: message.content)
+ }
+
+ // Token usage
+ if let input = message.inputTokens, let output = message.outputTokens {
+ Text("In: \(input) | Out: \(output)")
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundStyle(.tertiary)
+ .padding(.top, 4)
+ }
+ }
+ .padding(12)
+ .background(.quaternary.opacity(0.5))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ .textSelection(.enabled)
+
+ Spacer(minLength: 60)
+ }
+ .padding(.horizontal, 16)
+ }
+
+ private var toolResultsBubble: some View {
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 8) {
+ ForEach(message.toolCalls) { tc in
+ JSONToolResultView(toolCallData: tc)
+ }
+ }
+ Spacer(minLength: 60)
+ }
+ .padding(.horizontal, 16)
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift b/apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift
new file mode 100644
index 0000000..5caba91
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift
@@ -0,0 +1,95 @@
+import SwiftUI
+
+struct MessageList: View {
+ let conversation: Conversation
+ let streamingContent: String
+ let isStreaming: Bool
+ let liveToolCalls: [LiveToolCall]
+
+ var body: some View {
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(spacing: 12) {
+ ForEach(conversation.sortedMessages) { message in
+ MessageBubble(message: message)
+ .id(message.id)
+ }
+
+ // Live tool calls
+ ForEach(liveToolCalls) { tc in
+ ToolCallView(toolCall: tc)
+ .id("live-tc-\(tc.id)")
+ }
+
+ // Streaming content
+ if !streamingContent.isEmpty {
+ HStack(alignment: .top) {
+ assistantBubble(content: streamingContent, isStreaming: true)
+ Spacer(minLength: 60)
+ }
+ .padding(.horizontal, 16)
+ .id("streaming")
+ }
+
+ // Thinking indicator
+ if isStreaming && streamingContent.isEmpty && liveToolCalls.isEmpty {
+ HStack {
+ StreamingIndicator()
+ Spacer()
+ }
+ .padding(.horizontal, 16)
+ .id("thinking")
+ }
+
+ // Bottom spacer for scroll padding
+ Color.clear.frame(height: 8)
+ .id("bottom")
+ }
+ .padding(.vertical, 12)
+ }
+ .onChange(of: streamingContent) {
+ withAnimation(.easeOut(duration: 0.15)) {
+ proxy.scrollTo("bottom", anchor: .bottom)
+ }
+ }
+ .onChange(of: conversation.messages.count) {
+ withAnimation(.easeOut(duration: 0.15)) {
+ proxy.scrollTo("bottom", anchor: .bottom)
+ }
+ }
+ .onChange(of: liveToolCalls.count) {
+ withAnimation(.easeOut(duration: 0.15)) {
+ proxy.scrollTo("bottom", anchor: .bottom)
+ }
+ }
+ }
+ }
+
+ private func assistantBubble(content: String, isStreaming: Bool) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ MarkdownView(content: content)
+
+ if isStreaming {
+ Rectangle()
+ .fill(.accent)
+ .frame(width: 2, height: 16)
+ .opacity(0.8)
+ .modifier(PulseAnimation())
+ }
+ }
+ .padding(12)
+ .background(.quaternary.opacity(0.5))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ }
+}
+
+private struct PulseAnimation: ViewModifier {
+ @State private var isAnimating = false
+
+ func body(content: Content) -> some View {
+ content
+ .opacity(isAnimating ? 0.3 : 1.0)
+ .animation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true), value: isAnimating)
+ .onAppear { isAnimating = true }
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Views/Chat/StreamingIndicator.swift b/apps/omega-mac/OmegaMac/Views/Chat/StreamingIndicator.swift
new file mode 100644
index 0000000..2ee907e
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Views/Chat/StreamingIndicator.swift
@@ -0,0 +1,24 @@
+import SwiftUI
+
+struct StreamingIndicator: View {
+ @State private var dotCount = 0
+ private let timer = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
+
+ var body: some View {
+ HStack(spacing: 6) {
+ Image(systemName: "sparkles")
+ .foregroundStyle(.accent)
+ .font(.caption)
+
+ Text("Omega is thinking" + String(repeating: ".", count: dotCount))
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+ .padding(12)
+ .background(.quaternary.opacity(0.5))
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ .onReceive(timer) { _ in
+ dotCount = (dotCount + 1) % 4
+ }
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Views/ContentView.swift b/apps/omega-mac/OmegaMac/Views/ContentView.swift
new file mode 100644
index 0000000..95d12e9
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Views/ContentView.swift
@@ -0,0 +1,39 @@
+import SwiftData
+import SwiftUI
+
+struct ContentView: View {
+ @Environment(\.modelContext) private var modelContext
+ @State private var selectedConversation: Conversation?
+ @State private var orchestrator = ChatOrchestrator()
+
+ var body: some View {
+ NavigationSplitView {
+ SidebarView(
+ selectedConversation: $selectedConversation,
+ onNewConversation: createNewConversation
+ )
+ .navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 340)
+ } detail: {
+ if let conversation = selectedConversation {
+ ChatView(conversation: conversation, orchestrator: orchestrator)
+ } else {
+ EmptyStateView(onNewConversation: createNewConversation)
+ }
+ }
+ .navigationSplitViewStyle(.balanced)
+ .onChange(of: selectedConversation) {
+ orchestrator.resetConversation()
+ }
+ .onReceive(NotificationCenter.default.publisher(for: .newConversation)) { _ in
+ createNewConversation()
+ }
+ }
+
+ private func createNewConversation() {
+ let conversation = Conversation()
+ modelContext.insert(conversation)
+ try? modelContext.save()
+ selectedConversation = conversation
+ orchestrator.resetConversation()
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Views/Settings/APIKeySettings.swift b/apps/omega-mac/OmegaMac/Views/Settings/APIKeySettings.swift
new file mode 100644
index 0000000..8e0abf0
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Views/Settings/APIKeySettings.swift
@@ -0,0 +1,129 @@
+import SwiftData
+import SwiftUI
+
+struct APIKeySettings: View {
+ @State private var apiKey: String = ""
+ @State private var hasKey: Bool = false
+ @State private var showKey: Bool = false
+ @State private var saveStatus: String?
+
+ @Query private var settingsArray: [UserSettings]
+ @Environment(\.modelContext) private var modelContext
+
+ private var settings: UserSettings {
+ if let existing = settingsArray.first {
+ return existing
+ }
+ let s = UserSettings()
+ modelContext.insert(s)
+ try? modelContext.save()
+ return s
+ }
+
+ @State private var selectedModel: String = "gpt-4.1-mini"
+
+ private let availableModels = [
+ "gpt-4.1-mini",
+ "gpt-4.1",
+ "gpt-4.1-nano",
+ "gpt-4o",
+ "gpt-4o-mini",
+ "o4-mini",
+ ]
+
+ var body: some View {
+ Form {
+ Section("OpenAI API Key") {
+ HStack {
+ if showKey {
+ TextField("sk-...", text: $apiKey)
+ .font(.system(.body, design: .monospaced))
+ } else {
+ SecureField("sk-...", text: $apiKey)
+ .font(.system(.body, design: .monospaced))
+ }
+
+ Button {
+ showKey.toggle()
+ } label: {
+ Image(systemName: showKey ? "eye.slash" : "eye")
+ }
+ .buttonStyle(.borderless)
+ }
+
+ HStack {
+ Button("Save Key") {
+ saveAPIKey()
+ }
+ .disabled(apiKey.isEmpty)
+
+ if hasKey {
+ Button("Remove Key", role: .destructive) {
+ removeAPIKey()
+ }
+ }
+
+ Spacer()
+
+ if let status = saveStatus {
+ Text(status)
+ .font(.caption)
+ .foregroundStyle(status.contains("Error") ? .red : .green)
+ }
+ }
+
+ if hasKey {
+ Label("API key is stored securely in Keychain", systemImage: "lock.shield")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Section("Model") {
+ Picker("Model", selection: $selectedModel) {
+ ForEach(availableModels, id: \.self) { model in
+ Text(model).tag(model)
+ }
+ }
+ .onChange(of: selectedModel) { _, newValue in
+ settings.selectedModel = newValue
+ try? modelContext.save()
+ }
+ }
+ }
+ .formStyle(.grouped)
+ .padding()
+ .onAppear {
+ hasKey = KeychainService.load(key: "OPENAI_API_KEY") != nil
+ selectedModel = settings.selectedModel
+ }
+ }
+
+ private func saveAPIKey() {
+ do {
+ try KeychainService.save(key: "OPENAI_API_KEY", value: apiKey)
+ hasKey = true
+ apiKey = ""
+ saveStatus = "Saved"
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
+ saveStatus = nil
+ }
+ } catch {
+ saveStatus = "Error: \(error.localizedDescription)"
+ }
+ }
+
+ private func removeAPIKey() {
+ do {
+ try KeychainService.delete(key: "OPENAI_API_KEY")
+ hasKey = false
+ apiKey = ""
+ saveStatus = "Removed"
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
+ saveStatus = nil
+ }
+ } catch {
+ saveStatus = "Error: \(error.localizedDescription)"
+ }
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Views/Settings/EnvVarsSettings.swift b/apps/omega-mac/OmegaMac/Views/Settings/EnvVarsSettings.swift
new file mode 100644
index 0000000..5fca261
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Views/Settings/EnvVarsSettings.swift
@@ -0,0 +1,131 @@
+import SwiftData
+import SwiftUI
+
+struct EnvVarsSettings: View {
+ @Query(sort: \EnvVar.keyName) private var envVars: [EnvVar]
+ @Environment(\.modelContext) private var modelContext
+
+ @State private var newKeyName: String = ""
+ @State private var newKeyValue: String = ""
+ @State private var errorMessage: String?
+
+ var body: some View {
+ Form {
+ Section("Stored Environment Variables") {
+ if envVars.isEmpty {
+ Text("No environment variables configured.")
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ .padding(.vertical, 4)
+ } else {
+ ForEach(envVars) { envVar in
+ HStack {
+ VStack(alignment: .leading) {
+ Text(envVar.keyName)
+ .font(.system(.body, design: .monospaced))
+ Text("....\(envVar.valueHint)")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ Spacer()
+
+ Button(role: .destructive) {
+ deleteEnvVar(envVar)
+ } label: {
+ Image(systemName: "trash")
+ .foregroundStyle(.red)
+ }
+ .buttonStyle(.borderless)
+ }
+ }
+ }
+ }
+
+ Section("Add New") {
+ TextField("Key name (e.g., WEATHER_API_KEY)", text: $newKeyName)
+ .font(.system(.body, design: .monospaced))
+
+ SecureField("Value", text: $newKeyValue)
+ .font(.system(.body, design: .monospaced))
+
+ HStack {
+ Button("Add") {
+ addEnvVar()
+ }
+ .disabled(newKeyName.isEmpty || newKeyValue.isEmpty)
+
+ if let error = errorMessage {
+ Text(error)
+ .font(.caption)
+ .foregroundStyle(.red)
+ }
+ }
+ }
+
+ Section {
+ Label(
+ "Values are stored in macOS Keychain. Only key names are visible in the app.",
+ systemImage: "lock.shield"
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ Label(
+ "All environment variables are passed to tool executions automatically.",
+ systemImage: "info.circle"
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .formStyle(.grouped)
+ .padding()
+ }
+
+ private func addEnvVar() {
+ let name = newKeyName.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
+ let value = newKeyValue
+
+ guard !name.isEmpty, !value.isEmpty else { return }
+
+ // Check for duplicates
+ if envVars.contains(where: { $0.keyName == name }) {
+ // Update existing
+ do {
+ try KeychainService.save(key: name, value: value)
+ if let existing = envVars.first(where: { $0.keyName == name }) {
+ existing.valueHint = String(value.suffix(4))
+ }
+ try? modelContext.save()
+ newKeyName = ""
+ newKeyValue = ""
+ errorMessage = nil
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ return
+ }
+
+ do {
+ try KeychainService.save(key: name, value: value)
+
+ let hint = String(value.suffix(4))
+ let envVar = EnvVar(keyName: name, valueHint: hint)
+ modelContext.insert(envVar)
+ try? modelContext.save()
+
+ newKeyName = ""
+ newKeyValue = ""
+ errorMessage = nil
+ } catch {
+ errorMessage = error.localizedDescription
+ }
+ }
+
+ private func deleteEnvVar(_ envVar: EnvVar) {
+ try? KeychainService.delete(key: envVar.keyName)
+ modelContext.delete(envVar)
+ try? modelContext.save()
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Views/Settings/SettingsView.swift b/apps/omega-mac/OmegaMac/Views/Settings/SettingsView.swift
new file mode 100644
index 0000000..e23b7bc
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Views/Settings/SettingsView.swift
@@ -0,0 +1,23 @@
+import SwiftUI
+
+struct SettingsView: View {
+ var body: some View {
+ TabView {
+ APIKeySettings()
+ .tabItem {
+ Label("API Key", systemImage: "key")
+ }
+
+ EnvVarsSettings()
+ .tabItem {
+ Label("Environment", systemImage: "server.rack")
+ }
+
+ SystemPromptSettings()
+ .tabItem {
+ Label("System Prompt", systemImage: "text.bubble")
+ }
+ }
+ .frame(width: 520, height: 420)
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Views/Settings/SystemPromptSettings.swift b/apps/omega-mac/OmegaMac/Views/Settings/SystemPromptSettings.swift
new file mode 100644
index 0000000..f1a6907
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Views/Settings/SystemPromptSettings.swift
@@ -0,0 +1,73 @@
+import SwiftData
+import SwiftUI
+
+struct SystemPromptSettings: View {
+ @Query private var settingsArray: [UserSettings]
+ @Environment(\.modelContext) private var modelContext
+
+ @State private var promptText: String = ""
+ @State private var saved: Bool = false
+
+ private var settings: UserSettings {
+ if let existing = settingsArray.first {
+ return existing
+ }
+ let s = UserSettings()
+ modelContext.insert(s)
+ try? modelContext.save()
+ return s
+ }
+
+ var body: some View {
+ Form {
+ Section("Custom System Prompt") {
+ TextEditor(text: $promptText)
+ .font(.system(.body, design: .monospaced))
+ .frame(minHeight: 200)
+ .scrollContentBackground(.hidden)
+ .padding(4)
+ .background(.quaternary.opacity(0.3))
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+
+ HStack {
+ Button("Save") {
+ settings.systemPrompt = promptText.isEmpty ? nil : promptText
+ try? modelContext.save()
+ saved = true
+ DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
+ saved = false
+ }
+ }
+
+ Button("Reset to Default") {
+ promptText = ""
+ settings.systemPrompt = nil
+ try? modelContext.save()
+ }
+
+ Spacer()
+
+ if saved {
+ Text("Saved")
+ .font(.caption)
+ .foregroundStyle(.green)
+ }
+ }
+ }
+
+ Section {
+ Label(
+ "Custom instructions are appended to the default Omega system prompt. Leave empty to use the default.",
+ systemImage: "info.circle"
+ )
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .formStyle(.grouped)
+ .padding()
+ .onAppear {
+ promptText = settings.systemPrompt ?? ""
+ }
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Views/Shared/EmptyStateView.swift b/apps/omega-mac/OmegaMac/Views/Shared/EmptyStateView.swift
new file mode 100644
index 0000000..61338e0
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Views/Shared/EmptyStateView.swift
@@ -0,0 +1,52 @@
+import SwiftUI
+
+struct EmptyStateView: View {
+ let onNewConversation: () -> Void
+
+ var body: some View {
+ VStack(spacing: 20) {
+ Image(systemName: "star.circle.fill")
+ .font(.system(size: 56))
+ .foregroundStyle(.accent)
+ .symbolEffect(.pulse, options: .repeating)
+
+ Text("Omega")
+ .font(.largeTitle.bold())
+
+ Text("AI assistant powered by 1M+ tools from the TPMJS registry")
+ .font(.body)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+ .frame(maxWidth: 400)
+
+ Button(action: onNewConversation) {
+ Label("New Conversation", systemImage: "plus")
+ .font(.headline)
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.large)
+ .keyboardShortcut("n", modifiers: .command)
+
+ VStack(alignment: .leading, spacing: 8) {
+ featureRow(icon: "magnifyingglass", text: "Search 1M+ tools by keyword")
+ featureRow(icon: "play.circle", text: "Execute tools in a secure sandbox")
+ featureRow(icon: "bolt", text: "Auto-discovers relevant tools")
+ featureRow(icon: "key", text: "Securely store API keys in Keychain")
+ }
+ .padding(.top, 8)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(.background)
+ }
+
+ private func featureRow(icon: String, text: String) -> some View {
+ HStack(spacing: 10) {
+ Image(systemName: icon)
+ .frame(width: 20)
+ .foregroundStyle(.accent)
+ Text(text)
+ .font(.callout)
+ .foregroundStyle(.secondary)
+ }
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Views/Shared/MarkdownView.swift b/apps/omega-mac/OmegaMac/Views/Shared/MarkdownView.swift
new file mode 100644
index 0000000..041577e
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Views/Shared/MarkdownView.swift
@@ -0,0 +1,79 @@
+import MarkdownUI
+import SwiftUI
+
+/// Renders markdown content from assistant responses using MarkdownUI.
+struct MarkdownView: View {
+ let content: String
+
+ var body: some View {
+ Markdown(content)
+ .markdownTheme(.omega)
+ .textSelection(.enabled)
+ }
+}
+
+// MARK: - Custom Markdown Theme
+
+extension MarkdownUI.Theme {
+ static let omega = Theme()
+ .text {
+ ForegroundColor(.primary)
+ FontSize(14)
+ }
+ .code {
+ FontFamilyVariant(.monospaced)
+ FontSize(12)
+ ForegroundColor(.secondary)
+ }
+ .codeBlock { configuration in
+ configuration.label
+ .markdownTextStyle {
+ FontFamilyVariant(.monospaced)
+ FontSize(12)
+ ForegroundColor(.secondary)
+ }
+ .padding(10)
+ .background(Color.black.opacity(0.2))
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ }
+ .link {
+ ForegroundColor(.accentColor)
+ }
+ .heading1 { configuration in
+ configuration.label
+ .markdownTextStyle {
+ FontWeight(.bold)
+ FontSize(20)
+ }
+ .markdownMargin(top: 16, bottom: 8)
+ }
+ .heading2 { configuration in
+ configuration.label
+ .markdownTextStyle {
+ FontWeight(.semibold)
+ FontSize(17)
+ }
+ .markdownMargin(top: 12, bottom: 6)
+ }
+ .heading3 { configuration in
+ configuration.label
+ .markdownTextStyle {
+ FontWeight(.semibold)
+ FontSize(15)
+ }
+ .markdownMargin(top: 10, bottom: 4)
+ }
+ .blockquote { configuration in
+ HStack(spacing: 0) {
+ Rectangle()
+ .fill(Color.accentColor.opacity(0.4))
+ .frame(width: 3)
+ configuration.label
+ .markdownTextStyle {
+ ForegroundColor(.secondary)
+ FontSize(13)
+ }
+ .padding(.leading, 10)
+ }
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift b/apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift
new file mode 100644
index 0000000..79fd307
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift
@@ -0,0 +1,25 @@
+import SwiftUI
+
+struct ConversationRow: View {
+ let conversation: Conversation
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(conversation.displayTitle)
+ .font(.system(.body, design: .default))
+ .lineLimit(1)
+ .foregroundStyle(.primary)
+
+ Text(timeAgo(conversation.updatedAt))
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .padding(.vertical, 2)
+ }
+
+ private func timeAgo(_ date: Date) -> String {
+ let formatter = RelativeDateTimeFormatter()
+ formatter.unitsStyle = .abbreviated
+ return formatter.localizedString(for: date, relativeTo: Date())
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Views/Sidebar/SidebarView.swift b/apps/omega-mac/OmegaMac/Views/Sidebar/SidebarView.swift
new file mode 100644
index 0000000..d675258
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Views/Sidebar/SidebarView.swift
@@ -0,0 +1,52 @@
+import SwiftData
+import SwiftUI
+
+struct SidebarView: View {
+ @Binding var selectedConversation: Conversation?
+ let onNewConversation: () -> Void
+
+ @Query(sort: \Conversation.updatedAt, order: .reverse)
+ private var conversations: [Conversation]
+
+ @Environment(\.modelContext) private var modelContext
+
+ var body: some View {
+ List(selection: $selectedConversation) {
+ ForEach(conversations) { conversation in
+ ConversationRow(conversation: conversation)
+ .tag(conversation)
+ .contextMenu {
+ Button("Delete", role: .destructive) {
+ deleteConversation(conversation)
+ }
+ }
+ }
+ }
+ .listStyle(.sidebar)
+ .toolbar {
+ ToolbarItem(placement: .automatic) {
+ Button(action: onNewConversation) {
+ Image(systemName: "plus")
+ }
+ .help("New Conversation (Cmd+N)")
+ }
+ }
+ .overlay {
+ if conversations.isEmpty {
+ ContentUnavailableView {
+ Label("No Conversations", systemImage: "bubble.left.and.bubble.right")
+ } description: {
+ Text("Press Cmd+N to start a new conversation")
+ }
+ }
+ }
+ }
+
+ private func deleteConversation(_ conversation: Conversation) {
+ if selectedConversation == conversation {
+ selectedConversation = nil
+ }
+ modelContext.delete(conversation)
+ try? modelContext.save()
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Views/Tools/JSONToolResultView.swift b/apps/omega-mac/OmegaMac/Views/Tools/JSONToolResultView.swift
new file mode 100644
index 0000000..c57c72c
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Views/Tools/JSONToolResultView.swift
@@ -0,0 +1,98 @@
+import SwiftUI
+
+/// Displays a persisted tool call result from a ToolCallData record.
+/// Collapsible card with monospaced JSON input/output.
+struct JSONToolResultView: View {
+ let toolCallData: ToolCallData
+ @State private var isExpanded: Bool = false
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ // Header
+ Button {
+ withAnimation(.easeInOut(duration: 0.2)) {
+ isExpanded.toggle()
+ }
+ } label: {
+ HStack(spacing: 8) {
+ Image(systemName: isError ? "xmark.circle.fill" : "checkmark.circle.fill")
+ .foregroundStyle(isError ? .red : .green)
+ .font(.caption)
+
+ Text(toolCallData.toolName)
+ .font(.system(.caption, design: .monospaced).bold())
+ .foregroundStyle(.primary)
+
+ Spacer()
+
+ Image(systemName: "chevron.right")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ .rotationEffect(.degrees(isExpanded ? 90 : 0))
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ }
+ .buttonStyle(.plain)
+
+ if isExpanded {
+ Divider()
+ .padding(.horizontal, 10)
+
+ VStack(alignment: .leading, spacing: 8) {
+ // Input
+ if let args = toolCallData.args {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("INPUT")
+ .font(.system(size: 9, design: .monospaced))
+ .foregroundStyle(.tertiary)
+
+ Text(args.prettyString)
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(6)
+ .background(.black.opacity(0.15))
+ .clipShape(RoundedRectangle(cornerRadius: 4))
+ }
+ }
+
+ // Output
+ if let output = toolCallData.output {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("OUTPUT")
+ .font(.system(size: 9, design: .monospaced))
+ .foregroundStyle(.tertiary)
+
+ Text(output.prettyString)
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(6)
+ .background(.black.opacity(0.15))
+ .clipShape(RoundedRectangle(cornerRadius: 4))
+ .lineLimit(20)
+ }
+ }
+ }
+ .padding(10)
+ }
+ }
+ .background(.quaternary.opacity(0.3))
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .strokeBorder(isError ? .red.opacity(0.2) : .green.opacity(0.15), lineWidth: 1)
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ }
+
+ private var isError: Bool {
+ if case .object(let obj) = toolCallData.output,
+ case .bool(true) = obj["error"] {
+ return true
+ }
+ return false
+ }
+}
diff --git a/apps/omega-mac/OmegaMac/Views/Tools/ToolCallView.swift b/apps/omega-mac/OmegaMac/Views/Tools/ToolCallView.swift
new file mode 100644
index 0000000..6e1c005
--- /dev/null
+++ b/apps/omega-mac/OmegaMac/Views/Tools/ToolCallView.swift
@@ -0,0 +1,119 @@
+import SwiftUI
+
+struct ToolCallView: View {
+ let toolCall: LiveToolCall
+
+ var body: some View {
+ HStack(alignment: .top) {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(spacing: 8) {
+ statusIcon
+ Text(toolCall.toolName)
+ .font(.system(.callout, design: .monospaced).bold())
+ .foregroundStyle(.primary)
+ Spacer()
+ statusBadge
+ }
+
+ // Input arguments
+ if !toolCall.arguments.isEmpty {
+ DisclosureGroup("Input") {
+ Text(prettyJSON(toolCall.arguments))
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(8)
+ .background(.black.opacity(0.2))
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+
+ // Output
+ if let output = toolCall.output {
+ DisclosureGroup("Output") {
+ Text(output.prettyString)
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundStyle(.secondary)
+ .textSelection(.enabled)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(8)
+ .background(.black.opacity(0.2))
+ .clipShape(RoundedRectangle(cornerRadius: 6))
+ }
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .padding(12)
+ .background(.quaternary.opacity(0.3))
+ .overlay(
+ RoundedRectangle(cornerRadius: 10)
+ .strokeBorder(borderColor, lineWidth: 1)
+ )
+ .clipShape(RoundedRectangle(cornerRadius: 10))
+
+ Spacer(minLength: 60)
+ }
+ .padding(.horizontal, 16)
+ }
+
+ @ViewBuilder
+ private var statusIcon: some View {
+ switch toolCall.status {
+ case "running":
+ ProgressView()
+ .controlSize(.small)
+ case "success":
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ case "error":
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(.red)
+ default:
+ Image(systemName: "questionmark.circle")
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ @ViewBuilder
+ private var statusBadge: some View {
+ Text(toolCall.status.capitalized)
+ .font(.system(size: 10, design: .monospaced))
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(badgeColor.opacity(0.15))
+ .foregroundStyle(badgeColor)
+ .clipShape(Capsule())
+ }
+
+ private var badgeColor: Color {
+ switch toolCall.status {
+ case "running": return .orange
+ case "success": return .green
+ case "error": return .red
+ default: return .secondary
+ }
+ }
+
+ private var borderColor: Color {
+ switch toolCall.status {
+ case "running": return .orange.opacity(0.3)
+ case "success": return .green.opacity(0.2)
+ case "error": return .red.opacity(0.3)
+ default: return .clear
+ }
+ }
+
+ private func prettyJSON(_ jsonString: String) -> String {
+ guard let data = jsonString.data(using: .utf8),
+ let obj = try? JSONSerialization.jsonObject(with: data),
+ let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
+ let str = String(data: pretty, encoding: .utf8) else {
+ return jsonString
+ }
+ return str
+ }
+}
diff --git a/apps/omega-mac/Package.swift b/apps/omega-mac/Package.swift
new file mode 100644
index 0000000..0257a25
--- /dev/null
+++ b/apps/omega-mac/Package.swift
@@ -0,0 +1,19 @@
+// swift-tools-version: 5.10
+import PackageDescription
+
+let package = Package(
+ name: "OmegaMac",
+ platforms: [.macOS(.v15)],
+ dependencies: [
+ .package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.0"),
+ ],
+ targets: [
+ .executableTarget(
+ name: "OmegaMac",
+ dependencies: [
+ .product(name: "MarkdownUI", package: "swift-markdown-ui"),
+ ],
+ path: "OmegaMac"
+ ),
+ ]
+)
diff --git a/apps/omega-mac/README.md b/apps/omega-mac/README.md
new file mode 100644
index 0000000..a188f72
--- /dev/null
+++ b/apps/omega-mac/README.md
@@ -0,0 +1,115 @@
+# Omega Mac
+
+Native macOS chat app powered by the [TPMJS tool registry](https://tpmjs.com) — 1M+ AI-ready tools at your fingertips.
+
+Omega Mac is the desktop counterpart to the web-based Omega agent. It connects directly to the OpenAI API and the TPMJS registry to search, discover, and execute tools in a secure remote sandbox — all from a native SwiftUI interface.
+
+## Requirements
+
+- macOS 15 (Sequoia) or later
+- Xcode 16+
+- An OpenAI API key
+
+## Getting Started
+
+1. Open `Package.swift` in Xcode
+2. Wait for Swift Package Manager to resolve dependencies
+3. Build and run (Cmd+R)
+4. Open Settings (Cmd+,) and enter your OpenAI API key
+5. Press Cmd+N to start a new conversation
+
+## How It Works
+
+Omega Mac implements a full **agentic tool-use loop**:
+
+```
+User message
+ → Auto-discover relevant tools (BM25 search against tpmjs.com)
+ → Build tool list (registrySearch + registryExecute + discovered tools)
+ → Stream OpenAI response
+ → If tool calls returned:
+ → Execute tools via remote sandbox (executor.tpmjs.com)
+ → Feed results back to OpenAI
+ → Loop (up to 10 iterations)
+ → Display final response
+```
+
+### Two Core Tools
+
+Every conversation has access to two meta-tools that unlock the entire registry:
+
+- **registrySearch** — Search 1M+ tools by keyword. Returns tool IDs and metadata.
+- **registryExecute** — Execute any tool by its ID. Runs in a secure remote sandbox.
+
+When you send a message, Omega also auto-discovers relevant tools via BM25 search and injects them as directly-callable functions — so the AI can call them without going through registryExecute.
+
+## Architecture
+
+```
+OmegaMac/
+├── Models/ SwiftData persistence
+│ ├── Conversation Chat sessions with token tracking
+│ ├── Message User/assistant/tool messages with JSON tool call data
+│ ├── ToolCallRecord Individual tool execution records
+│ ├── EnvVar Environment variable metadata (values in Keychain)
+│ └── UserSettings Model selection, system prompt, pinned tools
+├── Services/ Actor-based networking
+│ ├── OpenAIService Streaming chat completions via SSE
+│ ├── StreamParser Server-Sent Events line parser
+│ ├── TPMJSRegistry Tool search + remote execution
+│ ├── KeychainService Secure storage for API keys and env vars
+│ └── ChatOrchestrator @Observable coordinator for the agentic loop
+├── Views/ SwiftUI interface
+│ ├── Sidebar/ Conversation list with @Query
+│ ├── Chat/ Messages, input bar, streaming indicator
+│ ├── Tools/ Tool call cards with collapsible JSON
+│ ├── Settings/ API key, env vars, system prompt, model picker
+│ └── Shared/ Markdown rendering, empty state
+└── Utilities/ Tool name sanitization, system prompt builder
+```
+
+### Key Design Decisions
+
+- **SwiftData** for local persistence — no server, no auth, everything on-device
+- **Keychain** for secrets — API keys and env var values are encrypted at rest
+- **Actors** for networking — `OpenAIService` and `TPMJSRegistryService` are actors for safe concurrent access
+- **@Observable** — `ChatOrchestrator` drives all UI state with zero Combine boilerplate
+- **Dark theme** by default — matches the web Omega aesthetic
+
+## Settings
+
+### API Key (required)
+
+Your OpenAI API key is stored in the macOS Keychain. Omega Mac calls the OpenAI API directly — no proxy server.
+
+### Model Selection
+
+Choose from: `gpt-4.1-mini` (default), `gpt-4.1`, `gpt-4.1-nano`, `gpt-4o`, `gpt-4o-mini`, `o4-mini`.
+
+### Environment Variables
+
+Many tools in the TPMJS registry require API keys (e.g., `WEATHER_API_KEY`, `GITHUB_TOKEN`). Add them in Settings → Environment. Values are stored in Keychain; only key names and last-4-char hints are visible in the app.
+
+All stored env vars are automatically passed to every tool execution.
+
+### Custom System Prompt
+
+Append custom instructions to Omega's default system prompt. Useful for constraining behavior, adding domain context, or specifying preferred tools.
+
+## Keyboard Shortcuts
+
+| Shortcut | Action |
+|----------|--------|
+| Cmd+N | New conversation |
+| Cmd+, | Open settings |
+| Enter | Send message |
+| Shift+Enter | New line in input |
+
+## Dependencies
+
+- [MarkdownUI](https://github.com/gonzalezreal/swift-markdown-ui) — GitHub-flavored markdown rendering
+- Everything else uses Apple frameworks (SwiftUI, SwiftData, Security, Foundation)
+
+## Relationship to Web Omega
+
+This app ports the core logic from the web implementation at `apps/web/src/app/api/omega/`. The agentic loop, system prompt, tool name sanitization, and search/execute flow are all faithful Swift translations of the TypeScript originals. The key difference is that web Omega uses server-side auth and a database, while Omega Mac stores everything locally with SwiftData and Keychain.
diff --git a/packages/tools/official/agentmail/README.md b/packages/tools/official/agentmail/README.md
new file mode 100644
index 0000000..68486bd
--- /dev/null
+++ b/packages/tools/official/agentmail/README.md
@@ -0,0 +1,77 @@
+# @tpmjs/tools-agentmail
+
+AgentMail API tools for AI agents — create inboxes, send/receive emails, manage threads, drafts, and more.
+
+## Installation
+
+```bash
+npm install @tpmjs/tools-agentmail
+```
+
+## Setup
+
+Set the `AGENTMAIL_API_KEY` environment variable. Get your API key from [AgentMail Dashboard](https://app.agentmail.to).
+
+```bash
+export AGENTMAIL_API_KEY=your_api_key_here
+```
+
+## Usage
+
+```typescript
+import { createInbox, sendMessage, listMessages } from '@tpmjs/tools-agentmail';
+
+// Create a new inbox for your AI agent
+const inbox = await createInbox.execute({
+ username: 'my-agent',
+ display_name: 'My AI Agent',
+});
+
+// Send an email
+const message = await sendMessage.execute({
+ inbox_id: inbox.inbox_id,
+ to: 'recipient@example.com',
+ subject: 'Hello from AI',
+ text: 'This is an automated message from my AI agent.',
+});
+
+// List received messages
+const messages = await listMessages.execute({
+ inbox_id: inbox.inbox_id,
+ limit: 20,
+});
+```
+
+## Tools
+
+| Tool | Description |
+|------|-------------|
+| createInbox | Create a new email inbox with optional custom username and domain |
+| listInboxes | List all email inboxes with pagination support |
+| getInbox | Get details of a specific inbox by ID |
+| deleteInbox | Delete an inbox and all its messages permanently |
+| sendMessage | Send an email message with subject and body |
+| replyToMessage | Reply to an existing email in a thread |
+| listMessages | List email messages in an inbox with pagination |
+| getMessage | Get full details of a specific message |
+| listThreads | List email threads, optionally filtered by labels |
+| getThread | Get a thread with all its messages |
+| createDraft | Create an email draft for approval before sending |
+| sendDraft | Send a previously created draft |
+
+## Features
+
+- **Complete Email Management**: Create inboxes, send/receive emails, manage threads
+- **Thread Support**: View full conversation history with thread IDs
+- **Draft Support**: Create drafts for human-in-the-loop approval workflows
+- **Label Support**: Organize messages with custom labels
+- **Pagination**: Efficiently handle large inboxes with cursor-based pagination
+- **Type-Safe**: Full TypeScript types for all API responses
+
+## API Documentation
+
+For detailed API documentation, visit [AgentMail API Docs](https://docs.agentmail.to).
+
+## License
+
+MIT
diff --git a/packages/tools/official/agentmail/package.json b/packages/tools/official/agentmail/package.json
new file mode 100644
index 0000000..8326cf1
--- /dev/null
+++ b/packages/tools/official/agentmail/package.json
@@ -0,0 +1,109 @@
+{
+ "name": "@tpmjs/tools-agentmail",
+ "version": "0.1.0",
+ "description": "AgentMail API tools for AI agents — create inboxes, send/receive emails, manage threads, drafts, and more",
+ "type": "module",
+ "keywords": [
+ "tpmjs",
+ "ops",
+ "ai",
+ "email",
+ "agentmail"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "default": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist"
+ ],
+ "scripts": {
+ "build": "tsup",
+ "dev": "tsup --watch",
+ "type-check": "tsc --noEmit",
+ "clean": "rm -rf dist .turbo"
+ },
+ "devDependencies": {
+ "@tpmjs/tsconfig": "workspace:*",
+ "tsup": "^8.5.1",
+ "typescript": "^5.9.3"
+ },
+ "dependencies": {
+ "ai": "6.0.49"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/tpmjs/tpmjs.git",
+ "directory": "packages/tools/official/agentmail"
+ },
+ "homepage": "https://tpmjs.com",
+ "license": "MIT",
+ "tpmjs": {
+ "category": "ops",
+ "frameworks": [
+ "vercel-ai"
+ ],
+ "env": [
+ {
+ "name": "AGENTMAIL_API_KEY",
+ "description": "AgentMail API key from https://app.agentmail.to",
+ "required": true
+ }
+ ],
+ "tools": [
+ {
+ "name": "createInbox",
+ "description": "Create a new email inbox for an AI agent with optional custom username and domain."
+ },
+ {
+ "name": "listInboxes",
+ "description": "List all email inboxes in the AgentMail account with pagination support."
+ },
+ {
+ "name": "getInbox",
+ "description": "Get details of a specific email inbox by its inbox ID."
+ },
+ {
+ "name": "deleteInbox",
+ "description": "Delete an email inbox and all its messages permanently."
+ },
+ {
+ "name": "sendMessage",
+ "description": "Send an email message from an inbox to a recipient with subject and body."
+ },
+ {
+ "name": "replyToMessage",
+ "description": "Reply to an existing email message in a thread conversation."
+ },
+ {
+ "name": "listMessages",
+ "description": "List email messages in an inbox with pagination support."
+ },
+ {
+ "name": "getMessage",
+ "description": "Get the full details of a specific email message by its message ID."
+ },
+ {
+ "name": "listThreads",
+ "description": "List email threads in an inbox, optionally filtered by labels."
+ },
+ {
+ "name": "getThread",
+ "description": "Get a thread with all its messages for viewing a full conversation."
+ },
+ {
+ "name": "createDraft",
+ "description": "Create an email draft for human-in-the-loop approval before sending."
+ },
+ {
+ "name": "sendDraft",
+ "description": "Send a previously created draft, converting it to a sent message."
+ }
+ ]
+ }
+}
diff --git a/packages/tools/official/agentmail/src/index.ts b/packages/tools/official/agentmail/src/index.ts
new file mode 100644
index 0000000..e66f7e4
--- /dev/null
+++ b/packages/tools/official/agentmail/src/index.ts
@@ -0,0 +1,751 @@
+/**
+ * @tpmjs/tools-agentmail — AgentMail API Tools for AI Agents
+ *
+ * Complete email management for AI agents: create inboxes, send/receive emails,
+ * manage threads, drafts, and more.
+ *
+ * @requires AGENTMAIL_API_KEY environment variable
+ */
+
+import { jsonSchema, tool } from 'ai';
+
+const BASE_URL = 'https://api.agentmail.to/v0';
+
+// ─── Client Infrastructure ──────────────────────────────────────────────────
+
+function getApiKey(): string {
+ const key = process.env.AGENTMAIL_API_KEY;
+ if (!key) {
+ throw new Error(
+ 'AGENTMAIL_API_KEY environment variable is required. Get your token from https://app.agentmail.to'
+ );
+ }
+ return key;
+}
+
+async function apiRequest(
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE',
+ path: string,
+ body?: unknown
+): Promise {
+ const token = getApiKey();
+
+ const headers: Record = {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ };
+
+ const options: RequestInit = {
+ method,
+ headers,
+ };
+
+ if (body !== undefined) {
+ options.body = JSON.stringify(body);
+ }
+
+ const response = await fetch(`${BASE_URL}${path}`, options);
+
+ // Handle 204 No Content for DELETE operations
+ if (response.status === 204) {
+ return { success: true } as T;
+ }
+
+ if (!response.ok) {
+ const errorText = await response.text();
+ throw new Error(
+ `AgentMail HTTP error ${response.status}: ${response.statusText}${errorText ? ` - ${errorText}` : ''}`
+ );
+ }
+
+ return (await response.json()) as T;
+}
+
+// ─── Output Types ────────────────────────────────────────────────────────────
+
+export interface InboxResult {
+ inbox_id: string;
+ pod_id: string;
+ display_name?: string;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ListInboxesResult {
+ count: number;
+ inboxes: InboxResult[];
+ next_page_token?: string;
+}
+
+export interface SuccessResult {
+ success: boolean;
+ inbox_id?: string;
+}
+
+export interface MessageResult {
+ message_id: string;
+ thread_id: string;
+ inbox_id: string;
+ from: string;
+ to: string;
+ subject: string;
+ text: string;
+ html?: string;
+ labels: string[];
+ created_at: string;
+}
+
+export interface ListMessagesResult {
+ count: number;
+ messages: MessageResult[];
+ next_page_token?: string;
+}
+
+export interface ThreadResult {
+ thread_id: string;
+ inbox_id: string;
+ subject: string;
+ labels: string[];
+ message_count: number;
+ created_at: string;
+ updated_at: string;
+}
+
+export interface ListThreadsResult {
+ count: number;
+ threads: ThreadResult[];
+ next_page_token?: string;
+}
+
+export interface ThreadDetailResult {
+ thread_id: string;
+ inbox_id: string;
+ subject: string;
+ labels: string[];
+ messages: MessageResult[];
+ created_at: string;
+ updated_at: string;
+}
+
+export interface DraftResult {
+ draft_id: string;
+ inbox_id: string;
+ to: string;
+ subject: string;
+ text: string;
+ html?: string;
+ created_at: string;
+}
+
+// ─── Inboxes ────────────────────────────────────────────────────────────────
+
+export interface CreateInboxInput {
+ username?: string;
+ domain?: string;
+ display_name?: string;
+}
+
+export const createInbox = tool({
+ description:
+ 'Create a new email inbox for an AI agent with optional custom username and domain.',
+ inputSchema: jsonSchema({
+ type: 'object',
+ properties: {
+ username: {
+ type: 'string',
+ description: 'Optional username for the inbox. If not provided, a random one is generated.',
+ },
+ domain: {
+ type: 'string',
+ description: 'Optional domain (defaults to agentmail.to).',
+ },
+ display_name: {
+ type: 'string',
+ description: 'Optional display name for the inbox.',
+ },
+ },
+ additionalProperties: false,
+ }),
+ async execute(input: CreateInboxInput): Promise {
+ try {
+ return await apiRequest('POST', '/inboxes', {
+ username: input.username,
+ domain: input.domain,
+ display_name: input.display_name,
+ });
+ } catch (error) {
+ throw new Error(`Failed to create inbox: ${(error as Error).message}`);
+ }
+ },
+});
+
+export interface ListInboxesInput {
+ limit?: number;
+ page_token?: string;
+}
+
+export const listInboxes = tool({
+ description: 'List all email inboxes in the AgentMail account with pagination support.',
+ inputSchema: jsonSchema({
+ type: 'object',
+ properties: {
+ limit: {
+ type: 'number',
+ description: 'Number of inboxes to return (1-100, default: 50).',
+ },
+ page_token: {
+ type: 'string',
+ description: 'Pagination token from previous response.',
+ },
+ },
+ additionalProperties: false,
+ }),
+ async execute(input: ListInboxesInput): Promise {
+ try {
+ if (input.limit !== undefined && (input.limit < 1 || input.limit > 100)) {
+ throw new Error('Limit must be between 1 and 100');
+ }
+
+ const params = new URLSearchParams();
+ if (input.limit !== undefined) params.append('limit', String(input.limit));
+ if (input.page_token) params.append('page_token', input.page_token);
+
+ const queryString = params.toString();
+ const path = queryString ? `/inboxes?${queryString}` : '/inboxes';
+
+ return await apiRequest('GET', path);
+ } catch (error) {
+ throw new Error(`Failed to list inboxes: ${(error as Error).message}`);
+ }
+ },
+});
+
+export interface GetInboxInput {
+ inbox_id: string;
+}
+
+export const getInbox = tool({
+ description: 'Get details of a specific email inbox by its inbox ID.',
+ inputSchema: jsonSchema({
+ type: 'object',
+ properties: {
+ inbox_id: {
+ type: 'string',
+ description: 'The inbox ID to retrieve.',
+ },
+ },
+ required: ['inbox_id'],
+ additionalProperties: false,
+ }),
+ async execute(input: GetInboxInput): Promise {
+ try {
+ if (!input.inbox_id) {
+ throw new Error('inbox_id is required and must be non-empty');
+ }
+ return await apiRequest('GET', `/inboxes/${input.inbox_id}`);
+ } catch (error) {
+ throw new Error(`Failed to get inbox: ${(error as Error).message}`);
+ }
+ },
+});
+
+export interface DeleteInboxInput {
+ inbox_id: string;
+}
+
+export const deleteInbox = tool({
+ description: 'Delete an email inbox and all its messages permanently.',
+ inputSchema: jsonSchema({
+ type: 'object',
+ properties: {
+ inbox_id: {
+ type: 'string',
+ description: 'The inbox ID to delete.',
+ },
+ },
+ required: ['inbox_id'],
+ additionalProperties: false,
+ }),
+ async execute(input: DeleteInboxInput): Promise {
+ try {
+ if (!input.inbox_id) {
+ throw new Error('inbox_id is required and must be non-empty');
+ }
+ await apiRequest('DELETE', `/inboxes/${input.inbox_id}`);
+ return { success: true, inbox_id: input.inbox_id };
+ } catch (error) {
+ throw new Error(`Failed to delete inbox: ${(error as Error).message}`);
+ }
+ },
+});
+
+// ─── Messages ───────────────────────────────────────────────────────────────
+
+export interface SendMessageInput {
+ inbox_id: string;
+ to: string;
+ subject: string;
+ text: string;
+ html?: string;
+ cc?: string;
+ bcc?: string;
+ labels?: string[];
+}
+
+export const sendMessage = tool({
+ description: 'Send an email message from an inbox to a recipient with subject and body.',
+ inputSchema: jsonSchema({
+ type: 'object',
+ properties: {
+ inbox_id: {
+ type: 'string',
+ description: 'The inbox ID to send from.',
+ },
+ to: {
+ type: 'string',
+ description: 'Recipient email address.',
+ },
+ subject: {
+ type: 'string',
+ description: 'Email subject line.',
+ },
+ text: {
+ type: 'string',
+ description: 'Plain text email body.',
+ },
+ html: {
+ type: 'string',
+ description: 'Optional HTML email body.',
+ },
+ cc: {
+ type: 'string',
+ description: 'Optional CC recipients (comma-separated).',
+ },
+ bcc: {
+ type: 'string',
+ description: 'Optional BCC recipients (comma-separated).',
+ },
+ labels: {
+ type: 'array',
+ items: { type: 'string' },
+ description: 'Optional labels to apply to the message.',
+ },
+ },
+ required: ['inbox_id', 'to', 'subject', 'text'],
+ additionalProperties: false,
+ }),
+ async execute(input: SendMessageInput): Promise {
+ try {
+ if (!input.inbox_id || !input.to || !input.subject || !input.text) {
+ throw new Error('inbox_id, to, subject, and text are required and must be non-empty');
+ }
+ return await apiRequest('POST', `/inboxes/${input.inbox_id}/messages`, {
+ to: input.to,
+ subject: input.subject,
+ text: input.text,
+ html: input.html,
+ cc: input.cc,
+ bcc: input.bcc,
+ labels: input.labels,
+ });
+ } catch (error) {
+ throw new Error(`Failed to send message: ${(error as Error).message}`);
+ }
+ },
+});
+
+export interface ReplyToMessageInput {
+ inbox_id: string;
+ message_id: string;
+ text: string;
+ html?: string;
+ labels?: string[];
+}
+
+export const replyToMessage = tool({
+ description: 'Reply to an existing email message in a thread conversation.',
+ inputSchema: jsonSchema({
+ type: 'object',
+ properties: {
+ inbox_id: {
+ type: 'string',
+ description: 'The inbox ID.',
+ },
+ message_id: {
+ type: 'string',
+ description: 'The message ID to reply to.',
+ },
+ text: {
+ type: 'string',
+ description: 'Plain text reply body.',
+ },
+ html: {
+ type: 'string',
+ description: 'Optional HTML reply body.',
+ },
+ labels: {
+ type: 'array',
+ items: { type: 'string' },
+ description: 'Optional labels to apply to the reply.',
+ },
+ },
+ required: ['inbox_id', 'message_id', 'text'],
+ additionalProperties: false,
+ }),
+ async execute(input: ReplyToMessageInput): Promise {
+ try {
+ if (!input.inbox_id || !input.message_id || !input.text) {
+ throw new Error('inbox_id, message_id, and text are required and must be non-empty');
+ }
+ return await apiRequest(
+ 'POST',
+ `/inboxes/${input.inbox_id}/messages/${input.message_id}/reply`,
+ {
+ text: input.text,
+ html: input.html,
+ labels: input.labels,
+ }
+ );
+ } catch (error) {
+ throw new Error(`Failed to reply to message: ${(error as Error).message}`);
+ }
+ },
+});
+
+export interface ListMessagesInput {
+ inbox_id: string;
+ limit?: number;
+ page_token?: string;
+}
+
+export const listMessages = tool({
+ description: 'List email messages in an inbox with pagination support.',
+ inputSchema: jsonSchema({
+ type: 'object',
+ properties: {
+ inbox_id: {
+ type: 'string',
+ description: 'The inbox ID to list messages from.',
+ },
+ limit: {
+ type: 'number',
+ description: 'Number of messages to return (1-100, default: 50).',
+ },
+ page_token: {
+ type: 'string',
+ description: 'Pagination token from previous response.',
+ },
+ },
+ required: ['inbox_id'],
+ additionalProperties: false,
+ }),
+ async execute(input: ListMessagesInput): Promise {
+ try {
+ if (!input.inbox_id) {
+ throw new Error('inbox_id is required and must be non-empty');
+ }
+ if (input.limit !== undefined && (input.limit < 1 || input.limit > 100)) {
+ throw new Error('Limit must be between 1 and 100');
+ }
+
+ const params = new URLSearchParams();
+ if (input.limit !== undefined) params.append('limit', String(input.limit));
+ if (input.page_token) params.append('page_token', input.page_token);
+
+ const queryString = params.toString();
+ const path = queryString
+ ? `/inboxes/${input.inbox_id}/messages?${queryString}`
+ : `/inboxes/${input.inbox_id}/messages`;
+
+ return await apiRequest('GET', path);
+ } catch (error) {
+ throw new Error(`Failed to list messages: ${(error as Error).message}`);
+ }
+ },
+});
+
+export interface GetMessageInput {
+ inbox_id: string;
+ message_id: string;
+}
+
+export const getMessage = tool({
+ description: 'Get the full details of a specific email message by its message ID.',
+ inputSchema: jsonSchema({
+ type: 'object',
+ properties: {
+ inbox_id: {
+ type: 'string',
+ description: 'The inbox ID.',
+ },
+ message_id: {
+ type: 'string',
+ description: 'The message ID to retrieve.',
+ },
+ },
+ required: ['inbox_id', 'message_id'],
+ additionalProperties: false,
+ }),
+ async execute(input: GetMessageInput): Promise {
+ if (!input.inbox_id) {
+ throw new Error('inbox_id is required and must be non-empty');
+ }
+ if (!input.message_id) {
+ throw new Error('message_id is required and must be non-empty');
+ }
+
+ try {
+ return await apiRequest(
+ 'GET',
+ `/inboxes/${encodeURIComponent(input.inbox_id)}/messages/${encodeURIComponent(input.message_id)}`
+ );
+ } catch (error) {
+ throw new Error(
+ `Failed to get message "${input.message_id}" from inbox "${input.inbox_id}": ${(error as Error).message}`
+ );
+ }
+ },
+});
+
+// ─── Threads ────────────────────────────────────────────────────────────────
+
+export interface ListThreadsInput {
+ inbox_id: string;
+ limit?: number;
+ page_token?: string;
+ labels?: string[];
+}
+
+export const listThreads = tool({
+ description: 'List email threads in an inbox, optionally filtered by labels.',
+ inputSchema: jsonSchema({
+ type: 'object',
+ properties: {
+ inbox_id: {
+ type: 'string',
+ description: 'The inbox ID to list threads from.',
+ },
+ limit: {
+ type: 'number',
+ description: 'Number of threads to return (1-100, default: 50).',
+ },
+ page_token: {
+ type: 'string',
+ description: 'Pagination token from previous response.',
+ },
+ labels: {
+ type: 'array',
+ items: { type: 'string' },
+ description: 'Optional labels to filter threads by.',
+ },
+ },
+ required: ['inbox_id'],
+ additionalProperties: false,
+ }),
+ async execute(input: ListThreadsInput): Promise {
+ if (!input.inbox_id) {
+ throw new Error('inbox_id is required and must be non-empty');
+ }
+ if (input.limit !== undefined && (input.limit < 1 || input.limit > 100)) {
+ throw new Error('Limit must be between 1 and 100');
+ }
+
+ try {
+ const params = new URLSearchParams();
+ if (input.limit !== undefined) params.append('limit', String(input.limit));
+ if (input.page_token) params.append('page_token', input.page_token);
+ if (Array.isArray(input.labels)) {
+ const validLabels = input.labels.filter((label) => typeof label === 'string' && label.trim().length > 0);
+ for (const label of validLabels) {
+ params.append('labels', label);
+ }
+ }
+
+ const queryString = params.toString();
+ const path = queryString
+ ? `/inboxes/${encodeURIComponent(input.inbox_id)}/threads?${queryString}`
+ : `/inboxes/${encodeURIComponent(input.inbox_id)}/threads`;
+
+ return await apiRequest('GET', path);
+ } catch (error) {
+ throw new Error(
+ `Failed to list threads for inbox "${input.inbox_id}": ${(error as Error).message}`
+ );
+ }
+ },
+});
+
+export interface GetThreadInput {
+ inbox_id: string;
+ thread_id: string;
+}
+
+export const getThread = tool({
+ description: 'Get a thread with all its messages for viewing a full conversation.',
+ inputSchema: jsonSchema({
+ type: 'object',
+ properties: {
+ inbox_id: {
+ type: 'string',
+ description: 'The inbox ID.',
+ },
+ thread_id: {
+ type: 'string',
+ description: 'The thread ID to retrieve.',
+ },
+ },
+ required: ['inbox_id', 'thread_id'],
+ additionalProperties: false,
+ }),
+ async execute(input: GetThreadInput): Promise {
+ try {
+ if (!input.inbox_id || !input.thread_id) {
+ throw new Error('inbox_id and thread_id are required and must be non-empty');
+ }
+ return await apiRequest(
+ 'GET',
+ `/inboxes/${input.inbox_id}/threads/${input.thread_id}`
+ );
+ } catch (error) {
+ throw new Error(`Failed to get thread: ${(error as Error).message}`);
+ }
+ },
+});
+
+// ─── Drafts ─────────────────────────────────────────────────────────────────
+
+export interface CreateDraftInput {
+ inbox_id: string;
+ to: string;
+ subject: string;
+ text: string;
+ html?: string;
+ cc?: string;
+ bcc?: string;
+}
+
+export const createDraft = tool({
+ description: 'Create an email draft for human-in-the-loop approval before sending.',
+ inputSchema: jsonSchema({
+ type: 'object',
+ properties: {
+ inbox_id: {
+ type: 'string',
+ description: 'The inbox ID to create the draft in.',
+ },
+ to: {
+ type: 'string',
+ description: 'Recipient email address.',
+ },
+ subject: {
+ type: 'string',
+ description: 'Email subject line.',
+ },
+ text: {
+ type: 'string',
+ description: 'Plain text email body.',
+ },
+ html: {
+ type: 'string',
+ description: 'Optional HTML email body.',
+ },
+ cc: {
+ type: 'string',
+ description: 'Optional CC recipients (comma-separated).',
+ },
+ bcc: {
+ type: 'string',
+ description: 'Optional BCC recipients (comma-separated).',
+ },
+ },
+ required: ['inbox_id', 'to', 'subject', 'text'],
+ additionalProperties: false,
+ }),
+ async execute(input: CreateDraftInput): Promise {
+ try {
+ if (!input.inbox_id || !input.to || !input.subject || !input.text) {
+ throw new Error('inbox_id, to, subject, and text are required and must be non-empty');
+ }
+ return await apiRequest('POST', `/inboxes/${input.inbox_id}/drafts`, {
+ to: input.to,
+ subject: input.subject,
+ text: input.text,
+ html: input.html,
+ cc: input.cc,
+ bcc: input.bcc,
+ });
+ } catch (error) {
+ throw new Error(`Failed to create draft: ${(error as Error).message}`);
+ }
+ },
+});
+
+export interface SendDraftInput {
+ inbox_id: string;
+ draft_id: string;
+}
+
+export const sendDraft = tool({
+ description: 'Send a previously created draft, converting it to a sent message.',
+ inputSchema: jsonSchema({
+ type: 'object',
+ properties: {
+ inbox_id: {
+ type: 'string',
+ description: 'The inbox ID.',
+ },
+ draft_id: {
+ type: 'string',
+ description: 'The draft ID to send.',
+ },
+ },
+ required: ['inbox_id', 'draft_id'],
+ additionalProperties: false,
+ }),
+ async execute(input: SendDraftInput): Promise {
+ if (!input.inbox_id) {
+ throw new Error('inbox_id is required and must be non-empty');
+ }
+ if (!input.draft_id) {
+ throw new Error('draft_id is required and must be non-empty');
+ }
+
+ try {
+ const response = await apiRequest(
+ 'POST',
+ `/inboxes/${encodeURIComponent(input.inbox_id)}/drafts/${encodeURIComponent(input.draft_id)}/send`
+ );
+
+ if (!response || !response.message_id) {
+ throw new Error('AgentMail API returned an invalid response when sending draft');
+ }
+
+ return response;
+ } catch (error) {
+ throw new Error(
+ `Failed to send draft "${input.draft_id}" from inbox "${input.inbox_id}": ${(error as Error).message}`
+ );
+ }
+ },
+});
+
+// ─── Default Export ─────────────────────────────────────────────────────────
+
+export default {
+ // Inboxes
+ createInbox,
+ listInboxes,
+ getInbox,
+ deleteInbox,
+ // Messages
+ sendMessage,
+ replyToMessage,
+ listMessages,
+ getMessage,
+ // Threads
+ listThreads,
+ getThread,
+ // Drafts
+ createDraft,
+ sendDraft,
+};
diff --git a/packages/tools/official/agentmail/tsconfig.json b/packages/tools/official/agentmail/tsconfig.json
new file mode 100644
index 0000000..84965a7
--- /dev/null
+++ b/packages/tools/official/agentmail/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "@tpmjs/tsconfig/react-library.json",
+ "compilerOptions": {
+ "outDir": "dist",
+ "rootDir": "src",
+ "incremental": false,
+ "composite": false
+ },
+ "include": ["src/**/*.ts"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/tools/official/agentmail/tsup.config.ts b/packages/tools/official/agentmail/tsup.config.ts
new file mode 100644
index 0000000..2260c9e
--- /dev/null
+++ b/packages/tools/official/agentmail/tsup.config.ts
@@ -0,0 +1,12 @@
+import { defineConfig } from 'tsup';
+
+export default defineConfig({
+ entry: ['src/index.ts'],
+ format: ['esm'],
+ dts: true,
+ clean: true,
+ sourcemap: true,
+ target: 'es2022',
+ treeshake: true,
+ splitting: false,
+});
diff --git a/packages/tools/official/blocks.yml b/packages/tools/official/blocks.yml
index 37b3914..49aeffc 100644
--- a/packages/tools/official/blocks.yml
+++ b/packages/tools/official/blocks.yml
@@ -13105,6 +13105,288 @@ blocks:
description: "Pin confirmation"
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
+ # ---------------------------------------------------------------------------
+ # AgentMail Tools (email for AI agents)
+ # ---------------------------------------------------------------------------
+ ops.agentmailCreateInbox:
+ type: utility
+ description: "Create a new email inbox for an AI agent."
+ path: "agentmail"
+ domain_rules:
+ - id: api_integration
+ description: "Must call AgentMail API POST /v0/inboxes endpoint"
+ inputs:
+ - name: username
+ type: string
+ optional: true
+ description: "Username for the email address"
+ - name: domain
+ type: string
+ optional: true
+ description: "Domain for the email address"
+ - name: display_name
+ type: string
+ optional: true
+ description: "Display name for the inbox"
+ outputs:
+ - name: inbox
+ type: InboxResult
+ description: "Created inbox details"
+ measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
+
+ ops.agentmailListInboxes:
+ type: utility
+ description: "List all email inboxes in the account."
+ path: "agentmail"
+ domain_rules:
+ - id: api_integration
+ description: "Must call AgentMail API GET /v0/inboxes endpoint"
+ inputs:
+ - name: limit
+ type: number
+ optional: true
+ description: "Max inboxes to return (1-100)"
+ - name: page_token
+ type: string
+ optional: true
+ description: "Pagination token"
+ outputs:
+ - name: result
+ type: ListInboxesResult
+ description: "List of inboxes with pagination"
+ measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
+
+ ops.agentmailGetInbox:
+ type: utility
+ description: "Get details of a specific email inbox."
+ path: "agentmail"
+ domain_rules:
+ - id: api_integration
+ description: "Must call AgentMail API GET /v0/inboxes/{inbox_id} endpoint"
+ inputs:
+ - name: inbox_id
+ type: string
+ description: "Inbox ID"
+ outputs:
+ - name: inbox
+ type: InboxResult
+ description: "Inbox details"
+ measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
+
+ ops.agentmailDeleteInbox:
+ type: utility
+ description: "Delete an email inbox and all its messages."
+ path: "agentmail"
+ domain_rules:
+ - id: api_integration
+ description: "Must call AgentMail API DELETE /v0/inboxes/{inbox_id} endpoint"
+ inputs:
+ - name: inbox_id
+ type: string
+ description: "Inbox ID"
+ outputs:
+ - name: result
+ type: SuccessResult
+ description: "Deletion confirmation"
+ measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
+
+ ops.agentmailSendMessage:
+ type: utility
+ description: "Send an email message from an inbox."
+ path: "agentmail"
+ domain_rules:
+ - id: api_integration
+ description: "Must call AgentMail API POST /v0/inboxes/{inbox_id}/messages endpoint"
+ inputs:
+ - name: inbox_id
+ type: string
+ description: "Inbox ID to send from"
+ - name: to
+ type: string
+ description: "Recipient email address"
+ - name: subject
+ type: string
+ description: "Email subject"
+ - name: text
+ type: string
+ description: "Plain text body"
+ - name: html
+ type: string
+ optional: true
+ description: "HTML body"
+ outputs:
+ - name: message
+ type: MessageResult
+ description: "Sent message details"
+ measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
+
+ ops.agentmailReplyToMessage:
+ type: utility
+ description: "Reply to an existing email message in a thread."
+ path: "agentmail"
+ domain_rules:
+ - id: api_integration
+ description: "Must call AgentMail API POST /v0/inboxes/{inbox_id}/messages/{message_id}/reply endpoint"
+ inputs:
+ - name: inbox_id
+ type: string
+ description: "Inbox ID"
+ - name: message_id
+ type: string
+ description: "Message ID to reply to"
+ - name: text
+ type: string
+ description: "Reply text"
+ - name: html
+ type: string
+ optional: true
+ description: "Reply HTML body"
+ outputs:
+ - name: message
+ type: MessageResult
+ description: "Reply message details"
+ measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
+
+ ops.agentmailListMessages:
+ type: utility
+ description: "List email messages in an inbox."
+ path: "agentmail"
+ domain_rules:
+ - id: api_integration
+ description: "Must call AgentMail API GET /v0/inboxes/{inbox_id}/messages endpoint"
+ inputs:
+ - name: inbox_id
+ type: string
+ description: "Inbox ID"
+ - name: limit
+ type: number
+ optional: true
+ description: "Max messages to return (1-100)"
+ - name: page_token
+ type: string
+ optional: true
+ description: "Pagination token"
+ outputs:
+ - name: result
+ type: ListMessagesResult
+ description: "List of messages with pagination"
+ measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
+
+ ops.agentmailGetMessage:
+ type: utility
+ description: "Get the full details of a specific email message."
+ path: "agentmail"
+ domain_rules:
+ - id: api_integration
+ description: "Must call AgentMail API GET /v0/inboxes/{inbox_id}/messages/{message_id} endpoint"
+ inputs:
+ - name: inbox_id
+ type: string
+ description: "Inbox ID"
+ - name: message_id
+ type: string
+ description: "Message ID"
+ outputs:
+ - name: message
+ type: MessageResult
+ description: "Full message details"
+ measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
+
+ ops.agentmailListThreads:
+ type: utility
+ description: "List email threads in an inbox, optionally filtered by labels."
+ path: "agentmail"
+ domain_rules:
+ - id: api_integration
+ description: "Must call AgentMail API GET /v0/inboxes/{inbox_id}/threads endpoint"
+ inputs:
+ - name: inbox_id
+ type: string
+ description: "Inbox ID"
+ - name: limit
+ type: number
+ optional: true
+ description: "Max threads to return (1-100)"
+ - name: labels
+ type: array
+ optional: true
+ description: "Filter by labels"
+ outputs:
+ - name: result
+ type: ListThreadsResult
+ description: "List of threads with pagination"
+ measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
+
+ ops.agentmailGetThread:
+ type: utility
+ description: "Get a thread with all its messages for viewing a full conversation."
+ path: "agentmail"
+ domain_rules:
+ - id: api_integration
+ description: "Must call AgentMail API GET /v0/inboxes/{inbox_id}/threads/{thread_id} endpoint"
+ inputs:
+ - name: inbox_id
+ type: string
+ description: "Inbox ID"
+ - name: thread_id
+ type: string
+ description: "Thread ID"
+ outputs:
+ - name: thread
+ type: ThreadDetailResult
+ description: "Thread with all messages"
+ measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
+
+ ops.agentmailCreateDraft:
+ type: utility
+ description: "Create an email draft for human-in-the-loop approval."
+ path: "agentmail"
+ domain_rules:
+ - id: api_integration
+ description: "Must call AgentMail API POST /v0/inboxes/{inbox_id}/drafts endpoint"
+ inputs:
+ - name: inbox_id
+ type: string
+ description: "Inbox ID"
+ - name: to
+ type: string
+ description: "Recipient email"
+ - name: subject
+ type: string
+ description: "Email subject"
+ - name: text
+ type: string
+ description: "Plain text body"
+ - name: html
+ type: string
+ optional: true
+ description: "HTML body"
+ outputs:
+ - name: draft
+ type: DraftResult
+ description: "Created draft details"
+ measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
+
+ ops.agentmailSendDraft:
+ type: utility
+ description: "Send a previously created draft, converting it to a sent message."
+ path: "agentmail"
+ domain_rules:
+ - id: api_integration
+ description: "Must call AgentMail API POST /v0/inboxes/{inbox_id}/drafts/{draft_id}/send endpoint"
+ inputs:
+ - name: inbox_id
+ type: string
+ description: "Inbox ID"
+ - name: draft_id
+ type: string
+ description: "Draft ID"
+ outputs:
+ - name: message
+ type: MessageResult
+ description: "Sent message details"
+ measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
+
# =============================================================================
# VALIDATORS - Which validators to run against each block
# =============================================================================
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d0098f4..f8df04c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1014,6 +1014,22 @@ importers:
specifier: ^5.9.3
version: 5.9.3
+ packages/tools/official/agentmail:
+ dependencies:
+ ai:
+ specifier: 6.0.49
+ version: 6.0.49(zod@4.3.5)
+ devDependencies:
+ '@tpmjs/tsconfig':
+ specifier: workspace:*
+ version: link:../../../config/tsconfig
+ tsup:
+ specifier: ^8.5.1
+ version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+
packages/tools/official/anomaly-detect-mad:
dependencies:
ai: