# 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](https://ably.com/docs/push.md) sends the notification to the user's devices, including devices that are not connected to the conversation's [Ably channel](https://ably.com/docs/channels.md). 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: 1. The user sends a prompt that starts a long task, such as research, code generation, or data analysis. 2. The user closes the app or goes somewhere else. 3. The agent finishes the response. The response holds the result, or ends with a question or an approval request for the user. 4. The agent checks whether the user is still on the channel. 5. 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. 6. The user taps the notification, and your app opens the [conversation](https://ably.com/docs/ai-transport/conversation.md). ## 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](https://ably.com/docs/pub-sub/api/javascript/rest/rest-client.md) 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](https://ably.com/docs/ai-transport/awareness/presence.md#check-from-agent) or the channel's [occupancy](https://ably.com/docs/presence-occupancy/occupancy.md): | 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: ### Javascript ``` // 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: ### Javascript ``` 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](https://ably.com/docs/ai-transport/api/javascript/agent-adaptor.md) 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](https://ably.com/docs/push/publish.md#payload) 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](https://ably.com/docs/ai-transport/artifacts.md), 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](https://ably.com/docs/ai-transport/internals/codec-architecture.md) 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](https://ably.com/docs/ai-transport/setup/channel-rules.md) 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](https://ably.com/docs/ai-transport/setup/authentication.md#capabilities) | 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`](#register) subscribes every device registered under the user's `clientId` | This agent code, in the agent's HTTP endpoint, publishes the [prompt](https://ably.com/docs/ai-transport/conversation.md#prompt), streams the response and publishes the notification if the user has left.: ### Vercel AI SDK Javascript ``` // This runs on your server, so it holds the API key. import { createHttpClient, createRealtimeClient } from '@ably/pubsub-server'; import { vercelAdaptor } from '@ably/ai-transport/vercel'; import { convertToModelMessages, streamText, toUIMessageStream } from 'ai'; const httpClient = createHttpClient({ key: process.env.ABLY_API_KEY }); export async function POST(req) { const { channelName, userId, message } = await req.json(); const headers = { requestId: message.id }; const agentClient = createRealtimeClient({ key: process.env.ABLY_API_KEY }); const channel = agentClient.channels.get(channelName); const adaptor = vercelAdaptor(channel); try { await adaptor.send({ type: 'user-message', message }, { headers }); const uiMessages = [...(await loadConversation(channelName)), message]; const messages = await convertToModelMessages(uiMessages); const result = streamText({ model, messages }); const { serial } = await adaptor.pipe( toUIMessageStream({ stream: result.fullStream, originalMessages: uiMessages, generateMessageId: () => crypto.randomUUID() }), { headers }, ); await saveResponse(channelName, serial); const { items } = await httpClient.channels.get(channelName).presence.get({ clientId: userId }); if (items.length === 0) { await channel.publish({ name: 'notification', data: { requestId: message.id }, 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 }); } ``` ### OpenAI Javascript ``` // 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](https://ably.com/docs/push/configure/device.md) 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](https://ably.com/docs/push/getting-started/web.md), 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`: ### Javascript ``` // 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](https://ably.com/docs/ai-transport/conversation/history.md#store-serial), so the client receives every message the agent published after that serial. [History and replay](https://ably.com/docs/ai-transport/conversation/history.md#joining) 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](https://ably.com/docs/channels/options/rewind.md). The codec did not write the message, so the [decoder](https://ably.com/docs/ai-transport/api/javascript/decoder.md) 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: ### Javascript ``` 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`, so `subscribeClient` does not cover it. Your client should register the device after sign-in, or subscribe that device with `subscribeDevice`. - A device does not receive notifications until it calls `push.activate()`. In React Native, your app should pass a rotated platform token to Ably with [`push.updateToken()`](https://ably.com/docs/push/configure/device.md#update-token), so that the device keeps receiving notifications. - Every device subscribed under the user's `clientId` gets the notification. To notify one device only, your client can subscribe that device with `subscribeDevice` in place of `subscribeClient`. - A message without `extras.push` does 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](https://ably.com/docs/ai-transport/awareness/ephemeral-messages.md) 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](#open) 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](https://ably.com/docs/push/publish.md#client-id), 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](https://ably.com/docs/ai-transport/awareness/presence.md): check from the agent whether the user is still on the channel. - [History and replay](https://ably.com/docs/ai-transport/conversation/history.md): load the finished conversation when the user returns. - [Configure the channel rule](https://ably.com/docs/ai-transport/setup/channel-rules.md): enable push notifications on your conversation channels. - [Ably Push](https://ably.com/docs/push.md): the full reference for push notifications. ## Related Topics - [Overview](https://ably.com/docs/ai-transport/awareness.md): Show users what an AI agent is doing right now, and let the agent check who is on the channel, with LiveObjects, ephemeral messages, presence, and occupancy on the channel the conversation already uses. - [Agent progress](https://ably.com/docs/ai-transport/awareness/agent-progress.md): Show your users the AI agent's plan and its progress through each step while it runs, with LiveObjects, ephemeral messages, and presence on the conversation's Ably channel. - [Presence](https://ably.com/docs/ai-transport/awareness/presence.md): Show whether the AI agent is thinking or streaming, show which users are on the channel and let the agent check whether the user is still there, with Ably presence and occupancy on the channel the conversation already uses. - [Ephemeral messages](https://ably.com/docs/ai-transport/awareness/ephemeral-messages.md): Send short-lived status from an AI agent, such as the tool the agent is calling, to every subscribed client with ephemeral messages, which Ably does not store. ## Documentation Index To discover additional Ably documentation: 1. Fetch [llms.txt](https://ably.com/llms.txt) for the canonical list of available pages. 2. Identify relevant URLs from that index. 3. Fetch target pages as needed. Avoid using assumed or outdated documentation paths.