Most teams building on Linq treat the conversation as the whole product. The user texts your number, your agent replies, and everything the agent knows lives inside the thread. That is a fine default. But the thread is a text-only surface. It is bad at complex state: running totals, current status, the thing you want the user to glance at without scrolling back through twenty messages.
The Dynamic Island handles that well. It is persistent and always at the top of the screen. It holds one small piece of live state and updates it in place. Running both surfaces at once gives your agent a second channel that costs the user nothing to read.
One of our partners, BodyBuddy, shipped the first version of this we have seen in production. This post covers how the pattern works, why it depends on being iMessage-native, and what you need on both sides to build it.
What BodyBuddy built
BodyBuddy is an AI health accountability coach you text with. You connect HealthKit through their iOS app, and from then on the coaching happens over iMessage. Real texts from your coach, no notification spam. They launched at the start of the year and have run over 200k coaching texts since.
In their latest release, while you are texting the coach, your calories, meals, and current state stay live at the top of the screen in the Dynamic Island. Log a meal in the conversation and the calorie ring in the Island moves. The chat carries the conversation. The Island carries the state the conversation produces. Those side effects used to be invisible unless you scrolled.
Here it is running. The conversation is in Messages, and the Island stays live above it the whole time.
Nobody had done this before because of a platform detail. That detail is the whole point.
Why this depends on iMessage
A Live Activity renders in the Dynamic Island only when its owning app is not in the foreground. That is the normal case. You start a workout in a fitness app, background it, and the Island keeps the timer alive while you do other things. Reopen the app and the Island collapses, because the app is now on screen and owns the full display.
A conventional chat app cannot use this. If BodyBuddy's chat lived on a screen inside their own app, that screen would be in the foreground the whole time the user is chatting. Their own Live Activity would not show. The user would see the chat and nothing else.
BodyBuddy chats over iMessage. So the foreground app is Messages, and the BodyBuddy app is backgrounded. Its Live Activity owns the Island for the entire session. The user reads the conversation in Messages and the ambient state in the Island at the same time, from two different apps. Being iMessage-native, through Linq, is what makes the surface available at all.
The architecture
There are two independent channels to the same user, joined in your backend.
| Channel | Owns | Mechanism |
|---|---|---|
| Conversation | The back-and-forth | Linq: inbound message.received webhooks in, POST /v3/messages out |
| Ambient state | The glanceable status | ActivityKit: an APNs Live Activity push updates a ContentState your widget renders |
Linq never touches the Dynamic Island. ActivityKit never touches the thread. Your server is the join point. Here is the end-to-end loop for a single inbound text.
- The user texts your Linq number. Linq fires a
message.receivedwebhook at your endpoint. - Your agent runs. It reads the message, updates its model of the user (logs the meal, recomputes the calorie total, advances the streak), and decides on a reply.
- You send the reply back over Linq with
POST /v3/messages. - You send an ActivityKit update push to APNs, carrying the new
ContentState, targeted at that user's Live Activity push token. - iOS re-renders the Live Activity in place. The app is backgrounded behind Messages, so the update lands in the Dynamic Island the user is already looking at.
Steps 3 and 4 are independent and can fire in parallel. The reply is the conversation. The push is the ambient state.
The identity join
Everything above is standard plumbing. The one piece that is yours to solve is correlating a Linq handle with an ActivityKit push token. It sits between the two systems and neither one owns it.
The inbound webhook identifies the user by phone number. From the message.received payload:
{
"event_type": "message.received",
"data": {
"chat": { "id": "8f392755-...", "is_group": false },
"direction": "inbound",
"sender_handle": { "handle": "+12025559876", "service": "iMessage" },
"parts": [{ "type": "text", "value": "just had a chicken bowl, ~600 cal" }]
}
}
sender_handle.handle is your key. The app supplies the other half. When a user onboards through the BodyBuddy app (granting HealthKit, signing in), you already have their phone number and their app account. Store that mapping. When the app starts a Live Activity, register its push token against the same account:
let activity = try Activity.request(
attributes: CoachAttributes(userID: currentUser.id),
content: .init(state: initialState, staleDate: nil),
pushType: .token
)
for await tokenData in activity.pushTokenUpdates {
let token = tokenData.map { String(format: "%02x", $0) }.joined()
await api.registerLiveActivityToken(token, for: currentUser.id)
}
Now your backend has phone number → app user → live-activity push token. When a webhook arrives, resolve the handle to the user, look up their active token, and you know which Island to update. Verify the webhook signature before you trust any of it. Linq signs with the Standard Webhooks spec, the same as everywhere else in the API.
The widget: the DynamicIsland struct
The Island layout is a DynamicIsland inside your ActivityConfiguration. You define the same state in up to four presentations and the system picks one based on context. Compact when your activity is the only one live, minimal when it is sharing the Island, expanded on long-press.
ActivityConfiguration(for: CoachAttributes.self) { context in
// Lock Screen / banner presentation
CoachLockScreenView(state: context.state)
} dynamicIsland: { context in
DynamicIsland {
// Expanded (long-press), up to four regions
DynamicIslandExpandedRegion(.leading) {
CalorieRing(consumed: context.state.calories, goal: context.state.goal)
}
DynamicIslandExpandedRegion(.trailing) {
Label("\(context.state.streak)🔥", systemImage: "flame.fill")
}
DynamicIslandExpandedRegion(.bottom) {
Text(context.state.lastMeal)
}
} compactLeading: {
CalorieRing(consumed: context.state.calories, goal: context.state.goal)
.frame(width: 22)
} compactTrailing: {
Text("\(context.state.calories)")
} minimal: {
CalorieRing(consumed: context.state.calories, goal: context.state.goal)
}
.keylineTint(.green)
.widgetURL(URL(string: "bodybuddy://today"))
}
Two configuration calls are worth knowing. keylineTint(_:) tints the border glow around the Island so your activity is recognizable at a glance. widgetURL(_:) sets where a tap goes. Deep-link it into the relevant screen of your app so the Island doubles as a shortcut back in. DynamicIsland also exposes contentMargins(_:_:for:) if the defaults crowd your layout.
Keep the compact and minimal views to a single glanceable number or ring. They are a few points wide. Put detail in the expanded view, which appears only on a deliberate long-press.
The server: the update push
When your agent finishes handling a webhook, push the new state to APNs. It is a normal HTTP/2 request with Live-Activity-specific headers:
apns-topic: <your-bundle-id>.push-type.liveactivity
apns-push-type: liveactivity
apns-priority: 10
{
"aps": {
"timestamp": 1751990400,
"event": "update",
"content-state": {
"calories": 1240,
"goal": 2100,
"streak": 6,
"lastMeal": "Chicken bowl · 600 cal"
},
"stale-date": 1751994000
}
}
The content-state must match the ContentState your ActivityAttributes declares. Same fields, same types, or the update is dropped. event is update here. It is end when you want to tear the activity down, for example when a texting session goes idle, with an optional dismissal-date. Set a stale-date so the Island visibly goes stale if your backend goes quiet, rather than showing numbers the user cannot trust.
To make the Island appear the moment a session starts, without the user reopening the app, use push-to-start (iOS 17.2+). Register the app's pushToStartToken the same way you register the update token, then send a push with event: "start" that includes attributes-type, attributes, and the initial content-state. The first text of a session can spin the Island up server-side, and the last one can tear it down.
When to use it
This pattern earns its keep when your agent produces state the user wants to watch accumulate over a session. Calories and streaks here, but equally an order total as items get added, a delivery ETA, a balance, points remaining, or a checklist burning down. If the user would otherwise scroll the thread to reconstruct a number, pin it instead.
Skip it for one-shot Q&A or anything where the state is just the last message. The thread already shows that. The Dynamic Island is complementary not a place where you duplicate the last message.
It only pays off on an iMessage-native build. That is the one configuration where your ambient surface and your conversation surface are visible at the same time. If your users live inside your own app's chat UI, the Island collapses the moment they open it, and you have built a channel nobody can see. Delegating the thread to Messages, through Linq, is what frees up the rest of the screen for the Island.


