taool kit --help

TaoolKit

taool's Swift package for your app. A product per feature, so your app links only what it uses. No dependencies, and nothing collected that a feature doesn't need: each product has its own privacy manifest.

iOS 17, iPadOS 17, watchOS 10, tvOS 17, macOS 14 and visionOS 1 or later, each product's platforms under its import. Every product takes your public key from the taool app's TaoolKit page.

swift package add-dependency taool-swift

Install

Add the package, then the products you use to your app target, and to any other app target that ships to the App Store, such as a separate Mac app. Never to widgets or extensions.

https://github.com/taoolcom/taool-swift
Xcode

Up to Next Major Version, so you get every 1.x with its fixes.

File → Add Package Dependencies…
  https://github.com/taoolcom/taool-swift
  Dependency Rule: Up to Next Major Version, 1.0.0

Add to Target: your app, and any other app target that ships to the App Store
  ✓ TaoolUpdates   ✓ TaoolPurchases    only the products you use

Or in a package of your own.

dependencies: [
    .package(url: "https://github.com/taoolcom/taool-swift", from: "1.0.0"),
],
targets: [
    .target(name: "MyApp", dependencies: [
        .product(name: "TaoolUpdates", package: "taool-swift"),
        .product(name: "TaoolPurchases", package: "taool-swift"),   // includes TaoolAnalytics
    ]),
]

TaoolKit finds your app by its bundle ID. If Debug builds use one of their own, pass the App Store app's to each modifier. It's the same in release builds, so there's no need for #if DEBUG.

ContentView()
    .taoolUpdatePrompts(key: "taool_public_…", bundleID: "com.example.app")
    .taoolPurchases(key: "taool_public_…", bundleID: "com.example.app")

Every product takes the same key, taool_public_…, from the taool app's TaoolKit page: one for all of your apps. It's public, so it belongs in your app's code, not in a secrets file.

Two keys, two hosts, never mixed. TaoolKit in your app calls kit.taool.com with the public key. Your server calls api.taool.com with the app's server key, taool_secret_…, from the app's Server Keys in the taool app, which belongs only on a server. No request takes both.

On the root view of every scene
Each product's modifier goes on the root view inside every WindowGroup, and each Window users work in, outside any switch between signed-out and signed-in screens.
One check-in
However many products, scenes and modifiers your app has, they share their checks with taool.
Nothing around it
No network calls, timers, caching or retries of your own: TaoolKit checks when it should, and when taool can't be reached it uses the last response it cached on the device.
Apps only
Never in widgets, extensions or App Clips.

taool kit updates

Update prompts

import TaoolUpdates

  • iOS
  • iPadOS
  • macOS

taool watches your app on the App Store. When a new version goes live, every install learns about it, with no version number to bump on your server. Set a minimum version in the taool app, and anyone on an older one has to update before they can keep using your app.

  • Update available: a banner at the top with an Update button, or at the bottom with placement: .bottom. With placement: .inline, it shows only where you put TaoolUpdates.Banner(), such as the top of your home screen's scroll view. Closed, it stays closed for that version.
  • Update required: a screen covers the app until the user updates.
  • No update possible: an update is required, but every version at or above your minimum needs a newer OS than the device has. The screen still covers the app, and says which OS the latest release needs.

TaoolKit ships with translations in 37 languages and follows the language your app runs in. In the taool app you can write your own text for the banner and the update screen, per language, for every app version or specific ones. Languages you haven't written keep TaoolKit's. An app in only one language can lock the prompts to it.

MyApp.swift

On the root view inside each WindowGroup, outside any switch between signed-out and signed-in screens, so everyone gets it.

import SwiftUI
import TaoolUpdates

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .taoolUpdatePrompts(key: "taool_public_…")
        }
    }
}

The update screen covers the view the modifier is on. A full-screen cover or sheet users can't close, such as a hard paywall or onboarding, sits above it, so it gets the modifier too. It's still one check. A sheet users can close needs nothing, and a paywall your root view switches to is covered already.

WindowGroup {
    ContentView()
        .fullScreenCover(isPresented: $store.showsPaywall) {
            Paywall()
                .taoolUpdatePrompts(key: "taool_public_…")   // covers the paywall too
        }
        .taoolUpdatePrompts(key: "taool_public_…")
}

The banner at the top of your home screen, scrolling with it rather than over the app: placement: .inline on the modifier, and TaoolUpdates.Banner() where it goes, with your own padding. It shows nothing until an update is available. Or placement: .bottom, above a tab bar.

// MyApp.swift
ContentView()
    .taoolUpdatePrompts(key: "taool_public_…", placement: .inline)

// HomeView.swift
ScrollView {
    VStack(spacing: 16) {
        TaoolUpdates.Banner()
        // your cards
    }
    .padding()
}

