iOS Widgets skill for Codex
Widgets that are fresh without burning budget. Codex builds widgets that render from a small snapshot in the App Group container, request timelines with the right reload policy, and reload only the affected kind when the app's data changes. It adds interactive App Intents buttons, Dynamic Island and lock screen Live Activities with push-token updates from the server, and ends activities when the real event ends.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- ios-widgets Installs to ~/.agents/skills/ios-widgets/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install ios-widgets The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project ios-widgets Installs to .agents/skills/ios-widgets/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/ios-widgets.zipunzip -o ios-widgets.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) ios-widgets PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $ios-widgets, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds WidgetKit widgets and ActivityKit Live Activities for iOS - widget extensions, TimelineProvider and AppIntentTimelineProvider, timeline reload policies and budgets, App Group data sharing, configurable and interactive widgets with App Intents, lock screen accessory families, Live Activities with Dynamic Island layouts, push-token updates over APNs, and previews. Use when adding a home screen or lock screen widget, when a widget shows stale data or placeholders, when building a Live Activity for deliveries, rides or scores, or when updating activities from a server.
Use it when
- Adding a home screen, lock screen or StandBy widget
- Widgets show stale or placeholder content
- Making widgets configurable or interactive with App Intents
- Building or server-updating Live Activities and Dynamic Island layouts
Use something else when
- Regular alert notifications and token handling: use mobile-push-notifications
- Layout and state questions inside the main app's SwiftUI views: use swiftui-views
How to check the result
Every supported family renders in previews with real and empty data, an app change updates the widget within seconds, and a Live Activity starts, updates by push, goes stale and ends on device.
The mistake it prevents
Using a mistyped App Group suite name or a group missing from release profiles, so the widget reads an empty store and shows placeholders only on release builds.
Done well means: the widget renders instantly from data already on disk, timelines carry enough future entries that the system rarely needs to wake the extension, the app reloads timelines only when data actually changes, and Live Activities end themselves when the real-world event ends.
Before changing anything
- Read
AGENTS.md, then check targets in the Xcode project: is there a widget extension, which App Group it shares with the app, and the deployment target (interactive widgets andcontainerBackgroundneed iOS 17; push-to-start Live Activities need iOS 17.2). - Find where the app stores the data the widget shows; the widget cannot call into the running app and should not depend on the network to render.
- Confirm both targets have the same App Group entitlement and that release provisioning profiles include it.
Data flow
App (writes snapshot) --> App Group container (JSON file, UserDefaults suite, or shared SwiftData/SQLite)
--> WidgetCenter.shared.reloadTimelines(ofKind:)
Widget extension (TimelineProvider reads snapshot) --> entries --> SwiftUI views
- Write a small, widget-specific snapshot (the five items shown, not the whole database).
- Reload only the affected kind, only when the snapshot changed. Calling
reloadAllTimelines()on every app event wastes the daily budget. - If the widget must fetch from the network, do it in the provider with a short timeout and fall back to the last snapshot.
Timeline provider
struct OrderEntry: TimelineEntry {
let date: Date
let orders: [OrderSummary]
}
struct OrderProvider: TimelineProvider {
func placeholder(in context: Context) -> OrderEntry { .init(date: .now, orders: OrderSummary.samples) }
func getSnapshot(in context: Context, completion: @escaping (OrderEntry) -> Void) {
completion(.init(date: .now, orders: context.isPreview ? OrderSummary.samples : SharedStore.loadOrders()))
}
func getTimeline(in context: Context, completion: @escaping (Timeline<OrderEntry>) -> Void) {
let orders = SharedStore.loadOrders()
let entries = (0..<4).map { i in
OrderEntry(date: Calendar.current.date(byAdding: .minute, value: i * 15, to: .now)!, orders: orders)
}
completion(Timeline(entries: entries, policy: .atEnd))
}
}
struct OrderWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "OrderWidget", provider: OrderProvider()) { entry in
OrderWidgetView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget)
}
.configurationDisplayName("Orders")
.description("Your active orders at a glance.")
.supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular])
}
}
| Reload policy | Use when |
|---|---|
.atEnd | Entries cover a known window; ask again after the last |
.after(date) | Data changes on a schedule (next train, next calendar event) |
.never | Data changes only when the app writes it; app calls reloadTimelines |
- The system budgets refreshes (roughly a few dozen per day for a frequently viewed widget) and decides actual timing; requested dates are hints.
- For countdowns and clocks use
Text(date, style: .timer)orText(timerInterval:countsDown:); they tick without new entries. - Keep the extension light: it has a tight memory limit (tens of MB). Downscale images before writing them to the App Group.
Configurable and interactive widgets
- iOS 17+: use
AppIntentConfigurationwith anAppIntentTimelineProviderand aWidgetConfigurationIntentfor user-chosen parameters (which list, which account). - Interactive controls:
Button(intent:)andToggle(isOn:intent:)with anAppIntentwhoseperform()writes to the shared store and returns. The system reloads the widget after it runs. Only buttons and toggles are interactive; everything else deep links. - Deep links:
.widgetURL(_:)for the whole small widget,Linkfor regions in larger families; route them through the app’s URL router. - iOS 18 Control Center controls (
ControlWidget) follow the same App Intents model.
Live Activities
- Add
NSSupportsLiveActivities = YESto the app’s Info.plist. - Define attributes in a file shared by app and extension:
struct DeliveryAttributes: ActivityAttributes {
struct ContentState: Codable, Hashable {
var status: String
var eta: Date
}
var orderID: String
}
- Lay out lock screen and Dynamic Island in the widget extension:
ActivityConfiguration(for: DeliveryAttributes.self) { context in
DeliveryLockScreen(state: context.state)
} dynamicIsland: { context in
DynamicIsland {
DynamicIslandExpandedRegion(.leading) { Image(systemName: "box.truck") }
DynamicIslandExpandedRegion(.trailing) { Text(context.state.eta, style: .timer) }
DynamicIslandExpandedRegion(.bottom) { Text(context.state.status) }
} compactLeading: { Image(systemName: "box.truck") }
compactTrailing: { Text(context.state.eta, style: .timer).frame(width: 44) }
minimal: { Image(systemName: "box.truck") }
}
- Start from the app (in the foreground) and send the push token to your server:
let activity = try Activity.request(
attributes: DeliveryAttributes(orderID: id),
content: .init(state: .init(status: "Preparing", eta: eta), staleDate: eta.addingTimeInterval(1800)),
pushType: .token
)
Task { for await token in activity.pushTokenUpdates { await api.register(liveActivityToken: token, order: id) } }
- Update and end from the server with APNs:
apns-push-type: liveactivity,apns-topic: <bundle id>.push-type.liveactivity, payloadaps.eventofupdateorend,aps.timestamp(seconds),aps.content-statematchingContentState’s JSON keys exactly, optionalaps.stale-dateandaps.dismissal-date.
- A Live Activity runs up to 8 hours and can stay on the lock screen for up to 4 more after
ending. End it explicitly when the event ends (
activity.end(_:dismissalPolicy:)). - Content state is limited to about 4 KB; send status and numbers, not images.
- Users can disable Live Activities per app; check
ActivityAuthorizationInfo().areActivitiesEnabled. - Frequent updates use priority 10 sparingly; Apple throttles high-priority updates, so use priority 5 for non-urgent changes.
Push transport details (tokens, JWT auth, errors) are in mobile-push-notifications.
Previews and debugging
#Preview(as: .systemMedium) { OrderWidget() } timeline: {
OrderEntry(date: .now, orders: OrderSummary.samples)
OrderEntry(date: .now, orders: [])
}
- Run the widget extension scheme in Xcode to attach the debugger to the extension.
- Placeholder-only rendering on device usually means the provider crashed or exceeded memory; check the device console filtered by the extension’s process name.
- Test redacted rendering (
.privacySensitive(),.redacted(reason: .placeholder)) for locked devices showing private data.
Gotchas
- Each
kindstring is persistent identity; renaming it removes users’ placed widgets. UserDefaults(suiteName:)with a typo returns a working but separate store; both targets silently read different data. Centralize the suite name in a shared constant.- Accessory (lock screen) families render in vibrant monochrome; test with
widgetRenderingModeand do not rely on color. - Interactive widgets run the intent in the extension process; code it needs (models, store) must be in a framework or both targets.
Verification
- Every supported family renders in previews, light and dark, with empty data
- App change -> widget updates within seconds via
reloadTimelines(ofKind:) - Interactive button updates state with the app not running
- Live Activity: start, server update, stale date, end, and disabled-by-user paths
- Release build on device (App Group in release profiles)
Output
**Widgets:** <kind -> families, provider, reload policy, data source>
**Intents:** <configuration and interactive intents>
**Live Activity:** <attributes, start trigger, server update path, end condition>
**Tested:** <device, iOS version, families> -> result What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "iOS Widgets"
short_description: "Widgets that are fresh without burning budget"
default_prompt: "$ios-widgets Add a home screen and lock screen widget that shows today's tasks" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.