> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cskn.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# App messages

> Send a message to another mini app and get its answer back.

One mini app can send another a message, addressed by the other app's slug. The shell carries it. If the other app is open, the message reaches it without the screen changing. If it is closed, the shell opens it in the foreground and delivers the message once the app is listening. Whatever the receiving app's handler returns comes back to the sender.

```tsx theme={null}
import { sendToApp } from '@cskn/sdk';

async function saveAsNote(title: string, body: string) {
  const result = await sendToApp('notes', 'notes.create', { title, body }, { returnOnReply: true });
  if (result.error) return showError(result.error);
  toast(`Saved as note ${result.data.id}`);
}
```

The receiving app handles the action:

```tsx theme={null}
import { useAppMessage } from '@cskn/sdk';

function NotesRoot() {
  useAppMessage('notes.create', async ({ from, data }) => {
    const note = await createNote(data, { source: from.slug });
    return { id: note.id };
  });
  return <Notes />;
}
```

## How a message is delivered

1. The shell checks that the target app accepts the action from your app (see [Accepting messages](#accepting-messages)). If it doesn't, the answer is `not_accepted` and the target is never opened.
2. The first time your app messages a given app, the shell asks the user ("Let CV talk to Notes?"). Once they allow it, your app can message that app again without asking, whatever the action. Permission is one way: Notes messaging CV is asked separately.
3. If the target is open, the message is delivered in the background. If it is closed, the shell opens it in the foreground first.
4. The target's handler runs and its return value becomes your `result.data`.

<Note>
  Opening a closed app, or bringing an open one forward with `focus`, should follow a tap. If the user didn't just press something, the shell asks again even for a pair they already allowed. A background app can't open other apps.
</Note>

## Sending

<ParamField path="to" type="string" required>
  The target app's slug.
</ParamField>

<ParamField path="action" type="string" required>
  What you're asking for, such as `notes.create`. Letters, digits, `.`, `_` and `-`, up to 64 characters.
</ParamField>

<ParamField path="data" type="unknown">
  Any JSON value, up to 256 KB serialized.
</ParamField>

<ParamField path="options.launch" type="boolean" default="true">
  Open the target if it is closed. With `false`, a closed target answers `not_open`.
</ParamField>

<ParamField path="options.focus" type="boolean" default="false">
  Bring the target forward even when it is already open, for example so the user can pick something in it.
</ParamField>

<ParamField path="options.returnOnReply" type="boolean" default="false">
  When the target answers, switch back to your app. This only happens if the target was brought forward for this message and is still on screen.
</ParamField>

<ParamField path="options.timeoutMs" type="number" default="30000">
  How long to wait for the answer once the message is delivered, up to 300000. Time spent on the permission prompt or opening the target doesn't count.
</ParamField>

`sendToApp` never rejects. It resolves with one of these:

<ResponseField name="data" type="unknown">
  What the target's handler returned.
</ResponseField>

<ResponseField name="error" type="AppMessageError">
  Why there is no answer. See [Errors](#errors).
</ResponseField>

<ResponseField name="handedOff" type="boolean">
  The message was delivered, but this shell can't send an answer back (TV).
</ResponseField>

## Receiving

`onAppMessage(action, handler)` registers a handler and returns an unsubscribe function. `useAppMessage(action, handler)` does the same for the lifetime of a component and always calls the latest handler. Pass `'*'` to handle every action that has no handler of its own.

The handler receives:

<ResponseField name="from" type="{ slug: string; name?: string }">
  The sending app. The shell sets this, so the sender can't impersonate another app.
</ResponseField>

<ResponseField name="action" type="string">The requested action.</ResponseField>
<ResponseField name="data" type="unknown">The sender's payload.</ResponseField>
<ResponseField name="launched" type="boolean">`true` when your app was opened to receive this message.</ResponseField>

Return a JSON value (or a promise of one) to answer. If the handler throws, the sender gets `handler_failed`.

Register handlers early, at startup or in the screen that handles the action. The shell holds messages for your app until the first handler is registered. A message for an action without a handler waits 5 seconds for one, so a screen that loads lazily still gets it, and is then answered `no_handler`.

## Accepting messages

An app only receives the actions it declares. The declaration is part of the app's record in the catalogue:

```json theme={null}
{
  "messages": {
    "accepts": {
      "notes.create": { "label": { "en": "Add a note", "tr": "Not ekle" } },
      "notes.search": { "from": ["cv", "daily"], "launch": false }
    }
  }
}
```

<ParamField path="label" type="string | Record<string, string>">
  How the permission prompt names the action, as one string or one per language.
</ParamField>

<ParamField path="from" type="string[]">
  Only these apps (by slug) may send the action. Leave it out to accept any app.
</ParamField>

<ParamField path="launch" type="boolean" default="true">
  With `false`, the action is only delivered while your app is already open.
</ParamField>

<Note>
  You can't set this from your app's code yet. Ask the cskn team to add the actions your app accepts.
</Note>

## Errors

| `error` | Meaning |
| - | - |
| `not_available` | This surface doesn't carry app messages (browser, older app version). |
| `unknown_app` | No app with that slug for this user. |
| `not_activated` | The user hasn't added the target app. Nothing opens. To send them to its store page, use [`openDeepLink`](/sdk/native/deep-links). |
| `not_accepted` | The target doesn't accept this action from your app. |
| `not_open` | The target is closed and may not be opened for this message. |
| `declined` | The user said no. |
| `busy` | Your app already has a permission prompt on screen. |
| `no_handler` | The target never handled the action. |
| `handler_failed` | The target's handler threw. |
| `target_closed` | The target closed or reloaded before answering. |
| `timeout` | No answer within `timeoutMs`. |
| `too_large` | The payload or the answer is over 256 KB. |
| `rate_limited` | More than 20 messages in 10 seconds, or too many messages waiting for the target. |
| `invalid` | A malformed slug or action, or data that isn't JSON. |

## Surfaces

| Surface | Behavior |
| - | - |
| Mobile, desktop | Full support. On desktop, the target can be in the main window or in a window of its own; a closed target opens in the main window. |
| TV | One way. Only one app is open at a time, so opening the target replaces your app. You get `handedOff: true` before that happens, and the target's answer is dropped. `getAppMessagingAvailability()` reports `replies: false`. |
| Browser | `sendToApp` resolves `not_available` at once. `onAppMessage` registers nothing. |

Check ahead of time with `getAppMessagingAvailability()`, which resolves `{ available, replies }`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.