diff --git a/apps/ios/ActivityWidget/Assets.xcassets/Contents.json b/apps/ios/ActivityWidget/Assets.xcassets/Contents.json
new file mode 100644
index 00000000000..73c00596a7f
--- /dev/null
+++ b/apps/ios/ActivityWidget/Assets.xcassets/Contents.json
@@ -0,0 +1,6 @@
+{
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/apps/ios/ActivityWidget/Info.plist b/apps/ios/ActivityWidget/Info.plist
new file mode 100644
index 00000000000..4e12dc4f884
--- /dev/null
+++ b/apps/ios/ActivityWidget/Info.plist
@@ -0,0 +1,31 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ OpenClaw Activity
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundlePackageType
+ XPC!
+ CFBundleShortVersionString
+ 2026.3.2
+ CFBundleVersion
+ 20260301
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.widgetkit-extension
+
+ NSSupportsLiveActivities
+
+
+
diff --git a/apps/ios/ActivityWidget/OpenClawActivityWidgetBundle.swift b/apps/ios/ActivityWidget/OpenClawActivityWidgetBundle.swift
new file mode 100644
index 00000000000..424a97c1982
--- /dev/null
+++ b/apps/ios/ActivityWidget/OpenClawActivityWidgetBundle.swift
@@ -0,0 +1,9 @@
+import SwiftUI
+import WidgetKit
+
+@main
+struct OpenClawActivityWidgetBundle: WidgetBundle {
+ var body: some Widget {
+ OpenClawLiveActivity()
+ }
+}
diff --git a/apps/ios/ActivityWidget/OpenClawLiveActivity.swift b/apps/ios/ActivityWidget/OpenClawLiveActivity.swift
new file mode 100644
index 00000000000..836803f403f
--- /dev/null
+++ b/apps/ios/ActivityWidget/OpenClawLiveActivity.swift
@@ -0,0 +1,84 @@
+import ActivityKit
+import SwiftUI
+import WidgetKit
+
+struct OpenClawLiveActivity: Widget {
+ var body: some WidgetConfiguration {
+ ActivityConfiguration(for: OpenClawActivityAttributes.self) { context in
+ lockScreenView(context: context)
+ } dynamicIsland: { context in
+ DynamicIsland {
+ DynamicIslandExpandedRegion(.leading) {
+ statusDot(state: context.state)
+ }
+ DynamicIslandExpandedRegion(.center) {
+ Text(context.state.statusText)
+ .font(.subheadline)
+ .lineLimit(1)
+ }
+ DynamicIslandExpandedRegion(.trailing) {
+ trailingView(state: context.state)
+ }
+ } compactLeading: {
+ statusDot(state: context.state)
+ } compactTrailing: {
+ Text(context.state.statusText)
+ .font(.caption2)
+ .lineLimit(1)
+ .frame(maxWidth: 64)
+ } minimal: {
+ statusDot(state: context.state)
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func lockScreenView(context: ActivityViewContext) -> some View {
+ HStack(spacing: 8) {
+ statusDot(state: context.state)
+ .frame(width: 10, height: 10)
+ VStack(alignment: .leading, spacing: 2) {
+ Text("OpenClaw")
+ .font(.subheadline.bold())
+ Text(context.state.statusText)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ Spacer()
+ trailingView(state: context.state)
+ }
+ .padding(.vertical, 4)
+ }
+
+ @ViewBuilder
+ private func trailingView(state: OpenClawActivityAttributes.ContentState) -> some View {
+ if state.isConnecting {
+ ProgressView().controlSize(.small)
+ } else if state.isDisconnected {
+ Image(systemName: "wifi.slash")
+ .foregroundStyle(.red)
+ } else if state.isIdle {
+ Image(systemName: "antenna.radiowaves.left.and.right")
+ .foregroundStyle(.green)
+ } else {
+ Text(state.startedAt, style: .timer)
+ .font(.caption)
+ .monospacedDigit()
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ @ViewBuilder
+ private func statusDot(state: OpenClawActivityAttributes.ContentState) -> some View {
+ Circle()
+ .fill(dotColor(state: state))
+ .frame(width: 6, height: 6)
+ }
+
+ private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color {
+ if state.isDisconnected { return .red }
+ if state.isConnecting { return .gray }
+ if state.isIdle { return .green }
+ return .blue
+ }
+}
diff --git a/apps/ios/Config/Signing.xcconfig b/apps/ios/Config/Signing.xcconfig
index e0afd46aa7e..1285d2a38a4 100644
--- a/apps/ios/Config/Signing.xcconfig
+++ b/apps/ios/Config/Signing.xcconfig
@@ -4,6 +4,7 @@ OPENCLAW_IOS_SELECTED_TEAM = $(OPENCLAW_IOS_DEFAULT_TEAM)
OPENCLAW_APP_BUNDLE_ID = ai.openclaw.ios
OPENCLAW_WATCH_APP_BUNDLE_ID = ai.openclaw.ios.watchkitapp
OPENCLAW_WATCH_EXTENSION_BUNDLE_ID = ai.openclaw.ios.watchkitapp.extension
+OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID = ai.openclaw.ios.activitywidget
// Local contributors can override this by running scripts/ios-configure-signing.sh.
// Keep include after defaults: xcconfig is evaluated top-to-bottom.
diff --git a/apps/ios/Sources/Info.plist b/apps/ios/Sources/Info.plist
index 86556e094b0..b4d6ed3109a 100644
--- a/apps/ios/Sources/Info.plist
+++ b/apps/ios/Sources/Info.plist
@@ -54,6 +54,8 @@
OpenClaw needs microphone access for voice wake.
NSSpeechRecognitionUsageDescription
OpenClaw uses on-device speech recognition for voice wake.
+ NSSupportsLiveActivities
+
UIApplicationSceneManifest
UIApplicationSupportsMultipleScenes
diff --git a/apps/ios/Sources/LiveActivity/LiveActivityManager.swift b/apps/ios/Sources/LiveActivity/LiveActivityManager.swift
new file mode 100644
index 00000000000..b7be7597e35
--- /dev/null
+++ b/apps/ios/Sources/LiveActivity/LiveActivityManager.swift
@@ -0,0 +1,125 @@
+import ActivityKit
+import Foundation
+import os
+
+/// Minimal Live Activity lifecycle focused on connection health + stale cleanup.
+@MainActor
+final class LiveActivityManager {
+ static let shared = LiveActivityManager()
+
+ private let logger = Logger(subsystem: "ai.openclaw.ios", category: "LiveActivity")
+ private var currentActivity: Activity?
+ private var activityStartDate: Date = .now
+
+ private init() {
+ self.hydrateCurrentAndPruneDuplicates()
+ }
+
+ var isActive: Bool {
+ guard let activity = self.currentActivity else { return false }
+ guard activity.activityState == .active else {
+ self.currentActivity = nil
+ return false
+ }
+ return true
+ }
+
+ func startActivity(agentName: String, sessionKey: String) {
+ self.hydrateCurrentAndPruneDuplicates()
+
+ if self.currentActivity != nil {
+ self.handleConnecting()
+ return
+ }
+
+ let authInfo = ActivityAuthorizationInfo()
+ guard authInfo.areActivitiesEnabled else {
+ self.logger.info("Live Activities disabled; skipping start")
+ return
+ }
+
+ self.activityStartDate = .now
+ let attributes = OpenClawActivityAttributes(agentName: agentName, sessionKey: sessionKey)
+
+ do {
+ let activity = try Activity.request(
+ attributes: attributes,
+ content: ActivityContent(state: self.connectingState(), staleDate: nil),
+ pushType: nil)
+ self.currentActivity = activity
+ self.logger.info("started live activity id=\(activity.id, privacy: .public)")
+ } catch {
+ self.logger.error("failed to start live activity: \(error.localizedDescription, privacy: .public)")
+ }
+ }
+
+ func handleConnecting() {
+ self.updateCurrent(state: self.connectingState())
+ }
+
+ func handleReconnect() {
+ self.updateCurrent(state: self.idleState())
+ }
+
+ func handleDisconnect() {
+ self.updateCurrent(state: self.disconnectedState())
+ }
+
+ private func hydrateCurrentAndPruneDuplicates() {
+ let active = Activity.activities
+ guard !active.isEmpty else {
+ self.currentActivity = nil
+ return
+ }
+
+ let keeper = active.max { lhs, rhs in
+ lhs.content.state.startedAt < rhs.content.state.startedAt
+ } ?? active[0]
+
+ self.currentActivity = keeper
+ self.activityStartDate = keeper.content.state.startedAt
+
+ let stale = active.filter { $0.id != keeper.id }
+ for activity in stale {
+ Task {
+ await activity.end(
+ ActivityContent(state: self.disconnectedState(), staleDate: nil),
+ dismissalPolicy: .immediate)
+ }
+ }
+ }
+
+ private func updateCurrent(state: OpenClawActivityAttributes.ContentState) {
+ guard let activity = self.currentActivity else { return }
+ Task {
+ await activity.update(ActivityContent(state: state, staleDate: nil))
+ }
+ }
+
+ private func connectingState() -> OpenClawActivityAttributes.ContentState {
+ OpenClawActivityAttributes.ContentState(
+ statusText: "Connecting...",
+ isIdle: false,
+ isDisconnected: false,
+ isConnecting: true,
+ startedAt: self.activityStartDate)
+ }
+
+ private func idleState() -> OpenClawActivityAttributes.ContentState {
+ OpenClawActivityAttributes.ContentState(
+ statusText: "Idle",
+ isIdle: true,
+ isDisconnected: false,
+ isConnecting: false,
+ startedAt: self.activityStartDate)
+ }
+
+ private func disconnectedState() -> OpenClawActivityAttributes.ContentState {
+ OpenClawActivityAttributes.ContentState(
+ statusText: "Disconnected",
+ isIdle: false,
+ isDisconnected: true,
+ isConnecting: false,
+ startedAt: self.activityStartDate)
+ }
+}
diff --git a/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift b/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift
new file mode 100644
index 00000000000..d9d879c84b5
--- /dev/null
+++ b/apps/ios/Sources/LiveActivity/OpenClawActivityAttributes.swift
@@ -0,0 +1,45 @@
+import ActivityKit
+import Foundation
+
+/// Shared schema used by iOS app + Live Activity widget extension.
+struct OpenClawActivityAttributes: ActivityAttributes {
+ var agentName: String
+ var sessionKey: String
+
+ struct ContentState: Codable, Hashable {
+ var statusText: String
+ var isIdle: Bool
+ var isDisconnected: Bool
+ var isConnecting: Bool
+ var startedAt: Date
+ }
+}
+
+#if DEBUG
+extension OpenClawActivityAttributes {
+ static let preview = OpenClawActivityAttributes(agentName: "main", sessionKey: "main")
+}
+
+extension OpenClawActivityAttributes.ContentState {
+ static let connecting = OpenClawActivityAttributes.ContentState(
+ statusText: "Connecting...",
+ isIdle: false,
+ isDisconnected: false,
+ isConnecting: true,
+ startedAt: .now)
+
+ static let idle = OpenClawActivityAttributes.ContentState(
+ statusText: "Idle",
+ isIdle: true,
+ isDisconnected: false,
+ isConnecting: false,
+ startedAt: .now)
+
+ static let disconnected = OpenClawActivityAttributes.ContentState(
+ statusText: "Disconnected",
+ isIdle: false,
+ isDisconnected: true,
+ isConnecting: false,
+ startedAt: .now)
+}
+#endif
diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift
index 54548eb8d96..34826aefeaf 100644
--- a/apps/ios/Sources/Model/NodeAppModel.swift
+++ b/apps/ios/Sources/Model/NodeAppModel.swift
@@ -1695,6 +1695,7 @@ extension NodeAppModel {
self.operatorGatewayTask = nil
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
+ LiveActivityManager.shared.handleDisconnect()
self.gatewayHealthMonitor.stop()
Task {
await self.operatorGateway.disconnect()
@@ -1731,6 +1732,7 @@ private extension NodeAppModel {
self.operatorConnected = false
self.voiceWakeSyncTask?.cancel()
self.voiceWakeSyncTask = nil
+ LiveActivityManager.shared.handleDisconnect()
self.gatewayDefaultAgentId = nil
self.gatewayAgents = []
self.selectedAgentId = GatewaySettingsStore.loadGatewaySelectedAgentId(stableID: stableID)
@@ -1811,6 +1813,7 @@ private extension NodeAppModel {
await self.refreshAgentsFromGateway()
await self.refreshShareRouteFromGateway()
await self.startVoiceWakeSync()
+ await MainActor.run { LiveActivityManager.shared.handleReconnect() }
await MainActor.run { self.startGatewayHealthMonitor() }
},
onDisconnected: { [weak self] reason in
@@ -1818,6 +1821,7 @@ private extension NodeAppModel {
await MainActor.run {
self.operatorConnected = false
self.talkMode.updateGatewayConnected(false)
+ LiveActivityManager.shared.handleDisconnect()
}
GatewayDiagnostics.log("operator gateway disconnected reason=\(reason)")
await MainActor.run { self.stopGatewayHealthMonitor() }
@@ -1882,6 +1886,14 @@ private extension NodeAppModel {
self.gatewayStatusText = (attempt == 0) ? "Connecting…" : "Reconnecting…"
self.gatewayServerName = nil
self.gatewayRemoteAddress = nil
+ let liveActivity = LiveActivityManager.shared
+ if liveActivity.isActive {
+ liveActivity.handleConnecting()
+ } else {
+ liveActivity.startActivity(
+ agentName: self.selectedAgentId ?? "main",
+ sessionKey: self.mainSessionKey)
+ }
}
do {
diff --git a/apps/ios/SwiftSources.input.xcfilelist b/apps/ios/SwiftSources.input.xcfilelist
index 514ca732673..c94ef48fa32 100644
--- a/apps/ios/SwiftSources.input.xcfilelist
+++ b/apps/ios/SwiftSources.input.xcfilelist
@@ -62,3 +62,7 @@ Sources/Voice/VoiceWakePreferences.swift
../../Swabble/Sources/SwabbleKit/WakeWordGate.swift
Sources/Voice/TalkModeManager.swift
Sources/Voice/TalkOrbOverlay.swift
+Sources/LiveActivity/OpenClawActivityAttributes.swift
+Sources/LiveActivity/LiveActivityManager.swift
+ActivityWidget/OpenClawActivityWidgetBundle.swift
+ActivityWidget/OpenClawLiveActivity.swift
diff --git a/apps/ios/project.yml b/apps/ios/project.yml
index 1f3cad955bf..3cc4444ce09 100644
--- a/apps/ios/project.yml
+++ b/apps/ios/project.yml
@@ -38,6 +38,8 @@ targets:
dependencies:
- target: OpenClawShareExtension
embed: true
+ - target: OpenClawActivityWidget
+ embed: true
- target: OpenClawWatchApp
- package: OpenClawKit
- package: OpenClawKit
@@ -84,6 +86,7 @@ targets:
TARGETED_DEVICE_FAMILY: "1"
SWIFT_VERSION: "6.0"
SWIFT_STRICT_CONCURRENCY: complete
+ SUPPORTS_LIVE_ACTIVITIES: YES
ENABLE_APPINTENTS_METADATA: NO
ENABLE_APP_INTENTS_METADATA_GENERATION: NO
info:
@@ -115,6 +118,7 @@ targets:
NSLocationAlwaysAndWhenInUseUsageDescription: OpenClaw can share your location in the background when you enable Always.
NSMicrophoneUsageDescription: OpenClaw needs microphone access for voice wake.
NSSpeechRecognitionUsageDescription: OpenClaw uses on-device speech recognition for voice wake.
+ NSSupportsLiveActivities: true
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
@@ -164,6 +168,37 @@ targets:
NSExtensionActivationSupportsImageWithMaxCount: 10
NSExtensionActivationSupportsMovieWithMaxCount: 1
+ OpenClawActivityWidget:
+ type: app-extension
+ platform: iOS
+ configFiles:
+ Debug: Signing.xcconfig
+ Release: Signing.xcconfig
+ sources:
+ - path: ActivityWidget
+ - path: Sources/LiveActivity/OpenClawActivityAttributes.swift
+ dependencies:
+ - sdk: WidgetKit.framework
+ - sdk: ActivityKit.framework
+ settings:
+ base:
+ CODE_SIGN_IDENTITY: "Apple Development"
+ CODE_SIGN_STYLE: "$(OPENCLAW_CODE_SIGN_STYLE)"
+ DEVELOPMENT_TEAM: "$(OPENCLAW_DEVELOPMENT_TEAM)"
+ PRODUCT_BUNDLE_IDENTIFIER: "$(OPENCLAW_ACTIVITY_WIDGET_BUNDLE_ID)"
+ SWIFT_VERSION: "6.0"
+ SWIFT_STRICT_CONCURRENCY: complete
+ SUPPORTS_LIVE_ACTIVITIES: YES
+ info:
+ path: ActivityWidget/Info.plist
+ properties:
+ CFBundleDisplayName: OpenClaw Activity
+ CFBundleShortVersionString: "2026.3.2"
+ CFBundleVersion: "20260301"
+ NSSupportsLiveActivities: true
+ NSExtension:
+ NSExtensionPointIdentifier: com.apple.widgetkit-extension
+
OpenClawWatchApp:
type: application.watchapp2
platform: watchOS
diff --git a/changelog/fragments/ios-live-activity-status-cleanup.md b/changelog/fragments/ios-live-activity-status-cleanup.md
new file mode 100644
index 00000000000..06a6004080f
--- /dev/null
+++ b/changelog/fragments/ios-live-activity-status-cleanup.md
@@ -0,0 +1 @@
+- iOS: add Live Activity connection status (connecting/idle/disconnected) on Lock Screen and Dynamic Island, and clean up duplicate/stale activities before starting a new one (#33591) (thanks @mbelinky, @leepokai)