The inline banner as one of your own cards. TaoolKit still decides when it shows and what it says, in your app's language or your message from the taool app, opens the App Store and remembers the close.

TaoolUpdates.Banner { update in
    MyCard {
        Text(update.title)                              // "Update Available"
        Text(update.message)                            // "Version 2.0 is on the App Store."
        Button(update.button, action: update.open)      // "Update", to the App Store
        Button(update.notNow, action: update.dismiss)   // "Not Now", closed for this version
    }
}

The update-required screen in your own design, once after the modifiers. TaoolKit still decides when it covers the app and what it says, lays it over the whole window, and there's no closing it. No button when no version runs on the device's OS.

ContentView()
    .taoolUpdatePrompts(key: "taool_public_…")
    .taoolUpdateRequiredScreen { required in
        MyScreen {
            Text(required.title)                        // "Update Required"
            Text(required.message)                      // why, and the version to update to
            if let button = required.button {           // nil when no version runs on this OS
                Button(button, action: required.open)   // "Update", to the App Store
            }
        }
    }

The Update button and the icons take your asset catalog's AccentColor, the label black or white, whichever reads on it. For another color, add .taoolTint after the modifiers. It colors What's New's Update button too.

ContentView()
    .taoolUpdatePrompts(key: "taool_public_…")
    .taoolTint(.orange)                    // the label white or black, whichever reads
 // .taoolTint(.yellow, label: .white)     // the label as given

In UIKit, or for a UI of your own: ask for the status as the app becomes active. It never throws: when taool can't be reached it's the last saved answer, or .current.

import TaoolUpdates

let status = await TaoolUpdates.status(key: "taool_public_…")
switch status {
case .current:
    break
case .available(let release):
    showBanner(version: release.version, link: release.appStoreURL)
case .required(let release):
    showUpdateScreen(link: release.appStoreURL)
case .unsupported(let requiredOSVersion):
    showUnsupported(requiredOSVersion)
}

// What TaoolKit's own UI would say, on the main actor: your message or its own, in the app's language.
// .unsupported has no button: nothing on the App Store runs on this OS.
status.text?.title; status.text?.message; status.text?.button

If your server refuses old versions (see your server), send the version with each request, and tell TaoolKit when it answers 426: the update screen covers the app at once. From any thread, as often as requests fail.

import TaoolUpdates

var request = URLRequest(url: url)
request.setValue(TaoolUpdates.appVersion, forHTTPHeaderField: "X-App-Version")
let (data, response) = try await URLSession.shared.data(for: request)

if (response as? HTTPURLResponse)?.statusCode == 426 {
    TaoolUpdates.serverRequiredUpdate()   // the update screen, now
}

To see the UI without a real release. Simulations only work in debug builds; take it out before you ship.

ContentView()
    .taoolUpdatePrompts(key: "taool_public_…", simulating: .required)   // or .available, .unsupported

how it works

When it checks
Once per launch, then at most once an hour as the app becomes active, and it caches the response on the device. What's New comes in the same response, so using both is still one request.
Decided on the device
The response lists the app's recent versions, the minimum OS each one needs, and your minimum, so the device offers the newest version it can actually install, as the App Store does: never one its OS can't run.
Never in the way
If taool can't be reached, it uses the last response it cached on the device; with none, the install counts as current.
What's sent
The public key, the bundle ID and the platform. Nothing about the user or the device.

taool kit whats-new

What's New

import TaoolUpdates

  • iOS
  • iPadOS
  • macOS

Upload a page of your own design for a version in the taool app: one HTML file, or a folder with index.html and its images (or its zip), and taool hosts it, as a draft only you see until it's published: by you once that version is on the App Store, or with Auto-Publish the moment it is. Then it shows once, in a sheet, to everyone who had the app before it, whether they've updated or not: on an older version, a bar at the bottom, in your app's language, offers the update. Users who got the app after that version don't get it automatically, but a What's New row in your settings lets anyone open the latest page, any time.

MyApp.swift

On the root view, never on a full-screen cover. With update prompts too, the two share one check, and the page never shows over the update screen.

import SwiftUI
import TaoolUpdates

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .taoolUpdatePrompts(key: "taool_public_…")
                .taoolWhatsNew(key: "taool_public_…")
        }
    }
}

A hard paywall: .held keeps the page back while it shows, and the page shows the moment the mode is .automatic again, so right after they buy. Nothing is lost while it's held.

WindowGroup {
    Group {
        if store.hasPro { ContentView() } else { Paywall() }
    }
    .taoolUpdatePrompts(key: "taool_public_…")
    .taoolWhatsNew(key: "taool_public_…", mode: store.hasPro ? .automatic : .held)
}

