// Codable mirror of gateway/src/contract.ts. docs/api.md describes the same JSON for humans. // Change both sides together. // // String enums from the gateway (phase, host status, approval decision) are modeled as // RawRepresentable structs rather than Swift enums, so a value the watch does not know yet still // decodes instead of failing the whole feed. import Foundation struct Phase: RawRepresentable, Codable, Hashable, Sendable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } static let starting = Phase(rawValue: "starting") static let running = Phase(rawValue: "running") static let waitingForApproval = Phase(rawValue: "waiting_for_approval") static let waitingForInput = Phase(rawValue: "waiting_for_input") static let completed = Phase(rawValue: "completed") static let failed = Phase(rawValue: "failed") var needsYou: Bool { self == .waitingForApproval || self == .waitingForInput } var isWorking: Bool { self == .starting || self == .running } } struct HostStatus: RawRepresentable, Codable, Hashable, Sendable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } static let connecting = HostStatus(rawValue: "connecting") static let online = HostStatus(rawValue: "online") static let offline = HostStatus(rawValue: "offline") static let authInvalid = HostStatus(rawValue: "auth_invalid") static let incompatible = HostStatus(rawValue: "incompatible") /// A short label for a host that is not online, such as "offline" or "needs a new token". var problem: String? { switch self { case .online: nil case .connecting: "connecting" case .authInvalid: "needs a new token" case .incompatible: "incompatible" default: "offline" } } } struct ApprovalDecision: RawRepresentable, Codable, Hashable, Sendable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } static let accept = ApprovalDecision(rawValue: "accept") static let acceptForSession = ApprovalDecision(rawValue: "acceptForSession") static let acceptAlways = ApprovalDecision(rawValue: "acceptAlways") static let decline = ApprovalDecision(rawValue: "decline") static let cancel = ApprovalDecision(rawValue: "cancel") } struct HostSummary: Codable, Hashable, Sendable, Identifiable { var id: String var label: String var status: HostStatus var error: String? } /// Something a thread is waiting on: an approval, or questions for the user. struct PendingRequest: Codable, Hashable, Sendable, Identifiable { struct Option: Codable, Hashable, Sendable { var decision: ApprovalDecision var label: String } struct Question: Codable, Hashable, Sendable, Identifiable { struct Choice: Codable, Hashable, Sendable { var label: String var value: String } var id: String var header: String var question: String var options: [Choice] var allowCustomAnswer: Bool /// When true, the answer is an array of option values. var multiSelect = false } var id: String /// `command`, `file-change`, `permission`, `user_input`, ... var kind: String var prompt: String? var reason: String? var options: [Option] var questions: [Question] var isQuestion: Bool { !questions.isEmpty } /// The Allow and Deny pair shown on a feed card, when the server offers both. var quickOptions: (allow: Option, deny: Option)? { guard let allow = options.first(where: { $0.decision == .accept }), let deny = options.first(where: { $0.decision == .decline }) else { return nil } return (allow, deny) } /// One line saying what the agent wants, for a feed card. var summary: String { if let question = questions.first { return question.question } if let prompt, !prompt.isEmpty { return prompt } if let reason, !reason.isEmpty { return reason } return switch kind { case "command": "Run a command" case "file-change", "file_change": "Change files" default: "Approve a request" } } } extension PendingRequest.Question { private enum CodingKeys: String, CodingKey { case id, header, question, options, allowCustomAnswer, multiSelect } /// `multiSelect` is newer than the rest, so a gateway that leaves it out means single-select. init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.init( id: try container.decode(String.self, forKey: .id), header: try container.decode(String.self, forKey: .header), question: try container.decode(String.self, forKey: .question), options: try container.decode([Choice].self, forKey: .options), allowCustomAnswer: try container.decode(Bool.self, forKey: .allowCustomAnswer), multiSelect: try container.decodeIfPresent(Bool.self, forKey: .multiSelect) ?? false) } } struct FeedItem: Codable, Hashable, Sendable, Identifiable { /// The subagent a request came from, when a thread delegated work and that work waits on you. struct Via: Codable, Hashable, Sendable { var hostId: String var threadId: String var title: String } var hostId: String var hostLabel: String var threadId: String var projectId: String var projectTitle: String var title: String var phase: Phase var updatedAt: Date var lastMessage: String? var request: PendingRequest? /// Set when `request` and `phase` belong to a subagent rather than this thread. Older gateways /// leave it out, which decodes as nil. var via: Via? = nil /// What a working thread is doing right now. Nil when nothing is in progress, and from older /// gateways. var activity: Activity? = nil /// What the latest run changed, in total. Nil when it changed no files, and from older gateways. var changes: ChangeTotals? = nil var id: String { "\(hostId)/\(threadId)" } var route: Route { .thread(hostId: hostId, threadId: threadId) } /// The thread that answers `request`: the subagent named in `via`, or this thread. var requestThread: (hostId: String, threadId: String) { via.map { ($0.hostId, $0.threadId) } ?? (hostId, threadId) } } /// One step a thread is taking now, for the working card's activity line. struct Activity: Codable, Hashable, Sendable { struct Kind: RawRepresentable, Codable, Hashable, Sendable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } static let command = Kind(rawValue: "command") static let edit = Kind(rawValue: "edit") static let search = Kind(rawValue: "search") static let web = Kind(rawValue: "web") static let subagent = Kind(rawValue: "subagent") static let thinking = Kind(rawValue: "thinking") static let tool = Kind(rawValue: "tool") } var kind: Kind /// The command line, file name, search, subagent title or tool name, trimmed by the gateway. var text: String /// When the step started, for its elapsed time. var startedAt: Date? } /// What a thread's latest run changed, in total, for the line on a Recent card. struct ChangeTotals: Codable, Hashable, Sendable { var files: Int var additions: Int var deletions: Int /// "+42", "−8" and "3 files", the pieces of the card's "+42 −8 · 3 files". var parts: (additions: String, deletions: String, files: String) { ("+\(additions)", "\u{2212}\(deletions)", files == 1 ? "1 file" : "\(files) files") } } /// A file the latest run changed. `name` is the last part of `path`. struct ChangedFile: Codable, Hashable, Sendable, Identifiable { var path: String var name: String var additions: Int var deletions: Int var id: String { path } } /// A thread search hit: the thread, and the text around the match when it was inside a message. struct SearchHit: Codable, Hashable, Sendable, Identifiable { var thread: ThreadRow var projectTitle: String var hostLabel: String /// Nil when only the title matched. var snippet: String? var id: String { thread.id } } /// `GET /v1/search?q=`: hits from every online host, newest match first. struct SearchResponse: Codable, Hashable, Sendable { var hits: [SearchHit] /// Labels of hosts that were offline or failed, so their threads were not searched. var unsearched: [String] /// The line under the results naming the hosts left out, such as "Laptop is offline, so its /// threads were not searched." `offline` holds the labels of hosts the watch knows are offline; /// any other host failed or was too slow, which the gateway does not tell apart. static func unsearchedNote(_ labels: [String], offline: Set) -> String? { guard !labels.isEmpty else { return nil } let names: String = switch labels.count { case 1: labels[0] case 2: "\(labels[0]) and \(labels[1])" default: labels.dropLast().joined(separator: ", ") + " and " + labels[labels.count - 1] } guard labels.allSatisfy(offline.contains) else { return "\(names) could not be searched." } return labels.count == 1 ? "\(names) is offline, so its threads were not searched." : "\(names) are offline, so their threads were not searched." } } /// A thread in a list: a project's threads, or a thread's subagents. struct ThreadRow: Codable, Hashable, Sendable, Identifiable { var hostId: String var threadId: String var title: String /// Nil for a thread that never ran or was stopped. var phase: Phase? var settled: Bool var updatedAt: Date var id: String { "\(hostId)/\(threadId)" } var route: Route { .thread(hostId: hostId, threadId: threadId) } /// "working", "waiting", "settled", "failed" or "done", for a subagent's row. var statusWord: String { if phase?.isWorking == true { return "working" } if phase?.needsYou == true { return "waiting" } if settled { return "settled" } return phase == .failed ? "failed" : "done" } /// False once it finished or settled, which dims its title. var isActive: Bool { !settled && (phase?.isWorking == true || phase?.needsYou == true) } } /// `GET /v1/projects/:hostId/:projectId/threads`: a project's newest 50 threads, newest first. struct ThreadListResponse: Codable, Hashable, Sendable { var items: [ThreadRow] } /// `GET /v1/threads/:hostId/:threadId/history`: the page before the messages the watch has. struct HistoryResponse: Codable, Hashable, Sendable { /// Oldest first. var messages: [Message] /// The cursor for the page before these. Nil at the start of the thread. var earlierCursor: String? } struct Feed: Codable, Hashable, Sendable { var needsYou: [FeedItem] var working: [FeedItem] /// Finished or failed threads not settled yet, newest first and at most 10. var recent: [FeedItem] /// How many threads Recent holds in all. `GET /v1/recent` lists the ones past `recent`. var recentTotal: Int var hosts: [HostSummary] /// `recentTotal` defaults to the threads in `recent`, as when nothing is left out. init(needsYou: [FeedItem], working: [FeedItem], recent: [FeedItem], recentTotal: Int? = nil, hosts: [HostSummary]) { self.needsYou = needsYou self.working = working self.recent = recent self.recentTotal = recentTotal ?? recent.count self.hosts = hosts } /// Recent threads the feed leaves out, which the "N more" row counts. var moreRecent: Int { max(0, recentTotal - recent.count) } } extension Feed { private enum CodingKeys: String, CodingKey { case needsYou, working, recent, recentTotal, hosts } /// `recentTotal` is newer than the rest, so a gateway that leaves it out has nothing past `recent`. init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.init( needsYou: try container.decode([FeedItem].self, forKey: .needsYou), working: try container.decode([FeedItem].self, forKey: .working), recent: try container.decode([FeedItem].self, forKey: .recent), recentTotal: try container.decodeIfPresent(Int.self, forKey: .recentTotal), hosts: try container.decode([HostSummary].self, forKey: .hosts)) } } /// `GET /v1/recent`: every unsettled finished thread, newest first. Older ones may lack `lastMessage`. struct RecentResponse: Codable, Hashable, Sendable { var items: [FeedItem] } /// `POST /v1/recent/settle`. `failed` counts threads T3 refused or whose host was offline. struct SettleRecentResponse: Codable, Hashable, Sendable { var settled: Int var failed: Int } /// `GET /v1/summary`: what the complication and the Smart Stack widget show. struct Summary: Codable, Hashable, Sendable { struct Next: Codable, Hashable, Sendable { var hostId: String var threadId: String var title: String var hostLabel: String /// The command it wants to run or its first question, trimmed. Nil until the gateway has read /// it, and from older gateways. var request: String? = nil var route: Route { .thread(hostId: hostId, threadId: threadId) } } /// The working thread updated most recently, with what it is doing. struct Current: Codable, Hashable, Sendable { var hostId: String var threadId: String var title: String var activity: Activity? var route: Route { .thread(hostId: hostId, threadId: threadId) } } var needsYou: Int var working: Int /// The thread that has waited longest for you. var next: Next? /// Nil when nothing is working, and from older gateways. var current: Current? = nil var hostsOffline: Int } extension Summary { /// The same summary worked out from a feed. The feed lists needs-you oldest request first, so its /// first item is the one that has waited longest, and working newest first. init(_ feed: Feed) { let next = feed.needsYou.first let current = feed.working.first self.init( needsYou: feed.needsYou.count, working: feed.working.count, next: next.map { Next(hostId: $0.hostId, threadId: $0.threadId, title: $0.title, hostLabel: $0.hostLabel, request: $0.request?.summary) }, current: current.map { Current(hostId: $0.hostId, threadId: $0.threadId, title: $0.title, activity: $0.activity) }, hostsOffline: feed.hosts.filter { $0.status != .online }.count) } } struct Message: Codable, Hashable, Sendable, Identifiable { struct Role: RawRepresentable, Codable, Hashable, Sendable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } static let user = Role(rawValue: "user") static let assistant = Role(rawValue: "assistant") static let system = Role(rawValue: "system") } var id: String var role: Role var text: String var createdAt: Date } struct ThreadDetail: Codable, Hashable, Sendable { var item: FeedItem /// Oldest first, at most 20. var messages: [Message] var canStop: Bool /// True when the thread asks before each action (T3's approval-required mode). Older gateways /// leave it out, which decodes as nil, and the screen hides the switch. var askFirst: Bool? = nil /// Pass to the history route for older messages. Nil when `messages` starts the thread, and from /// older gateways, which hides Load earlier. var earlierCursor: String? = nil /// Subagents and delegated threads this thread started, newest first. var subagents: [ThreadRow] = [] /// The files the latest run changed, most lines changed first. var changedFiles: [ChangedFile] = [] } extension ThreadDetail { private enum CodingKeys: String, CodingKey { case item, messages, canStop, askFirst, earlierCursor, subagents, changedFiles } /// `subagents` and `changedFiles` are newer than the rest, so a gateway that leaves them out lists none. init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.init( item: try container.decode(FeedItem.self, forKey: .item), messages: try container.decode([Message].self, forKey: .messages), canStop: try container.decode(Bool.self, forKey: .canStop), askFirst: try container.decodeIfPresent(Bool.self, forKey: .askFirst), earlierCursor: try container.decodeIfPresent(String.self, forKey: .earlierCursor), subagents: try container.decodeIfPresent([ThreadRow].self, forKey: .subagents) ?? [], changedFiles: try container.decodeIfPresent([ChangedFile].self, forKey: .changedFiles) ?? []) } } /// `POST /v1/threads/:hostId/:threadId/mode`. struct ThreadModeRequest: Codable, Sendable { var askFirst: Bool } struct ProjectsResponse: Codable, Hashable, Sendable { struct Host: Codable, Hashable, Sendable, Identifiable { var id: String var label: String var status: HostStatus var error: String? var projects: [Project] /// The providers that can start a thread now, each with its models. Empty from older /// gateways, which only start threads on the project's default model. var providers: [AgentProvider] = [] /// Projects a new thread can start in. A project without a default model is not offered. var launchable: [Project] { projects.filter { $0.model != nil } } func provider(_ instanceId: String) -> AgentProvider? { providers.first { $0.instanceId == instanceId } } /// True when this host still offers the model. func offers(_ model: ModelRef) -> Bool { provider(model.instanceId)?.models.contains { $0.slug == model.slug } ?? false } } struct Project: Codable, Hashable, Sendable, Identifiable { var id: String var title: String /// The model a new thread uses by default, such as `claude-opus-5-5`. var model: String? /// The provider `model` belongs to. Older gateways leave it out. var instanceId: String? = nil /// The default model as a pick, when the gateway names its provider. var defaultModel: ModelRef? { guard let model, let instanceId else { return nil } return ModelRef(instanceId: instanceId, slug: model) } } var hosts: [Host] } extension ProjectsResponse.Host { private enum CodingKeys: String, CodingKey { case id, label, status, error, projects, providers } /// `providers` is newer than the rest, so a gateway that leaves it out offers no model list. init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.init( id: try container.decode(String.self, forKey: .id), label: try container.decode(String.self, forKey: .label), status: try container.decode(HostStatus.self, forKey: .status), error: try container.decodeIfPresent(String.self, forKey: .error), projects: try container.decode([ProjectsResponse.Project].self, forKey: .projects), providers: try container.decodeIfPresent([AgentProvider].self, forKey: .providers) ?? []) } } /// An agent provider on a host, such as Claude or Codex, with the models a new thread can use. struct AgentProvider: Codable, Hashable, Sendable, Identifiable { struct Model: Codable, Hashable, Sendable, Identifiable { var slug: String /// Such as "Claude Opus 5.5". var name: String var id: String { slug } } var instanceId: String /// Such as "Claude" or "Codex". var name: String /// False when the provider cannot run in approval-required mode, so Ask before acting is hidden. var canAskFirst: Bool var models: [Model] var id: String { instanceId } } /// A model picked for a new thread: a slug and the provider instance it runs on. struct ModelRef: Codable, Hashable, Sendable { var instanceId: String var slug: String } /// How a route reaches its host, guessed by the gateway from the URL. struct RouteKind: RawRepresentable, Codable, Hashable, Sendable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } static let direct = RouteKind(rawValue: "direct") static let vpn = RouteKind(rawValue: "vpn") static let `public` = RouteKind(rawValue: "public") /// "direct", "VPN" or "public", for sentences like "Laptop is on its VPN route". var name: String { self == .vpn ? "VPN" : rawValue } var symbol: String { switch self { case .direct: "house.fill" case .vpn: "lock.shield.fill" default: "cloud.fill" } } } /// One way to reach a host. `Route` in the contract, renamed here because `Route` is the app's /// navigation. struct HostRoute: Codable, Hashable, Sendable { struct Result: RawRepresentable, Codable, Hashable, Sendable { let rawValue: String init(rawValue: String) { self.rawValue = rawValue } static let ok = Result(rawValue: "ok") static let failed = Result(rawValue: "failed") static let untried = Result(rawValue: "untried") } var url: String var kind: RouteKind /// From the latest connect. `untried` when an earlier route answered first. var result: Result var error: String? var checkedAt: Date? /// The URL without its scheme, such as `laptop.gib:3773`. var hostPort: String { guard let components = URLComponents(string: url), let host = components.host else { return url } return components.port.map { "\(host):\($0)" } ?? host } } /// One host as the status screens show it. struct HostStatusDetail: Codable, Hashable, Sendable, Identifiable { var id: String var label: String var status: HostStatus var error: String? /// The route in use. Null unless online. var activeUrl: String? /// Preferred first, the order the gateway tries them. var routes: [HostRoute] var onlineSince: Date? var lastOnlineAt: Date? /// When the gateway tries again, while it waits to retry. var nextRetryAt: Date? var tokenExpiresAt: Date? var serverVersion: String? var activeRoute: HostRoute? { routes.first { $0.url == activeUrl } } /// Online, but not on the first route. var onFallback: Bool { status == .online && activeUrl != nil && activeUrl != routes.first?.url } } /// What Apple answered for one push. struct PushResult: Codable, Hashable, Sendable { var ok: Bool var at: Date /// Apple's HTTP status, or 0 when the push never reached Apple. var status: Int /// Apple's reason, such as `BadDeviceToken`, or why the gateway could not send. var reason: String? } /// `GET /v1/status`: everything the settings screens show. struct StatusResponse: Codable, Hashable, Sendable { struct Gateway: Codable, Hashable, Sendable { var startedAt: Date /// False when the gateway has no APNs key, so nothing can be pushed. var pushConfigured: Bool var lastPush: PushResult? } /// The watch asking. struct Device: Codable, Hashable, Sendable { var name: String var pairedAt: Date /// True once the watch has sent its APNs token. var pushRegistered: Bool var apnsEnvironment: PushRegistration.Environment? /// Which pushes this watch gets. Older gateways leave it out, and the screen hides the switches. var notifications: NotificationPrefs? = nil } var hosts: [HostStatusDetail] var gateway: Gateway var device: Device } /// Which pushes a watch wants. `needsMe` covers approvals and questions on your own threads, and /// `subagents` covers the same from subagents. Host problems and test pushes always go out. struct NotificationPrefs: Codable, Hashable, Sendable { /// One switch, named as `PUT /v1/device/notifications` names it. enum Key: String, CaseIterable, Sendable { case needsMe, finished, failed, subagents var keyPath: WritableKeyPath { switch self { case .needsMe: \.needsMe case .finished: \.finished case .failed: \.failed case .subagents: \.subagents } } } var needsMe: Bool var finished: Bool var failed: Bool var subagents: Bool subscript(key: Key) -> Bool { get { self[keyPath: key.keyPath] } set { self[keyPath: key.keyPath] = newValue } } } struct PairRequest: Codable, Sendable { var code: String var deviceName: String } struct PairResponse: Codable, Sendable { var deviceId: String var deviceToken: String } struct PushRegistration: Codable, Sendable { enum Environment: String, Codable, Sendable { case sandbox, production } var apnsToken: String var apnsEnvironment: Environment } struct SendMessageRequest: Codable, Sendable { var text: String } enum RespondRequest: Encodable, Equatable, Sendable { /// One question's answer: an option value or typed text, or option values for a multi-select question. enum Answer: Encodable, Hashable, Sendable { case text(String) case values([String]) func encode(to encoder: any Encoder) throws { var container = encoder.singleValueContainer() switch self { case let .text(text): try container.encode(text) case let .values(values): try container.encode(values) } } } case decision(requestId: String, decision: ApprovalDecision) case answers(requestId: String, answers: [String: Answer]) private enum CodingKeys: String, CodingKey { case requestId, decision, answers } func encode(to encoder: any Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) switch self { case let .decision(requestId, decision): try container.encode(requestId, forKey: .requestId) try container.encode(decision, forKey: .decision) case let .answers(requestId, answers): try container.encode(requestId, forKey: .requestId) try container.encode(answers, forKey: .answers) } } } struct NewThreadRequest: Codable, Sendable { var hostId: String var projectId: String var text: String /// A model from the host's `providers`. Nil uses the project's default. var model: ModelRef? = nil /// Starts the thread in approval-required mode. Nil or false uses the host's default mode. var askFirst: Bool? = nil } struct NewThreadResponse: Codable, Sendable { var hostId: String var threadId: String } struct ErrorResponse: Codable, Sendable { var error: String } /// `question` on an `input` push that asks one single-choice question: its options, so the /// notification can offer each as a button. Answer with `[id: value]` on the respond route. struct PushQuestion: Codable, Hashable, Sendable { var id: String /// At most 4. var options: [PendingRequest.Question.Choice] /// Adds a spoken or typed answer. var allowCustomAnswer: Bool /// The question in a push's `userInfo`, or nil when the push has none or it does not decode. init?(userInfo: [AnyHashable: Any]) { guard let object = userInfo["question"], JSONSerialization.isValidJSONObject(object), let data = try? JSONSerialization.data(withJSONObject: object), let question = try? JSON.decoder().decode(Self.self, from: data) else { return nil } self = question } init(id: String, options: [PendingRequest.Question.Choice], allowCustomAnswer: Bool) { self.id = id self.options = options self.allowCustomAnswer = allowCustomAnswer } } /// Notification categories the watch registers actions for. enum PushCategory: String, CaseIterable, Sendable { case approval, finished, failed, input, host, test } enum JSON { static func decoder() -> JSONDecoder { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .custom { decoder in let container = try decoder.singleValueContainer() let string = try container.decode(String.self) guard let date = parseDate(string) else { throw DecodingError.dataCorruptedError( in: container, debugDescription: "Not an ISO 8601 date: \(string)") } return date } return decoder } static func encoder() -> JSONEncoder { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .custom { date, encoder in var container = encoder.singleValueContainer() try container.encode(Date.ISO8601FormatStyle(includingFractionalSeconds: true).format(date)) } return encoder } /// Parses ISO 8601 timestamps with or without fractional seconds. static func parseDate(_ string: String) -> Date? { let withFraction = Date.ISO8601FormatStyle(includingFractionalSeconds: true) if let date = try? withFraction.parse(string) { return date } return try? Date.ISO8601FormatStyle().parse(string) } }