---
name: permissionkit
description: "PermissionKit parental approval for minors: CommunicationLimits handle checks, PermissionQuestion, AskCenter requests and responses, PermissionButton, AskError, pending or expired requests. Use when building child communication safety, a child asking to allow a contact, or handling approvals."
metadata:
  source: multi-agent-pipeline
---

# PermissionKit

PermissionKit lets a child ask a parent or guardian to change the child's
communication rules for your app. The typical case: a parent has set
communication limits, the child wants to talk to someone new, and your app
turns that wish into a question the parent can approve or decline. The same
machinery also covers asking a parent to approve a significant app update.

What it is not:

- It is not a way to ask for in-app contact permission in general, and it is
  not a moderation or chat-safety framework. Use it only when the answer has
  to come from a parent or guardian.
- The request travels through iMessage and nothing else. Your own chat, DM or
  notification channel cannot carry it.
- It does not tell you what counts legally as a significant update in a given
  country. That call belongs to the developer, with qualified legal counsel.

Examples use Swift 6.3 against the iOS 26 SDK family. The API surface grew
across three point releases, so check every signature and `@available` line
against the Xcode 26 SDK you ship with before relying on it.

## Setup and availability

Add `@preconcurrency import PermissionKit`. The SwiftUI and UIKit/AppKit
pieces (`PermissionButton`, `AskCenter.ask(_:in:)`) come in through
cross-import overlays, so importing SwiftUI or UIKit next to PermissionKit is
enough. Most of the framework's types are not `Sendable`: `PermissionQuestion`
(a class), `PermissionResponse`, `PermissionChoice`, `CommunicationTopic`,
`AskCenter` and `CommunicationLimits` declare no conformance. The exceptions
are `CommunicationHandle`, which is `Sendable`, and the SwiftUI views
`PermissionButton` and `CommunicationLimitsButton`. Because the question is not
`Sendable`, under Swift 6 a plain import turns every `ask(_:in:)` call from the
main actor into a "sending 'question' risks causing data races" error;
`@preconcurrency` is what lets the examples below compile.

Do not add made-up entitlement keys for PermissionKit. If a signing
requirement exists, it will be in Apple's documentation and in the Xcode
capabilities list; confirm it there first.

When you review code or write guidance, name the exact tier instead of saying
"iOS 26+". Platforms for every tier: iOS, iPadOS, Mac Catalyst, macOS and
visionOS.

| Tier | What becomes available |
|---|---|
| 26.0 | `CommunicationTopic`, `CommunicationHandle`, `PermissionQuestion`, `PermissionResponse`, `PermissionChoice`, `CommunicationLimits` |
| 26.1 | `AskError` |
| 26.2 | `AskCenter`, `AskCenter.ask(_:in:)`, `AskCenter.responses(for:)`, `PermissionButton`, `SignificantAppUpdateTopic` (and the `AskError.notAvailable` case) |

Gate the 26.2 path explicitly:

```swift
if #available(iOS 26.2, macOS 26.2, visionOS 26.2, *) {
    try await AskCenter.shared.ask(question, in: hostController)
} else {
    showUpdateRequiredNotice()
}
```

## How a request flows

1. Inside your app, the child runs into a communication limit (for example,
   tries to message a player they have not been allowed to contact).
2. Your app builds a `PermissionQuestion`.
3. The system shows the question to the child, who can send it to a parent.
4. The parent approves or declines.
5. Your app later receives a `PermissionResponse` carrying that decision.

Types involved:

| Type | Role |
|---|---|
| `AskCenter` | Shared object (`AskCenter.shared`) that sends questions and delivers answers |
| `PermissionQuestion<Topic>` | What is being asked; has a `UUID` `id` |
| `PermissionResponse<Topic>` | The parent's decision, with `question` and `choice` |
| `PermissionChoice` | The concrete answer: approve or decline |
| `PermissionButton` | SwiftUI control that starts the ask flow when tapped |
| `CommunicationTopic` | Topic for communication requests |
| `CommunicationHandle` | Phone number, email address or custom identifier of a contact |
| `CommunicationLimits` | Tells you which handles the system already knows for your app |
| `SignificantAppUpdateTopic` | Topic for approving a significant app update |

## Known handles are not a limits check

`CommunicationLimits.current` answers one narrow question: does the system
already know this handle for my app? It does not tell you whether the parent
has turned communication limits on. The only reliable signal for "limits are
off" is `AskError.communicationLimitsNotEnabled`, thrown by
`AskCenter.shared.ask(_:in:)`, so that path always needs handling.

`knownHandles(in:)` needs a bundle identifier that is present and not empty.
Guard `Bundle.main.bundleIdentifier` before you call it.

```swift
@preconcurrency import PermissionKit

func unknownPlayers(_ ids: [String]) async -> Set<CommunicationHandle> {
    guard let bundleID = Bundle.main.bundleIdentifier, !bundleID.isEmpty else {
        return []
    }
    let handles = Set(ids.map { CommunicationHandle(value: $0, kind: .custom) })
    let known = await CommunicationLimits.current.knownHandles(in: handles)
    return handles.subtracting(known)
}

func isAlreadyKnown(email: String) async -> Bool {
    let handle = CommunicationHandle(value: email, kind: .emailAddress)
    return await CommunicationLimits.current.isKnownHandle(handle)
}
```

