Push notifications
Push notifications tell your users that a long-running agent has finished or needs their input, even after they close the app. The agent can check whether the user is still on the channel and, if the user has left, publish a message with a push payload to the channel.
Ably Push sends the notification to the user's devices, including devices that are not connected to the conversation's Ably channel. A research or coding agent can work for minutes, so the user may close the app before the response ends.
Notify an absent user
Typically, a notification for a long task follows these steps:
- The user sends a prompt that starts a long task, such as research, code generation, or data analysis.
- The user closes the app or goes somewhere else.
- The agent finishes the response. The response holds the result, or ends with a question or an approval request for the user.
- The agent checks whether the user is still on the channel.
- If the user has left, the agent publishes a message with a push payload, and Ably sends the notification to every device that the user subscribed to the channel.
- The user taps the notification, and your app opens the conversation.
Decide when to push
An agent might want to send a notification only if the user has left the channel.
After pipe resolves, the agent can check for the user with its HTTP client from createHttpClient. The HTTP client does not attach the channel, so occupancy does not count the agent as a subscriber. The agent can check the user's presence or the channel's occupancy:
| Check | What the user's client does | What it tells the agent | Capability on the agent's key |
|---|---|---|---|
HTTP presence.get({ clientId }) | Enters presence | Whether this user has a client in the presence set | presence-subscribe |
HTTP channel.status() | Subscribes to the channel | How many connections receive the channel's messages | channel-metadata |
Occupancy counts connections. On a channel that several users share, the count cannot show whether one particular user left.
A connected client stays in the presence set and counts as a subscriber whether or not the user is looking at it. With either check, the agent does not notify a user whose app is open in a background browser tab. If the agent checks presence, your client can leave presence when the user hides the app, so that the agent treats a hidden app as absent.
This agent code checks whether the user's client is in the presence set:
1
2
3
4
5
6
// channelName is the conversation's channel, and userId is the user's clientId.
import { createHttpClient } from '@ably/pubsub-server';
const httpClient = createHttpClient({ key: process.env.ABLY_API_KEY });
const { items } = await httpClient.channels.get(channelName).presence.get({ clientId: userId });
const userAway = items.length === 0;For a conversation with one user, the agent can read occupancy instead:
1
2
const { status } = await httpClient.channels.get(channelName).status();
const userAway = status.occupancy.metrics.subscribers === 0;Send the notification
The notification is a message with a push payload in extras.push. The agent can publish it with channel.publish, on the channel that the adaptor uses. The agent should publish it after pipe resolves, so the whole response is on the channel before the notification reaches the user.
In this example, the push payload holds a notification with a title and a body, and data with the channel name, so the client can open the conversation. Ably maps each payload field to APNs, FCM, and web push.
The title and body can say that the response is ready, or ask the user to come back and answer the question or approval request that the response ends with. If the agent made an artifact, such as a report or a table, the notification can say that the artifact is ready, and data can carry what your app needs to open it.
The message should have a name that the response's messages do not use, such as notification, so the client can identify and skip it. The codec publishes the response's messages with the name ai by default.
Push notifications need three settings:
| Setting | Where | Why |
|---|---|---|
| Push notifications enabled | The channel rule on your conversation channels, next to Message annotations, updates, deletes, and appends | Ably sends a notification for a message with a push payload only on a channel whose rule enables push |
push-subscribe capability | The user's token | The client needs it to register the device and subscribe it to the channel's notifications |
clientId | The user's token | subscribeClient subscribes every device registered under the user's clientId |
This agent code, in the agent's HTTP endpoint, publishes the prompt, streams the response and publishes the notification if the user has left.:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// This runs on your server, so it holds the API key.
import OpenAI from 'openai';
import { createHttpClient, createRealtimeClient } from '@ably/pubsub-server';
import { openaiAdaptor } from '@ably/ai-transport/openai';
const httpClient = createHttpClient({ key: process.env.ABLY_API_KEY });
export async function POST(req) {
const { channelName, userId, requestId, items } = await req.json();
const headers = { requestId };
const agentClient = createRealtimeClient({ key: process.env.ABLY_API_KEY });
const channel = agentClient.channels.get(channelName);
const adaptor = openaiAdaptor(channel);
try {
await adaptor.send({ type: 'input', items }, { headers });
const stream = await new OpenAI().responses.create({
model: 'gpt-5.5',
input: [...(await loadConversation(channelName)), ...items],
stream: true,
});
const { serial } = await adaptor.pipe(stream, { headers });
await saveResponse(channelName, serial);
const present = await httpClient.channels.get(channelName).presence.get({ clientId: userId });
if (present.items.length === 0) {
await channel.publish({
name: 'notification',
data: { requestId },
extras: {
headers,
push: {
notification: { title: 'Your report is ready', body: 'Tap to open the conversation' },
data: { channelName },
},
},
});
}
} finally {
agentClient.close();
}
return new Response(null, { status: 204 });
}Register the device
Your client should register the user's device for push once, so that Ably can send notifications to it. To register, the client creates its Ably client with the Push plugin from @ably/pubsub-device/push, and calls client.push.activate(). In a browser, the client also needs a service worker, which the pushServiceWorkerUrl option points to. In React Native, the client creates the plugin with ReactNativePush.create() from @ably/pubsub-device/react-native-push.
The client then subscribes the user to the conversation's channel with channel.push.subscribeClient(). A subscription by clientId covers every device the user registered, so the notification reaches the user's phone even if the user started the task on a laptop. Your client can subscribe when a conversation starts a long task, and call unsubscribeClient() when the user no longer wants notifications for that conversation.
The client learns its clientId from the token after it connects, so it should wait for the connection before it activates the device and subscribes. This client code registers the device and subscribes the user to the conversation's channel, channelName:
1
2
3
4
5
6
7
8
9
10
// Run it after the user signs in, so the token carries the user's clientId.
import { createClient } from '@ably/pubsub-device';
import { Push } from '@ably/pubsub-device/push';
const client = createClient({ authUrl: '/auth', plugins: { Push }, pushServiceWorkerUrl: '/service-worker.js' });
await client.connection.whenState('connected');
await client.push.activate();
// When the conversation starts a long task:
await client.channels.get(channelName).push.subscribeClient();Open the conversation from the notification
The notification's data carries the channel name. When the user taps the notification, your app can open that conversation. It can load the conversation from your database, then rewind the channel to the serial you stored, so the client receives every message the agent published after that serial. History and replay has a full example.
The notification message stays on the channel like any other message, so the client receives it live, in history, and in a rewind. The codec did not write the message, so the decoder passes it to onEvent as { event: undefined, message }, and the client can skip it by its name.
This client code skips the notification message on the client's decoder, and passes every other decoded event and its message to apply, your code that merges the events into the messages your UI uses:
1
2
3
4
decoder.onEvent(({ event, message }) => {
if (event === undefined && message.name === 'notification') return;
apply({ event, message });
});Edge cases
- With the presence check, a user whose client on any device has entered presence is present, so the agent does not notify any of the user's devices. With the occupancy check, the agent treats the user as present while any connection is subscribed to the channel.
- A device that registered before the user signed in does not have a
clientId, sosubscribeClientdoes not cover it. Your client should register the device after sign-in, or subscribe that device withsubscribeDevice. - A device does not receive notifications until it calls
push.activate(). In React Native, your app should pass a rotated platform token to Ably withpush.updateToken(), so that the device keeps receiving notifications. - Every device subscribed under the user's
clientIdgets the notification. To notify one device only, your client can subscribe that device withsubscribeDevicein place ofsubscribeClient. - A message without
extras.pushdoes not trigger a notification, so the response's messages do not trigger one. - On a channel whose rule does not have Push notifications enabled, the response reaches the channel and the user does not get a notification.
- The notification message stays in history like any other message, and a rewind includes it in its limit of 1,000 messages.
FAQ
Do I need a separate notification channel?
No. The agent publishes the notification to the conversation's channel, and Ably sends a notification only for the message with a push payload.
Does AI Transport send the notification?
No. The agent publishes the message that carries the push payload with channel.publish, because the adaptor's options set only extras.headers.
Can I make the notification message ephemeral so it stays out of history?
No. Ably delivers an ephemeral message only to the clients connected at that moment, and the user you notify has left. The agent publishes a normal message, and the client can skip it by its message name wherever it arrives.
What happens if the user opens a new device after the response finished?
A device that subscribes after the agent published the notification does not get that notification. Your app can show the finished response on that device when it loads the conversation.
Can I notify one user directly instead?
Yes. The agent can send the notification to the user's clientId with the push admin API, using httpClient.push.admin.publish({ clientId: userId }, payload) on its HTTP client. The device then does not need a channel subscription, and the agent's key needs the push-admin capability on the * resource.
Related features
- Presence: check from the agent whether the user is still on the channel.
- History and replay: load the finished conversation when the user returns.
- Configure the channel rule: enable push notifications on your conversation channels.
- Ably Push: the full reference for push notifications.