Onboarding, or a sheet of your own at launch: the page waits until it's done. New users never get a page anyway, since nothing in it is new to them.

@AppStorage("hasOnboarded") private var hasOnboarded = false

var body: some Scene {
    WindowGroup {
        ContentView()
            .taoolWhatsNew(key: "taool_public_…", mode: hasOnboarded ? .automatic : .held)
    }
}

A row that opens the page again, even after it was closed, there only while there's a page. hasWhatsNew is what the modifier's last check found, so it makes no request.

import SwiftUI
import TaoolUpdates

struct SettingsView: View {
    var body: some View {
        Form {
            if TaoolUpdates.hasWhatsNew {
                Button("What's New") { TaoolUpdates.showWhatsNew() }
            }
        }
    }
}

Only when your app says so: .manual never shows the page on its own.

ContentView()
    .taoolWhatsNew(key: "taool_public_…", mode: .manual)

// Wherever your app decides:
TaoolUpdates.showWhatsNew()
whats-new-2.0/

Upload one HTML file, or a folder like this one (or its zip): index.html and everything it uses, up to 200 files and 25 MB. taool hosts them together.

whats-new-2.0/
├── index.html
├── logo.png
└── lock-screen.png

Reading version, language and update from after the #: users still on an older version see "1.2 → 2.0" and "after you update", those on 2.0 "now in your app", and Swedish users the Swedish you wrote.

<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- This page is about 2.0. -->
<img src="logo.png" alt="" width="64">
<h1 data-text="title">What's New in 2.0</h1>
<p id="from"></p>
<ul>
  <li>
    <b data-text="widgets">Widgets on the Lock Screen</b>
    <em data-text="when">now in your app</em>
    <img src="lock-screen.png" alt="">
  </li>
  <li><b data-text="sync">iCloud sync</b> <em data-text="when">now in your app</em></li>
</ul>

<script>
  // Opened in 1.2, in Swedish: index.html#version=1.2&language=sv&update=1
  const query = new URLSearchParams(location.hash.slice(1));
  const update = query.get("update") === "1";
  const language = query.get("language") ?? "en";

  // Your words in each language you write; English is the page's own.
  const translations = {
    en: { when: update ? "after you update" : "now in your app" },
    sv: { title: "Nyheter i 2.0", widgets: "Widgetar på låsskärmen", sync: "iCloud-synk",
          when: update ? "när du har uppdaterat" : "nu i appen" },
  };
  // The language asked for, else its base ("pt" for "pt-BR"), else English.
  const lang = [language, language.split("-")[0]].find(l => translations[l]) ?? "en";
  document.documentElement.lang = lang;

  for (const el of document.querySelectorAll("[data-text]")) {
    el.textContent = translations[lang][el.dataset.text] ?? el.textContent;
  }
  if (update) {
    document.querySelector("#from").textContent = `${query.get("version")} → 2.0`;
  }
</script>

The same page in the app: opened in 2.0, opened in 1.2 with TaoolKit's update bar under it, and the same in Swedish, the bar in Swedish too.

×
What's New in 2.0
  • Widgets on the Lock Screennow in your app
  • iCloud syncnow in your app
#version=2.0&language=en
×
What's New in 2.0
1.2 → 2.0
  • Widgets on the Lock Screenafter you update
  • iCloud syncafter you update
Update to version 2.0 to get what’s new.Update
#version=1.2&language=en&update=1
×
Nyheter i 2.0
1.2 → 2.0
  • Widgetar på låsskärmennär du har uppdaterat
  • iCloud-synknär du har uppdaterat
Uppdatera till version 2.0 för att få det nya.Uppdatera
#version=1.2&language=sv&update=1

how it works

Rides on the update check
The page comes in the same cached response as update prompts, so it adds no request of its own: with both, still one request per launch and at most one an hour, and with What's New alone, that same one.
Published when you say
Until then, preview it in the taool app as each user would open it, in any version and language, and upload it again as often as you like. Switch on Auto-Publish and it goes out the moment 2.0 releases, or publish it yourself once 2.0 is out.
In TestFlight first
Switch on Show in TestFlight and your testers on 2.0 builds see it right away, before 2.0 is on the App Store, while App Store installs wait for it to be published. Builds from Xcode count as TestFlight, so you can check it while you develop.
Once per version, per device
Uploading a page again for its version, to fix a typo, or an older page going live again, doesn't show it again.
Held, never lost
A page counts as seen only once it shows. One held back appears as soon as it's let through, and one the app closed before showing appears the next time the app comes to the foreground.
Self-contained, for privacy
Include everything the page uses in the upload: taool adds a Content-Security-Policy that loads its images, fonts, scripts and styles only from its own files or inline, never from another site, your own included, so your app's privacy report shows no one new. Links still work, and open outside the app, in the App Store, Mail or the browser.
Version, language and update, after the #
TaoolKit adds the user's app version and language after the # in the page's address, and update=1 when they haven't updated to the version the What's New page is about yet: a page about 2.0, opened in 1.2 in Swedish, gets version=1.2, language=sv and update=1. So the page can tell users already on the new version from those still on an older one, and show "1.2 → 2.0" above its list of changes, say, or "after you update" on each feature rather than "now in your app"; TaoolKit already shows its own update bar then, so the page doesn't need one. And a page written in several languages can show the user's: the language your app runs in, or the one you locked in the taool app. All of it stays on the device: browsers never send what's after the #, so taool never sees a user's version or language, and every user gets the same page.
Nothing kept or sent
The page keeps no cookies or data on the device, and nothing about the user is sent to decide who sees it.