- `isKnownHandle(_:)` is async and returns `Bool` for one handle.
- `knownHandles(in:)` takes a `Set<CommunicationHandle>` and returns the
  subset the system knows.
- `CommunicationHandle(value:kind:)` kinds: `.phoneNumber`, `.emailAddress`,
  `.custom` (your own account or player id). Pick the kind that matches the
  value.

## Building a question

Quick forms, when a handle is all you have:

```swift
let single = PermissionQuestion<CommunicationTopic>(
    handle: CommunicationHandle(value: "+15550100", kind: .phoneNumber)
)
let group = PermissionQuestion<CommunicationTopic>(handles: [
    CommunicationHandle(value: "coach-17", kind: .custom),
    CommunicationHandle(value: "coach-18", kind: .custom),
])
```

A richer prompt shows the parent a name and, optionally, an avatar
(`CGImage`). Wrap each contact in `CommunicationTopic.PersonInformation`,
list the actions you actually support, then build the question from the
topic:

```swift
var name = PersonNameComponents()
name.givenName = "Rosa"
name.familyName = "Lindqvist"

let rosa = CommunicationTopic.PersonInformation(
    handle: CommunicationHandle(value: "player-4410", kind: .custom),
    nameComponents: name,
    avatarImage: nil
)
let topic = CommunicationTopic(personInformation: [rosa], actions: [.chat, .friend])
let question = PermissionQuestion<CommunicationTopic>(communicationTopic: topic)
```

`CommunicationTopic.Action` values:

| Action | Meaning |
|---|---|
| `.message` | Text messaging |
| `.audioCall` | Voice call |
| `.videoCall` | Video call |
| `.call` | A call of unspecified type |
| `.chat` | Chat |
| `.follow` | Follow someone |
| `.beFollowed` | Let someone follow the child |
| `.friend` | Friend request |
| `.connect` | Connection request |
| `.communicate` | Communication in general |

Only list actions your app really offers; the parent is approving exactly
those.

## Asking through AskCenter

`AskCenter.shared.ask(_:in:)` is `async throws`. It presents the system flow
in which the child sends the question; it does not return the parent's
answer. Decisions arrive later on `responses(for:)`. If the child backs out of
the send sheet, no `PermissionResponse` ever arrives, so a pending request
needs its own expiry or retry. `PermissionQuestion` has a settable
`expirationDate: Date?` (nil by default); once that date passes, the parent
can no longer respond. Set it before asking to bound the window, then move the
local entry to expired at the same date, since the SDK delivers nothing when a
question lapses.

```swift
@available(iOS 26.2, *)
@MainActor
func requestContact(_ question: PermissionQuestion<CommunicationTopic>,
                    from host: UIViewController) async {
    do {
        try await AskCenter.shared.ask(question, in: host)
        pending.markPending(question.id)
    } catch let error as AskError {
        switch error {
        case .communicationLimitsNotEnabled:
            unlockChat()                      // no limits: carry on normally
        case .contactSyncNotSetup:
            promptForContactSync()
        case .invalidQuestion:
            reportBrokenQuestion()            // e.g. no handles at all
        case .notAvailable:
            explainUnavailableOnDevice()
        case .systemError(let underlying):
            showFailure(underlying.localizedDescription)
        case .unknown:
            showRetryLater()
        @unknown default:
            showRetryLater()
        }
    } catch {
        showFailure(error.localizedDescription)
    }
}
```

In the 26.2 SDK `.systemError` is deprecated because the underlying error is
now thrown directly; keep the generic `catch` for that reason.

## PermissionButton in SwiftUI

`PermissionButton(question:label:)` starts the same flow when tapped:

```swift
PermissionButton(question: question) {
    Label("Ask to play with Rosa", systemImage: "person.badge.plus")
}
```

