// The JSON the watch app reads and sends. This is the contract between the gateway and the // watch's Codable models (watch/WristCode/API/Models.swift). docs/api.md describes it for humans. // Change both sides together. import type { Phase } from "./awareness.ts"; import type { ApprovalDecision } from "./t3/types.ts"; import type { HostStatus } from "./t3/host.ts"; export interface HostSummary { id: string; label: string; status: HostStatus; error: string | null; } /** * How a route reaches its host, guessed from the URL: `public` for HTTPS, `vpn` when the hostname * says vpn, and `direct` for the rest. */ export type RouteKind = "direct" | "vpn" | "public"; export interface Route { url: string; kind: RouteKind; /** From the latest connect. `untried` when an earlier route answered first. */ result: "ok" | "failed" | "untried"; /** Why it failed, such as `refused` or `no answer in 5 s`. */ error: string | null; checkedAt: string | null; } /** One host as the status screens show it. */ export interface HostStatusDetail extends HostSummary { /** The route in use. Null unless online. */ activeUrl: string | null; /** Preferred first, the order the gateway tries them. */ routes: Route[]; onlineSince: string | null; lastOnlineAt: string | null; /** When the gateway tries again, while it waits to retry. */ nextRetryAt: string | null; tokenExpiresAt: string | null; serverVersion: string | null; } /** What Apple answered for one push. */ export interface PushResult { ok: boolean; at: string; /** Apple's HTTP status, or 0 when the push never reached Apple. */ status: number; /** Apple's reason, such as `BadDeviceToken`, or why the gateway could not send. */ reason: string | null; } export interface StatusResponse { hosts: HostStatusDetail[]; gateway: { startedAt: string; /** False when the gateway has no APNs key, so nothing can be pushed. */ pushConfigured: boolean; lastPush: PushResult | null; }; /** The watch asking. */ device: { name: string; pairedAt: string; /** True once the watch has sent its APNs token. */ pushRegistered: boolean; apnsEnvironment: "sandbox" | "production" | null; notifications: NotificationPrefs; }; } /** * 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. */ export interface NotificationPrefs { needsMe: boolean; finished: boolean; failed: boolean; subagents: boolean; } /** Something a thread is waiting on: an approval, or questions for the user. */ export interface PendingRequest { id: string; /** `command`, `file-change`, `permission`, `user_input`, ... */ kind: string; /** What the agent wants to do, such as the command line. Null when the server gave none. */ prompt: string | null; reason: string | null; /** Approval choices, in the server's order. Empty for questions. `warning` flags a risky choice. */ options: Array<{ decision: ApprovalDecision; label: string; warning?: string }>; /** Questions for the user. Empty for approvals. */ questions: Array<{ id: string; header: string; question: string; options: Array<{ label: string; value: string }>; allowCustomAnswer: boolean; /** When true, answer with an array of option values. */ multiSelect: boolean; }>; } export interface FeedItem { hostId: string; hostLabel: string; threadId: string; projectId: string; projectTitle: string; title: string; phase: Phase; updatedAt: string; /** The agent's latest message, trimmed for the watch. Null until the gateway has read it. */ lastMessage: string | null; /** What a working thread is doing right now. Null when nothing is in progress or not read yet. */ activity: Activity | null; /** What the latest run changed, in total. Null when it changed no files or is not read yet. */ changes: ChangeTotals | null; request: PendingRequest | null; /** * Set when `request` comes from a subagent this thread delegated to, rather than the thread * itself. Answer the request on that thread. `phase` is then the subagent's. */ via: { hostId: string; threadId: string; title: string } | null; } /** One step a thread is taking now. */ export interface Activity { kind: "command" | "edit" | "search" | "web" | "subagent" | "thinking" | "tool"; /** The command line, file name, search, subagent title or tool name, trimmed. */ text: string; /** When the step started, for its elapsed time. */ startedAt: string | null; } export interface ChangeTotals { files: number; additions: number; deletions: number; } /** A file the latest run changed. `name` is the last part of `path`. */ export interface ChangedFile { path: string; name: string; additions: number; deletions: number; } /** A thread search hit: the thread, and where the words matched when it was inside a message. */ export interface SearchHit { thread: ThreadRow; projectTitle: string; hostLabel: string; /** Text around the match in a message, or null when only the title matched. */ snippet: string | null; } export interface SearchResponse { hits: SearchHit[]; /** Labels of hosts that were offline or failed, so their threads were not searched. */ unsearched: string[]; } /** A thread in a list: a project's threads, or a thread's subagents. */ export interface ThreadRow { hostId: string; threadId: string; title: string; /** Null for a thread that never ran or was stopped. */ phase: Phase | null; settled: boolean; updatedAt: string; } export interface ThreadListResponse { items: ThreadRow[]; } export interface HistoryResponse { /** Oldest first, just before the messages the watch already has. */ messages: Message[]; /** Pass to the history route for the page before these. Null at the start of the thread. */ earlierCursor: string | null; } /** Counts for the watch face complication. Cheap: no thread pages are read. */ export interface Summary { needsYou: number; working: number; /** The thread that has waited longest for you, if any. */ next: { hostId: string; threadId: string; title: string; hostLabel: string; /** The command it wants to run or its first question, trimmed. Null until the gateway has read it. */ request: string | null; } | null; /** The working thread updated most recently, with what it is doing. Null when nothing is working. */ current: { hostId: string; threadId: string; title: string; activity: Activity | null } | null; hostsOffline: number; } export interface Feed { /** Waiting for approval or input. Oldest request first. */ needsYou: FeedItem[]; /** Starting or running. Most recently updated first. */ working: FeedItem[]; /** Completed or failed and not yet settled. Most recent first, at most 10. */ recent: FeedItem[]; /** How many threads Recent holds in all. More than `recent.length` means `GET /v1/recent` has the rest. */ recentTotal: number; hosts: HostSummary[]; } export interface RecentResponse { /** Every unsettled finished thread, most recent first. */ items: FeedItem[]; } export interface SettleRecentResponse { settled: number; /** Threads T3 refused to settle, or whose host was offline. */ failed: number; } export interface Message { id: string; role: "user" | "assistant" | "system"; text: string; createdAt: string; } export interface ThreadDetail { item: FeedItem; /** Oldest first, at most 20. */ messages: Message[]; /** True while a run is active, so Stop makes sense. */ canStop: boolean; /** True when the thread runs in approval-required mode, asking before each action. */ askFirst: boolean; /** Pass to the history route for older messages. Null when `messages` starts the thread. */ earlierCursor: string | null; /** Subagents and delegated threads this thread started, newest first. */ subagents: ThreadRow[]; /** The files the latest run changed, most lines changed first. */ changedFiles: ChangedFile[]; } export interface ThreadModeRequest { askFirst: boolean; } export interface ProjectsResponse { hosts: Array< HostSummary & { projects: Array<{ id: string; title: string; /** The model a new thread will use, such as `claude-opus-5-5`. Null when none is configured. */ model: string | null; /** The provider instance `model` belongs to, matching an entry in `providers`. */ instanceId: string | null; }>; /** The providers on this host that can start a thread now, each with its models. */ providers: AgentProvider[]; } >; } export interface AgentProvider { instanceId: string; /** Such as `Claude` or `Codex`. */ name: string; /** False when the provider cannot run in approval-required mode. */ canAskFirst: boolean; models: Array<{ slug: string; name: string }>; } export interface PairRequest { code: string; deviceName: string; } export interface PairResponse { deviceId: string; deviceToken: string; } export interface PushRegistration { apnsToken: string; apnsEnvironment: "sandbox" | "production"; } export interface SendMessageRequest { text: string; } export type RespondRequest = | { requestId: string; decision: ApprovalDecision } /** Per question id: an option value, a typed answer, or for multi-select questions an array of option values. */ | { requestId: string; answers: Record }; export interface NewThreadRequest { hostId: string; projectId: string; text: string; /** A model from the host's `providers`. Absent uses the project's default. */ model?: { instanceId: string; slug: string }; /** Starts the thread in approval-required mode. Absent or false uses the host's default mode. */ askFirst?: boolean; } export interface NewThreadResponse { hostId: string; threadId: string; } export interface ErrorResponse { error: string; } /** Custom keys on every push notification, beside `aps`. */ export interface PushPayload { /** Absent on `test` pushes. */ hostId?: string; /** Absent on `host` pushes, which are about a host rather than a thread. */ threadId?: string; requestId?: string; /** * On `input` pushes that ask one single-choice question: its options, so the notification can * offer each as a button. `allowCustomAnswer` adds a spoken answer. */ question?: { id: string; options: Array<{ label: string; value: string }>; allowCustomAnswer: boolean }; } /** Notification categories the watch registers actions for. */ export type PushCategory = "approval" | "finished" | "failed" | "input" | "host" | "test";