taool kit updates --server

Your server

api.taool.com/v1/updates

If your app has a backend, it reads the same answer the app does, with the app's server key, and refuses versions below your minimum with 426, which the app's update screen answers at once. A webhook tells it when a new version goes live.

zsh

With the app's server key in your server's secrets, the only key a server sends, never the public one. Read it at most every minute, with If-None-Match, and keep the last answer. A missing field is null, and requireVersion is there only while Refuse Unknown Versions is on. A 404 means the key's maker no longer has the app in taool: refuse nothing.

$ curl -H "Authorization: Bearer taool_secret_…" \
    https://api.taool.com/v1/updates/com.example.app/ios/policy
{
  "appId": 1234567890,
  "bundleId": "com.example.app",
  "platform": "ios",
  "minimumVersion": "1.8.0",
  "latest": { "version": "1.8.8", "releasedAt": "2026-09-22T16:35:34Z", "minimumOsVersion": "26.2" },
  "versions": [ … ],
  "requireVersion": true
}

ASP.NET Core: refuse versions below the minimum with 426, on the routes the app calls only. The guide has the TaoolUpdates class this uses, which reads the policy and fails open.

// Never taool's webhook, health checks or web pages: they send no version.
app.UseWhen(context => context.Request.Path.StartsWithSegments("/api"), api => api.Use(async (context, next) =>
{
    var version = context.Request.Headers["X-App-Version"].ToString();
    var updates = context.RequestServices.GetRequiredService<TaoolUpdates>();
    if (await updates.MustUpdateAsync(version, context.RequestAborted))
    {
        context.Response.StatusCode = StatusCodes.Status426UpgradeRequired;
        await context.Response.WriteAsJsonAsync(new { error = "update_required" });
        return;
    }
    await next();
}));

A POST when a new version goes live. It carries the Authorization header you set in the taool app, or taool's signature: HMAC-SHA256 of "{t}.{raw body}" with the signing secret. Answer 2xx quickly, and keep reading the policy too: a delivery can be missed.

POST /hooks/taool
Taool-Event: version.released
Taool-Signature: t=1790000000,v1=5f2b…    # or your own Authorization header

{ "type": "version.released", "appId": 1234567890, "bundleId": "com.example.app", "platform": "ios",
  "version": "1.8.9", "releasedAt": "2026-09-30T08:00:00Z", "minimumOsVersion": "26.2", "releaseNotes": "…" }

Checking a delivery: the header against your copy, or the signature against the raw body, each in constant time, refusing a t more than 5 minutes old.

public static bool IsFromTaool(HttpRequest request, string body, string? authorization, string? secret) =>
    !string.IsNullOrEmpty(authorization)
        ? CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(request.Headers.Authorization.ToString()), Encoding.UTF8.GetBytes(authorization))
        : !string.IsNullOrEmpty(secret) && IsValid(request.Headers["Taool-Signature"].ToString(), body, secret);

static bool IsValid(string header, string body, string secret)
{
    var parts = header.Split(',').Select(p => p.Split('=', 2)).Where(p => p.Length == 2).ToDictionary(p => p[0], p => p[1]);
    if (!parts.TryGetValue("t", out var t) || !long.TryParse(t, out var timestamp) || !parts.TryGetValue("v1", out var signature))
        return false;
    if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp) > 300)
        return false;
    var expected = HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), Encoding.UTF8.GetBytes($"{t}.{body}"));
    return CryptographicOperations.FixedTimeEquals(Encoding.ASCII.GetBytes(Convert.ToHexString(expected).ToLowerInvariant()), Encoding.ASCII.GetBytes(signature));
}
Fail open
If taool can't be reached, keep using the last answer. With none, refuse nobody.
Comparing versions
Number by number, a missing part as 0: 1.10 is newer than 1.9, and 1.8 is 1.8.0. Never as text.
Refuse Unknown Versions
In the taool app's Update Prompts, under Servers. Turn it on once every build still in use sends its version: then requests without one are refused.