It uses the same response model as `AskCenter`. Keep observing
`responses(for:)`, and keep pending and cancelled states in your model,
because a tap does not guarantee that any decision comes back. Full screen
examples are in the
[patterns reference](references/permissionkit-patterns.md#swiftui-full-flow).

## Handling responses

Track each question by `question.id` (a `UUID`). Since a cancelled send
produces nothing, give every pending entry a way out: a retry, an expiry, or
both. When the question carries an `expirationDate`, use that date for the
local expiry so the app and the parent's window close together. When a flow combines known-handle checks with responses, keep the
bundle-identifier guard from the section above.

```swift
enum RequestState { case pending, approved, denied, expired }

@MainActor @Observable
final class ContactRequests {
    private(set) var states: [UUID: RequestState] = [:]

    func markPending(_ id: UUID) { states[id] = .pending }

    func expire(_ id: UUID) {
        guard states[id] == .pending else { return }
        states[id] = .expired              // lets the child ask again
    }

    @available(iOS 26.2, *)
    func listen() async {
        for await response in AskCenter.shared.responses(for: CommunicationTopic.self) {
            let id = response.question.id
            switch response.choice.answer {
            case .approval: states[id] = .approved
            case .denial: states[id] = .denied
            @unknown default: states[id] = .denied
            }
            let people = response.question.topic.personInformation
            refreshContacts(people.map(\.handle))
        }
    }

    private func refreshContacts(_ handles: [CommunicationHandle]) {}
}
```

- `responses(for:)` is an async sequence of `PermissionResponse<Topic>`; each
  element has `.question` and `.choice`.
- `response.question.topic` gives back the topic you asked about.
- `choice.answer` is `.approval` (allow the communication) or `.denial` (keep
  the restriction).
- `PermissionChoice` also exposes `id` and `title`, and the statics
  `PermissionChoice.approve` and `PermissionChoice.decline`.

## Significant app updates

`SignificantAppUpdateTopic` asks a parent to approve a significant change to
the app. You decide what is significant according to the regulations that
apply to you; get interpretation from qualified legal counsel, not from this
skill. The description should be short and name the concrete change the
parent is agreeing to, for example "Adds voice chat with players outside
your child's friend list", not "We updated our app".

```swift
@available(iOS 26.2, *)
@MainActor
func askAboutVoiceChat(from host: UIViewController) async throws {
    let topic = SignificantAppUpdateTopic(
        description: "Adds voice chat with players outside your child's friend list."
    )
    let question = PermissionQuestion<SignificantAppUpdateTopic>(significantAppUpdateTopic: topic)
    try await AskCenter.shared.ask(question, in: host)
    updateGate.markPending(question.id)
    updateGate.scheduleExpiry(for: question.id, after: .seconds(72 * 3600))
}

@available(iOS 26.2, *)
@MainActor
func watchUpdateDecisions() async {
    for await response in AskCenter.shared.responses(for: SignificantAppUpdateTopic.self) {
        switch response.choice.answer {
        case .approval: updateGate.enableVoiceChat()
        case .denial: updateGate.keepVoiceChatOff()
        @unknown default: updateGate.keepVoiceChatOff()
        }
    }
}
```

If the pending window closes with no answer, keep the update blocked or offer
another request. A child cancelling the send flow is not a denial; it simply
produces no response.

## Common mistakes

- Reading `isKnownHandle(_:)` or `knownHandles(in:)` as "limits are on".
  Those calls only classify handles. Still catch the limits-off case:

  ```swift
  do { try await AskCenter.shared.ask(question, in: host) }
  catch AskError.communicationLimitsNotEnabled { unlockChat() }
  ```

- One catch-all that shows nothing. Give each `AskError` case its own
  outcome: allow communication, prompt for contact sync, flag an invalid
  question, explain that the device cannot do it, surface a system error, or
  show a generic retry message.
- Empty handles. `PermissionQuestion(handles: [])` is an invalid question.
  Provide at least one handle or one `PersonInformation`.
- Fire-and-forget asking. Start the task that observes `responses(for:)`
  before or next to the `ask` call, and never wait forever: the child can
  cancel.
- Using `CommunicationLimitsButton`, or the `CommunicationLimits` ask and
  `updates` APIs. They are deprecated; use `PermissionButton`,
  `AskCenter.ask(_:in:)` and `AskCenter.responses(for:)`.

## Review checklist

- [ ] The team knows the request goes through iMessage only before picking
      PermissionKit.
- [ ] Availability is stated per tier: 26.0 core types and limits, 26.1
      `AskError`, 26.2 `AskCenter`, `PermissionButton`, responses and
      `SignificantAppUpdateTopic`.
- [ ] `.communicationLimitsNotEnabled` falls back to normal communication;
      every other `AskError` case has its own user feedback.
- [ ] Each `CommunicationHandle` uses the right `Kind`.
- [ ] Known-handle code guards a nonempty bundle identifier and is not used
      as a limits-enabled check.
- [ ] Every question has at least one handle or person information, and
      person information carries name components.
- [ ] `responses(for:)` is observed; `PermissionButton` is used, not the
      deprecated button.
- [ ] Listed actions match what the app really lets people do.
- [ ] Pending, cancelled and expired requests are handled when no answer
      arrives.
- [ ] Response handling updates UI on the main actor and errors tell the user
      what to do next.

## References

- [Patterns reference](references/permissionkit-patterns.md): UIKit
  controller, response manager, multi-contact questions, limits-check view
  model, SwiftUI full flow, macOS, error recovery.
- Apple documentation:
  - https://developer.apple.com/documentation/permissionkit
  - https://developer.apple.com/documentation/permissionkit/askcenter
  - https://developer.apple.com/documentation/permissionkit/permissionquestion
  - https://developer.apple.com/documentation/permissionkit/permissionbutton
  - https://developer.apple.com/documentation/permissionkit/permissionresponse
  - https://developer.apple.com/documentation/permissionkit/communicationtopic
  - https://developer.apple.com/documentation/permissionkit/communicationhandle
  - https://developer.apple.com/documentation/permissionkit/communicationlimits
  - https://developer.apple.com/documentation/permissionkit/significantappupdatetopic
  - https://developer.apple.com/documentation/permissionkit/askerror
  - https://developer.apple.com/documentation/permissionkit/creating-a-communication-experience