the whole setup, for you or your coding agent: integrate-updates.md

taool kit updates --languages

Languages

import TaoolUpdates

  • iOS
  • iPadOS
  • macOS

TaoolKit's own text, on the update banner and screens and on What's New's bar and Close button, comes in these 37 languages. It shows the one your app runs in, or its nearest regional variant (Spanish in Argentina gets Mexico's), and English for a language it doesn't have.

Your own messages can be in any language, these or others. If your app's project names a different language than its text is in, such as a Swedish app whose development language is still English, lock the prompts to the right one in the taool app. A What's New page gets your app's language after the # in its address, to show your own words in it.

  • Arabic ar
  • Catalan ca
  • Chinese, Hong Kong zh-HK
  • Chinese, Simplified zh-Hans
  • Chinese, Traditional zh-Hant
  • Croatian hr
  • Czech cs
  • Danish da
  • Dutch nl
  • English en
  • Finnish fi
  • French fr
  • French, Canada fr-CA
  • German de
  • Greek el
  • Hebrew he
  • Hindi hi
  • Hungarian hu
  • Indonesian id
  • Italian it
  • Japanese ja
  • Korean ko
  • Malay ms
  • Norwegian nb
  • Polish pl
  • Portuguese, Brazil pt-BR
  • Portuguese, Portugal pt-PT
  • Romanian ro
  • Russian ru
  • Slovak sk
  • Spanish es
  • Spanish, Mexico es-MX
  • Swedish sv
  • Thai th
  • Turkish tr
  • Ukrainian uk
  • Vietnamese vi

taool kit experiments

A/B/n tests

import TaoolExperiments

  • iOS
  • iPadOS
  • watchOS
  • tvOS
  • macOS
  • visionOS

An experiment is an enum of up to ten variants, the first case the control. Each user gets one variant, and taool counts the users who see each and the goals they reach afterwards: your own, and free trials and payments. In the taool app you see each variant's users, each goal's count and conversion, and the chance it beats the control. End it with a winner, and all users get that variant automatically from then on, without an app update.

MyApp.swift

Once, on the root view inside each WindowGroup.

import SwiftUI
import TaoolExperiments

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .taoolExperiments(key: "taool_public_…")
        }
    }
}

A String enum: the first case is the control, and the raw values are the names the taool app shows. One with more than ten cases isn't counted, and TaoolKit logs why. Its name is the type's, with any types it's nested in (PaywallView.Variant), unless you give it a key, which keeps it and its results when you rename or move the type.

import TaoolExperiments

enum PaywallButton: String, TaoolExperiment {
    case control
    case freeTrial

    static let key = "paywall-button"   // optional
}

Read it where the variant shows, never ahead of time (in a view model's init, say): reading it counts the user, once. Every part of the screen that differs reads the same one.

var body: some View {
    let button = PaywallButton.variant
    VStack {
        Text(button == .freeTrial ? "Try Pro free for 7 days" : "Unlock Pro")
        Button(button == .freeTrial ? "Start Free Trial" : "Continue") { buy() }
    }
}

// Or a view per variant:
switch PaywallButton.variant {
case .control: ClassicPaywall()
case .freeTrial: TrialPaywall()
}

A goal is any name you choose: letters, digits, ., _ and -, up to 64. Record it every time it happens; it's sent once per experiment shown, and counts for every variant the user saw before.

Button("Export") {
    export()
    TaoolExperiments.record("exported")
}

Free trials and payments are built-in goals. Record a purchase made in your app and it counts at once; one TaoolKit doesn't see (StoreKit's own views, another device, a renewal) comes from StoreKit's history, late but never missed.

let result = try await product.purchase()
TaoolExperiments.record(result)
// Your own handling stays: verify, unlock, and finish the transaction.

By default a user is their Apple Account, the same on all their devices and after a reinstall, with nothing to set up. With accounts of your own, pass the signed-in user's ID, and nil while no one is; only a SHA-256 of it leaves the device.

ContentView()
    .taoolExperiments(key: "taool_public_…", userID: session.userID)

Shows a variant without counting anyone, to check each one's UI. It only works in debug builds; remove it when you're done.

#if DEBUG
TaoolExperiments.force(PaywallButton.freeTrial)
#endif

how it works

Decided on the device
The user's ID and the experiment's name pick the variant, so the split is even and a user gets the same one on every device, offline too. A device keeps a variant once it's shown, even when a later build adds more.
Users, not devices
Apple's appTransactionID, or your own ID. When a user signs in, what they did signed out moves over to them; signing out or switching accounts never merges anyone.
Testing never skews results
Users on TestFlight, development and Xcode builds are counted apart from the App Store's, so you can try it all before release.
Offline
Variants need no network, and reports wait on the device until taool can be reached.

taool kit flags

Feature flags

import TaoolFlags

  • iOS
  • iPadOS
  • watchOS
  • tvOS
  • macOS
  • visionOS

Make a flag in the taool app, then read it by its key. Turn it on from the taool app with no app update: for a few users while you test, in TestFlight, then for everyone, at once, at a percentage you move up or down, or in a progressive rollout that climbs on its own over the days you pick. Off is one tap, whatever else is set.

Targeting rules turn a flag on or off by app version, OS version, device, App Store country, language, when the app was downloaded, or whether the user has bought something. And a flag can hold text, a number or JSON instead of on and off, for values your app reads: remote config.

MyApp.swift

Once, on the root view inside each WindowGroup. It includes TaoolAnalytics.

import SwiftUI
import TaoolFlags

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .taoolFlags(key: "taool_public_…")
        }
    }
}

Each raw value is a flag's key in the taool app, exactly as it's written there. Its Copy Swift writes this enum for you.

import TaoolFlags

enum Flags: String, TaoolFlag {
    case newOnboarding = "new-onboarding"
    case paywallHeadline = "paywall-headline"
    case trialDays = "trial-days"
    case plans
}

Off until the device has heard from taool, so put the new behavior behind isOn, never the old. Reading never waits on the network, and views redraw when a flag changes. On the main actor.

var body: some View {
    if Flags.newOnboarding.isOn {
        NewOnboarding()
    } else {
        Onboarding()
    }
}

Text, a number or JSON, each read with what the app shows until the device has heard from taool. The flag's default in the taool app is what it gives while it's off, and to everyone a percentage leaves out. A date in JSON is an ISO 8601 string ("2026-12-01T00:00:00Z"), which decodes into a Date.

Text(Flags.paywallHeadline.string(default: "Unlock Pro"))
let days = Flags.trialDays.int(default: 7)                         // .number(default:) for any number
let plans = Flags.plans.value(as: [Plan].self, default: Plan.all)   // JSON, as any Decodable

Pass the signed-in user's ID, and nil while no one is, to pick users by it in the taool app: on while they test, off for a user a new feature broke for. Percentages go by it too, so your server can work out the same; without it, by the user's Apple Account.

ContentView()
    .taoolFlags(key: "taool_public_…", userID: session.userID)

On for this device without asking taool, in debug builds only; remove it when you're done. Or set the flag's TestFlight value to On in the taool app, or pick this device's user there, and relaunch.

#if DEBUG
Flags.newOnboarding.force(true)
#endif

// This device's user, to pick in the taool app (on a debug or support screen):
Text(accountID ?? "not known yet")
    .task { accountID = await TaoolAnalytics.appTransactionID() }

how it works

No defaults in code for on and off
A flag is off until the device has heard from taool, then has the last values it heard, kept on the device. A key the taool app doesn't have is always off.
When it changes
The package asks at each launch, then as the app comes to the foreground once a minute has passed. New values apply then, never in the middle of a screen.
Percentages are users
By the user ID your app passes, else Apple's appTransactionID, the same on all of a user's devices. Raising one keeps everyone who had it; lowering it takes out those who got it last.
Worked out on the device
Percentages, rollouts and targeting rules, from what the app knows about itself, so none of it is sent for them.
Not for what users paid for
A flag decides what a user sees, never what they've bought access to: that's TaoolPurchases, or your own StoreKit code.

taool kit analytics

Your users

import TaoolAnalytics

  • iOS
  • iPadOS
  • watchOS
  • tvOS
  • macOS
  • visionOS

Each Apple Account that opens your app checks in now and then, so taool counts your users, weekly and monthly (daily with expanded analytics), new ones by the day they first downloaded the app, and the versions they're on. No sign-in, Subscriptions or server needed. TaoolPurchases and TaoolFlags include it.

MyApp.swift

Once, on the root view inside each WindowGroup. Several scenes and modifiers are still one check-in. With accounts, pass the signed-in user's ID as userID, and nil while no one is, so the taool app groups a user's Apple Accounts under it; pass the same to TaoolKit's other modifiers.

import SwiftUI
import TaoolAnalytics

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .taoolAnalytics(key: "taool_public_…")
        }
    }
}

Leave analytics out, and you switch between standard and expanded in the taool app, with no app update. Any other level is a promise in code the taool app can't change, and shows beside its switch.

.taoolAnalytics(key: "taool_public_…")                         // recommended: .automatic
.taoolAnalytics(key: "taool_public_…", analytics: .standard)   // never expanded
.taoolAnalytics(key: "taool_public_…", analytics: .minimal)    // only which Apple Account this is

With TaoolPurchases or TaoolFlags, the app checks in already: no .taoolAnalytics needed. Their analytics: sets the level as it would. With more than one, the most private level any of them asks for holds.

ContentView()
    .taoolPurchases(key: "taool_public_…", analytics: .standard)
levelwhat it sends
.minimalWhich Apple Account this is, and nothing about the device; with TaoolPurchases, what access needs (signed purchases and your user ID), and with TaoolFlags, the user ID you pass. Only code sets it.
.standardAnd the app's version and build, TaoolKit's version, the platform, the App Store country (from StoreKit, never the IP), the language your app runs in, the language the user chose in Settings and the device's region.
.expandedAnd when the app was last opened, how often and on which days, the OS version, and the kind and model of device. Declare it in your own privacy manifest before you turn it on.
.automaticThe default, recommended. Standard, until you turn Expanded Analytics on for the app in the taool app.

how it works

Who a user is
An Apple Account, by StoreKit's signed AppTransaction. Someone on several devices, or after a reinstall, is one user.
Not on every open
At a cold launch, then as the app becomes active or leaves, once 6 hours have passed, so a long session checks in as it starts and as it ends. Never while the app sits in the background. A check-in that fails waits on the device.
Never
An advertising or vendor ID, an install ID, the IP, or the device's name.

taool kit purchases

In-app purchases

import TaoolPurchases

  • iOS
  • iPadOS
  • watchOS
  • tvOS
  • macOS
  • visionOS

Who has access to what: subscriptions, lifetime unlocks and other non-consumables. StoreKit's answer the moment your app starts, kept right by taool: access you give, and a subscription that moved to another of your users. Your own StoreKit code keeps doing the buying.

Connect the app to Subscriptions in the taool app (Earn → Subscriptions). Until you do, access is StoreKit's alone, and taool takes the purchases from Apple's own history once you have.

MyApp.swift

With accounts, pass the signed-in user's ID, and nil while no one is; without, leave userID out. Cover every scene: a view reading the environment without it crashes, as SwiftUI does.

import SwiftUI
import TaoolPurchases

@main
struct MyApp: App {
    @State private var session = Session()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .taoolPurchases(key: "taool_public_…", userID: session.user?.id.uuidString)
        }
    }
}

Anywhere below the modifier. Where the app unlocks per product, ask with that product's IDs: any purchase makes hasAccess true.

@Environment(TaoolPurchases.self) private var purchases

var body: some View {
    // An app that sells one thing:
    if purchases.hasAccess { ProFeatures() } else { Paywall() }
}

// One that sells several: subscriptions, a lifetime unlock, other non-consumables.
purchases.has("com.example.pro.monthly", "com.example.pro.yearly", "com.example.pro.lifetime")
purchases.has("com.example.guide.paris")

purchases.isLoaded   // false until StoreKit answers this launch: the last copy shows meanwhile

Named as StoreKit 2 names them: productID, expirationDate (nil for good), offer (isTrial for short), willAutoRenew, state (.subscribed or .inGracePeriod), ownershipType, environment, originalTransactionID, and isGrant for access you gave in taool.

if let pro = purchases.currentEntitlement(for: "com.example.pro.monthly", "com.example.pro.yearly"),
   pro.isTrial, let end = pro.expirationDate {
    Text("Your trial ends \(end, format: .dateTime.weekday(.wide))")
}

purchases.currentEntitlements   // everything that gives access, the longest-lasting first

Your purchase code stays yours: TaoolKit never buys, never finishes a transaction and never blocks one. The options name the signed-in user on Apple's transaction; StoreKit's own views below the modifier get them by themselves. Record every purchase your code makes.

let result = try await product.purchase(options: purchases.purchaseOptions)
purchases.record(result)   // access has it at once, and taool hears of it
// …then verify and finish the transaction, as you do now.

With accounts, Restore Purchases is what moves a subscription to the user restoring. A Check Access button is optional: every cold launch does the same.

// Your Restore Purchases button. It runs AppStore.sync(), which can ask the user to sign in.
try await purchases.restore()

// A Check Access button: asks taool now, for access you just gave. Never asks anyone to sign in.
await purchases.refresh()

This Apple Account pays, but the subscription is another of your users'. A purchase or Restore Purchases moves it; nothing else does.

if purchases.isOnAnotherAccount {
    Text("In use by another account. Restore Purchases to use it here.")
}

Outside a view, TaoolPurchases.shared is the same object, on the main actor. It's @Observable.

import TaoolPurchases

@MainActor
final class Store {
    var hasPro: Bool { TaoolPurchases.shared.has("com.example.pro.monthly", "com.example.pro.yearly") }
}

how it works

StoreKit first
What gives access is StoreKit's own, read as the app becomes active and whenever StoreKit reports a change, and kept on the device, so it's right the moment the app starts. If taool can't be reached, StoreKit's answer stands.
Your users
Any string up to 128 characters. A UUID also becomes Apple's App Account Token, so Apple's own transactions name them.
When it syncs
When there's news (a purchase, renewal, refund, restore or new user), at every cold launch, so access you give shows once the app restarts, and otherwise in TaoolAnalytics' check-in.
It includes TaoolAnalytics
So it counts your users too. An app with .taoolAnalytics moves to .taoolPurchases rather than having both.

taool kit purchases --server

Your server

api.taool.com/v1/purchases

With a server of your own, taool POSTs each purchase event to it as it happens, already decoded, and sends it again until it answers. With a server key, your server asks taool whether a user has access, and gives or ends access. Set both up in the taool app: Earn → Subscriptions → the app → Your Server.

POST /hooks/taool/purchases

One event per POST: started, renewed, cancelled, billing issues, refunds, a subscription moving between your users, access you give. Checked as the release webhook is. Answer 2xx at once and do the work after; an event can come again, so skip an id you've handled.

POST /hooks/taool/purchases
Taool-Event: subscription.renewed

{
  "id": "5d1c0c3e-8f0a-4a3e-9d6b-2f9c1d7e4b10",
  "type": "subscription.renewed",
  "createdDate": "2026-10-09T08:00:00Z",
  "bundleId": "com.example.app",
  "data": {
    "environment": "production",
    "userId": "8e3f7a2c-4b1d-4c9e-a0f5-6d2b9e8c1a47",
    "subscription": { "productId": "com.example.pro.monthly", "status": "active",
                      "expirationDate": "2026-11-09T07:59:41Z", "willAutoRenew": true }
  }
}

Whether one user has access, by the ID your app passes TaoolKit, with the server key in your server's secrets, never in the app. An app without sign-in asks by the AppTransaction it sends your server.

$ curl -H "Authorization: Bearer taool_secret_…" \
    https://api.taool.com/v1/purchases/com.example.app/users/8e3f7a2c-4b1d-4c9e-a0f5-6d2b9e8c1a47
{
  "entitled": true,
  "entitlements": [
    { "productId": "com.example.pro.yearly", "source": "purchase",
      "expirationDate": "2027-10-09T07:59:41Z", "willAutoRenew": true }
  ]
}

Access you give, to a tester or for a support case, until a day or for good. To a user ID, only your server sees it; to an Apple Account, TaoolKit does too, at its next check-in.

$ curl -X POST https://api.taool.com/v1/purchases/com.example.app/grants \
    -H "Authorization: Bearer taool_secret_…" -H "Content-Type: application/json" \
    -d '{ "userId": "8e3f7a2c-…", "productId": "com.example.pro.yearly", "expirationDate": "2026-12-31T00:00:00Z", "note": "Beta tester" }'

every event and field, for you or your coding agent: integrate-purchases.md

cat PrivacyInfo.xcprivacy

Privacy

Each product has its own privacy manifest, declaring only what it collects, so your app declares only what it uses. Nothing is used for tracking. The taool app's TaoolKit page gives each app's App Privacy answers, for the products it uses.

productwhat it sendsits manifest declares
TaoolUpdatesThe public key, the bundle ID and the platform.Nothing: an app with it alone keeps Data Not Collected.
TaoolExperimentsThe user's ID (Apple's appTransactionID or a SHA-256 of yours), which variant they saw and when, the goals they reached, and their purchases as StoreKit signed them. Reports wait until StoreKit says who the user is; only a debug build, when StoreKit can't say, reports as the install.Product interaction and a user ID, for analytics. If your app sells anything, add purchase history to your own.
TaoolAnalyticsStoreKit's signed AppTransaction, and its level's facts (see your users).A user ID and purchase history, for analytics. Expanded analytics: add product interaction and other diagnostic data to your own first.
TaoolFlagsThe public key and the bundle ID for the flags; in TaoolAnalytics' check-in, the user ID you pass.A user ID, for app functionality, and TaoolAnalytics'.
TaoolPurchasesTaoolAnalytics' check-in, with your user ID and StoreKit's signed transactions.Purchase history, for app functionality and analytics, and a user ID, for app functionality, and TaoolAnalytics'.

Declared not linked to the user: Apple's ID for an Apple Account says nothing about who it is. Answer linked if you pass TaoolKit your own user IDs.

claude "add taool's update prompts"

Coding agents

Each product has a guide written for coding agents such as Claude Code, with its server part where it has one. The taool app's Copy Prompt buttons hand your agent the right guide, with your app, its bundle ID and your key filled in.

  • iPhone
  • iPad
  • Apple Watch
  • Mac