lotse

Lotse: know your users, reach the right ones

Deep links, attribution, analytics, feedback and push for Flutter apps on a Lotse backend. The SDK talks to the server through a generated Serverpod client but never shows it: apps only see Lotse and the SDK's own types.

Install

flutter pub add lotse

Requires Dart ^3.12.2 and Flutter >=3.44.4. You need a project in the Lotse console for the public key.

Getting started

import 'package:lotse/lotse.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  Lotse.captureLaunchLinks(); // before the first await
  await Lotse.init(
    publicKey: 'lt_pk_...', // from the Lotse console
    serverUrl: 'https://api.lotse.dev/',
  );
  runApp(LotseScope(child: MyApp()));
}

captureLaunchLinks keeps a link that reopens the app while main is still waiting (Android restores an app whose process it ended and delivers the link a moment later) and hands it to the router after the first frame. Call it before anything is awaited.

init registers the device, reports session_start and starts push messaging if the app has a Firebase configuration. Nothing in the SDK throws into the app: failures are logged with debugPrint and the call returns null or does nothing. The one exception is a serverUrl that breaks the rules below — a configuration mistake, thrown as LotseServerUrlError before any request is made.

On the web

The SDK runs in Flutter web apps too: events, links, users, feedback, heatmaps and replay. The device reports the platform web and, as its OS version, the browser and its major version (Chrome 140). Differences:

  • No push. The SDK never asks the browser for notification permission, even if the app has set up Firebase itself.
  • The install credential lives in localStorage through flutter_secure_storage. Any script on the page can read it, like any other token a web app keeps. It only lets its holder act as this one device.
  • Replay records a frame at most once a second. With the CanvasKit renderer, taking a frame costs the main thread about 10 to 20 ms; a --wasm build (skwasm) takes well under 2 ms.

On macOS, Windows and Linux

The SDK builds and runs there as the platform other: events, links, users, feedback and in-app messages work, push does not. The SDK does not touch Firebase on these platforms, never asks for a notification permission and reports no push token; requestPushPermission() returns false. The install referrer is Android only.

What a macOS app needs:

  • the entitlement com.apple.security.network.client in both DebugProfile.entitlements and Release.entitlements, or every call fails;
  • a build signed with your team. The install credential lives in the data protection keychain (flutter_secure_storage), which macOS refuses to an ad-hoc signed app ("Sign to Run Locally"): the SDK then logs could not store the identity and starts a new install whenever it needs a credential. If a team-signed build still logs it, add the Keychain Sharing capability;
  • a deployment target the Firebase packages accept (Flutter's default of macOS 12 builds), also though push stays off.

The server URL

serverUrl is https://api.lotse.dev/. It carries the install credential and private conversations, so the SDK checks it before the first request leaves the device: it must be https, without credentials, query or fragment. Certificates are checked the normal way of the platform; the SDK has no way to turn that off.

await Lotse.instance.track('checkout_started', props: {'cart': '3 items'});

final link = await Lotse.instance.resolveLink('summer');   // LotseLink?
final first = await Lotse.instance.deferredLink;           // once per install
final opened = await Lotse.instance.initialPush;           // LotsePush?
Lotse.instance.pushOpened.listen((push) => router.go(push.targetPath ?? '/'));

A link that resolves is tracked as link_open with its slug. Call resolveLink when a short link opens the app; the setup diagnosis in the console compares these opens with the clicks that ended up in the browser.

targetPath of a link, a push and an in-app message is a route of your app, written by your team in the console or sent by your backend: hand it to your router (router.go), not to launchUrl or a web view. The server refuses a path that starts with // or /\, which a browser would read as another host, but a router is still the only place the path belongs.

Message pushes

Besides campaigns, your backend can send a push to one user or one install through the Lotse server API (POST /v1/messages), e.g. "Your order has shipped". The app does nothing extra: a tapped message push arrives through initialPush and pushOpened like a campaign push, with the targetPath your backend sent. Tell the two apart by the id that is set:

Lotse.instance.pushOpened.listen((push) {
  if (push.messageId != null) {
    // Sent by your backend; messageId is the id it got back.
  }
  router.go(push.targetPath ?? '/');
});

campaignId is null for a message push, messageId for a campaign push. Both record push_open, a message push with the props messageId and deliveryId.

A push to a userId only reaches devices that proved the user: identify with a valid userHash, which needs the identity secret (see "Identity verification"). Without it, send to the install instead; the app hands Lotse.instance.installId to your backend.

Data, silent pushes and the badge

A message can carry data for your app, a badge and, with "silent": true, no notification at all. Each key of data arrives unchanged next to the Lotse keys: in RemoteMessage.data of firebase_messaging, and on iOS at the top level of the APNs payload, where a native handler finds it. Keys that start with lotse_ belong to Lotse.

A tapped message push hands its data to the app in LotsePush.data, without the keys of Lotse, FCM and APNs:

Lotse.instance.pushOpened.listen((push) {
  final orderId = push.data['orderId'];
  router.go(push.targetPath ?? '/orders/$orderId');
});

A silent push shows nothing and is never tapped, so it never reaches initialPush or pushOpened. On Android, read it where firebase_messaging delivers it, with Lotse.messageDataOf, which needs no init and works in the background isolate:

@pragma('vm:entry-point')
Future<void> onBackgroundMessage(RemoteMessage message) async {
  final data = Lotse.messageDataOf(message.data);
  if (data == null) return; // not a Lotse message push
  await refreshWidget(data['widget']);
}

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  Lotse.captureLaunchLinks();
  FirebaseMessaging.onBackgroundMessage(onBackgroundMessage);
  await Lotse.init(publicKey: 'lt_pk_...', serverUrl: 'https://api.lotse.dev/');
  runApp(LotseScope(child: MyApp()));
}

On iOS there is no Firebase (see "Push on iOS"): read a silent push natively in your AppDelegate, before calling super, where the keys sit at the top level of userInfo:

override func application(
  _ application: UIApplication,
  didReceiveRemoteNotification userInfo: [AnyHashable: Any],
  fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
  if let widget = userInfo["widget"] as? String {
    refreshWidget(widget)
    completionHandler(.newData)
    return
  }
  super.application(application, didReceiveRemoteNotification: userInfo,
                    fetchCompletionHandler: completionHandler)
}

The platforms deliver silent pushes when they see fit: iOS needs the background mode "Remote notifications" and holds them back in Low Power Mode, when the user force-quit the app or when it sends too many; Android delays them in Doze. Nothing may depend on a silent push arriving.

badge sets the number on the app icon on iOS to exactly this value, 0 clears it. Android has no such number for apps; there it shows on a visible push only, as its notification count. A silent push carries it in lotse_badge for an app that wants to use it.

When to ask for the push permission

init asks for the notification permission right away. To ask at a better moment, for example in your onboarding:

await Lotse.init(
  publicKey: 'lt_pk_...',
  serverUrl: 'https://api.lotse.dev/',
  requestPushPermission: false,
);

// Later, when the user has seen why notifications help:
final allowed = await Lotse.instance.requestPushPermission();

The push token is reported at start either way: silent pushes need no permission. On the web and on desktop platforms requestPushPermission() returns false without asking.

Push on iOS

On iOS the SDK needs no Firebase: a small native part of the package registers for remote notifications, takes the APNs device token and reports it, and hands tapped pushes to initialPush and pushOpened. Lotse sends to the iPhone directly through APNs, so the project owner uploads an APNs key (.p8) for the iOS app under Apps in the console; without one the iPhone gets no pushes. No GoogleService-Info.plist is needed. Android keeps using Firebase Cloud Messaging.

What the iOS app needs:

  • the Push Notifications capability (aps-environment in the entitlements) and the background mode "Remote notifications";
  • for Universal Links, the Associated Domains capability with applinks:<handle>.lotse.dev, and the Team ID on the iOS app in the console;
  • an APNs key of the same team. Builds run from Xcode register sandbox tokens and need a key uploaded for sandbox; TestFlight and App Store builds need production.

The SDK makes your AppDelegate (a FlutterAppDelegate) the delegate of the notification center when nobody is, so every plugin hears about taps. If your app sets its own delegate, forward userNotificationCenter(_:didReceive:…) to super. A Lotse push shows no banner while the app is in front, as on Android.

reset() cannot delete the APNs token, it belongs to the app installation. The server moves it to the new identity when the SDK reports it again.

Screen views

Hand a LotseScreenObserver to your navigator and every page that becomes visible is reported as a screen_view event with the prop screen:

final router = GoRouter(
  observers: [LotseScreenObserver()],
  routes: [
    GoRoute(path: '/', name: 'home', builder: (_, _) => const HomeScreen()),
    GoRoute(path: '/offer/:id', name: 'offer', builder: ...),
  ],
);

The screen is the route's name, or its path pattern (offer/:id) if it has none, never the concrete location, so ids do not end up in your analytics. Give your routes a name. Dialogs, bottom sheets and routes without a name are skipped; pass screenName: to decide yourself, returning null to skip a route. Since go_router 17 a ShellRoute reports its pages to the GoRouter observers, so one instance covers them; a second one in the shell's observers would count every view twice. With go_router 16 or older, or notifyRootObserver: false, the shell needs its own. Views are reported once Lotse.init has finished; views before init is called are dropped. With MaterialApp, use navigatorObservers: [LotseScreenObserver()].

Heatmaps

With Lotse.init(heatmaps: true) a LotseScope around the app records where users tap, per screen from LotseScreenObserver, and the console draws a heatmap from it:

await Lotse.init(publicKey: '…', serverUrl: '…', heatmaps: true);

A tap is a press released within 500 ms without moving; drags and scrolls are not taps. Each tap carries its position in logical pixels and, inside a vertical scroll view, the scroll offset, so taps on long lists land in the right place. Taps are sent in batches, at most every 30 seconds, from 100 taps and when the app goes to the background. Three or more taps within a second on one spot are rage taps; each burst also becomes the event rage_tap with screen, x and y, so "rage tappers" can be a segment.

Nothing is recorded while a dialog or bottom sheet is on top, on screens without a name, or on a LotseConfidential region. Taps are behavioural data like events: ask your users for consent as you do for analytics. Off by default.

The console draws a heatmap over a screenshot of its screen, one per screen, app version and width class (narrow below 360, regular up to 599, wide from 600 logical pixels). When one is missing, the server asks for it and the SDK takes it once that screen has been still for a second. By default only debug and profile builds do this: open each screen once on your own device after a new version. LotseConfidential regions are blacked out. To let release builds provide them too:

await Lotse.init(
  publicKey: '…',
  serverUrl: '…',
  heatmaps: true,
  heatmapBackgrounds: LotseBackgrounds.allBuilds,
);

App updates

When the app starts with another version than last time, init reports app_update with the props from and to. The version is version+buildNumber from your pubspec, e.g. 1.4.0+12; segments on the app version compare against exactly this text.

In-app messages

Your team writes in-app messages in the console: a card in the middle of the screen or a banner at the top, for a segment, opening the same deep link a push would. Put LotseInAppMessages into the builder of your MaterialApp, so it sits above every route:

MaterialApp.router(
  routerConfig: router,
  builder: (context, child) => LotseInAppMessages(
    onTap: (message) => router.go(message.targetPath ?? '/'),
    child: child!,
  ),
);

The SDK asks the server when the app starts and whenever it comes back to the front, at most once a minute, and shows one message at a time. The server checks the segment for this device at that moment, so a device that falls into it later gets the message then. Lotse.instance.checkInAppMessages() asks right away, for a moment your app knows better, e.g. after a purchase.

It tracks in_app_view, in_app_tap and in_app_dismiss with the prop messageId. A message the user tapped or closed does not come again; one that was only seen does, on a later start. The buttons read "Open" and "Close", in German on a German device; your team sets the card's button label per message. reset() forgets the messages of the old identity.

Users

Lotse works with anonymous installs. If your app knows who its user is, tell Lotse after login, and again whenever the traits change:

await Lotse.instance.identify('user-4711', traits: {'plan': 'pro', 'lang': 'de'});

The id is stored as is and shown in the console, so prefer a pseudonymous id from your own backend over an e-mail address. Several installs may name the same user; a later call merges its traits over the earlier ones and never removes one. Limits: an id of 1 to 256 characters, at most 50 traits per user, keys of 1 to 64 and values of at most 1024 characters. identify never throws; a refused call is logged without the id or a trait.

The user is a trait for analytics and segments. It opens nothing private: no install gains access to the feedback or conversations of another install of the same user. After reset() the new install is anonymous until you call identify again.

Identity verification

Without it, Lotse takes the id as the app says it. Anybody with your app can then call identify with the id of somebody else, overwrite that user's traits and with them the segments that decide which pushes, in-app messages and surveys the user gets. Identity verification closes that: your backend signs the id, and Lotse refuses an id without a valid signature.

Message pushes your backend sends to a userId (see "Message pushes") rely on it: they only go to devices whose identify came with a valid hash.

  1. In the console, open the project settings, "Identity verification", and generate the identity secret (lt_is_…). It is shown once. Put it where your backend keeps its secrets, e.g. an environment variable. Never ship it in the app and keep it out of version control: whoever has it can sign any id.

  2. Your backend computes the user hash of the signed-in user and hands it to the app with the login or session response:

    Node.js

    import { createHmac } from 'node:crypto';
    
    const userHash = createHmac('sha256', process.env.LOTSE_IDENTITY_SECRET)
      .update(userId, 'utf8')
      .digest('hex');
    

    Python

    import hashlib, hmac, os
    
    user_hash = hmac.new(
        os.environ["LOTSE_IDENTITY_SECRET"].encode("utf-8"),
        user_id.encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    

    Dart (package:crypto)

    import 'dart:convert';
    import 'package:crypto/crypto.dart';
    
    final userHash = Hmac(sha256, utf8.encode(identitySecret))
        .convert(utf8.encode(userId))
        .toString();
    
  3. The app passes it along:

    await Lotse.instance.identify(session.userId, userHash: session.lotseUserHash);
    

The rule: lowercase hex (64 characters) of HMAC-SHA256, the key being the UTF-8 bytes of the whole secret including lt_is_, the message the UTF-8 bytes of the user id as Lotse stores it, that is without leading and trailing whitespace. Ids from a database rarely have any; if yours might, trim the id before you hash it and pass the same id to identify. To check your code: the secret lt_is_example and the id user-42 give 2b6dbefac48e5741a14664b9210ebc33b3cf848a3f2be67e432f9144f517b117.

What the server does:

Project No hash Wrong hash Valid hash
no identity secret accepted ignored, accepted ignored, accepted
secret, verification not required accepted refused accepted
secret, verification required refused refused accepted

A refused call writes nothing: no user, no traits, no link to the install, no event. Like every failure of identify it is not thrown into the app; a debug build logs Lotse: could not identify the user: LotseInvalidInputException, without the id or the hash.

Roll it out in this order: generate the secret, ship the backend and an app version that send the hash, and switch on "Require verification" in the console once the versions without the hash are no longer in use. Until then calls without a hash still pass, wrong ones are refused.

Generating the secret again replaces it at once; hashes of the old one are refused from the next call on. Update the secret in your backend right away, or switch the requirement off while you do; an app should get a fresh hash with every login or session instead of keeping one forever. Deleting the secret switches verification off. Only the owner of the project can generate, delete or require it.

Where the law asks for consent to analytics (in the EU under the ePrivacy rules, e.g. § 25 TDDDG in Germany), start the SDK with the consent your app stored and change it when the user does:

await Lotse.init(
  publicKey: 'lt_pk_...',
  serverUrl: 'https://api.lotse.dev/',
  analytics: consent.analytics, // stored by your app
);

// In your privacy settings:
Lotse.instance.setAnalyticsEnabled(false);

With analytics off no event leaves the device: neither yours from track nor those of the SDK (session_start, screen_view, app_update, push_open, link_open, in_app_view, survey_view), no heatmap taps and no screenshots for heatmaps. Turning it off drops what still waits on the device. What keeps working: the device registration, the push token, identify, feedback, links, tapped pushes reaching initialPush and pushOpened, in-app messages and surveys. Dismissing an in-app message or a survey is still reported (in_app_tap, in_app_dismiss, survey_dismiss), so it does not come back. The SDK does not store the setting; on by default.

What stays on the device

  • The install credential is in the secure storage of the platform (Android Keystore, iOS Keychain).
  • Events and heatmap taps that could not be sent yet wait in SharedPreferences (keys lotse.eventQueue and lotse.tapQueue), at most 1,000 events for at most 30 days, with the props your app tracked. So do the install id and a few flags (lotse.*). On Android, SharedPreferences is part of Auto Backup unless your app excludes it; if your props can hold personal data, exclude the file FlutterSharedPreferences.xml in your backup rules (android:fullBackupContent / android:dataExtractionRules).
  • The SDK logs to the console of the app with the prefix Lotse:. Release builds get the type of an error, not its text.

Logout and account switch

Lotse has no user accounts. Feedback, the support chat, votes and pushes belong to an anonymous install. If your app has a login, tell Lotse when the user changes, before the next one sees the app:

await Lotse.instance.reset();

At once, before the future completes, open Lotse pages close, their live updates stop, unreadFeedbackCount drops to zero and answers of the server still on their way are discarded. Then the credential of the old identity is revoked, the push token of the device is deleted (on iOS the APNs token moves to the new install instead, see "Push on iOS"), and the SDK goes on as a new install with a new installId. Without a network the revocation is made up for on the next start. reset never throws.

The conversations of the old identity stay with your team in the console. From this app install they cannot be reached again, also not when the same person logs back in: Lotse cannot know it is the same person. A reinstall of the app is a new identity as well.

Lotse.instance.installId names the install and is not a secret. What opens its conversations is a credential the SDK keeps in the Android Keystore / iOS Keychain and never hands to the app.

Feedback

// Once around the app. Provides the boundary the screenshot is taken of.
runApp(LotseScope(child: MyApp()));

// Anywhere below a Navigator.
await Lotse.instance.showFeedback(
  context,
  metadata: {'cart': '3 items'}, // stored with the message, at most 20 entries
  askForEmail: true,             // optional e-mail field, off by default
);

// Black in every screenshot, unchanged on screen.
LotseConfidential(child: Text(iban));

What Lotse blacks out is painted into the image, never on the screen: LotseConfidential regions, every text input and every platform view (web view, map). Wrap a search field or a harmless web view in LotseUnmasked to show it; a LotseConfidential inside still hides its part. Platform views are grey with a window symbol. Only texture-based Android views (the default of AndroidView and webview_flutter) can appear in the image. A platform view that cannot, with Hybrid Composition, on iOS or on the web, stays grey inside LotseUnmasked too, labelled "Not recordable". LotseScope(maskAllText: true) blacks out every text as well. Custom input widgets that do not use Flutter's EditableText are not recognised; wrap them in LotseConfidential. Masks are found in everything Flutter paints, also below widgets that hide their subtree from screen readers (ExcludeSemantics, Semantics(excludeSemantics: true)). Only what is not painted is skipped: offstage routes and widgets, fully transparent subtrees, the hidden children of an IndexedStack. A Visibility that hides its child but keeps its size is masked as if it were shown.

The sheet is a modal bottom sheet in the theme of the app: category (bug, idea, praise, other), message, the optional e-mail field, a preview of the screenshot with "retake", "mark" and "remove", and a send button.

  • The screenshot is taken before the sheet opens. "Retake" closes the sheet and shows a floating bar with "Take screenshot" and "Cancel". The user can go anywhere in the app to the screen the feedback is about and capture it there. The bar can be dragged out of the way; it is never in the image. The sheet comes back with everything the user entered, and the last screen sent is the one captured. "Cancel" keeps the screenshot the sheet had.
  • "Mark" opens the screenshot full screen. The user draws on it with one pen in one colour, with undo and clear. "Done" burns the strokes into the image that is sent; opened again, earlier strokes can still be undone.
  • Without a LotseScope the sheet works the same, without a screenshot.
  • Screens with platform views (maps, web views) are captured like any other; the views are masked as described above.
  • If sending fails, the sheet shows the error, keeps every input and offers a retry. There is no offline queue yet.
  • Screenshots are scaled to at most 1280 px on the longest edge and 1 MB, with or without marks.
  • The name of the current route (ModalRoute.of(context)?.settings.name) is sent as the last screen, so named routes make the inbox easier to read. After a retake it is the route on top of the navigator.

Replay of the last seconds

await Lotse.init(
  publicKey: '…',
  serverUrl: '…',
  replay: const LotseReplayOptions(seconds: 60), // 15 to 120
);

With this option LotseScope keeps the last seconds of the screen in memory: a few pictures a second, only after Flutter drew something, masked like screenshots (text inputs, platform views and LotseConfidential regions), plus taps and screen changes. Nothing leaves the device on its own. The feedback sheet shows "Attach the last 60 seconds" when there is a recording; if the user keeps it on, the recording goes up after the message, and your team plays it in the console next to the screenshot. The recorder pauses while the sheet is open, so the message being typed is never in it. attachByDefault: false starts the switch off. Call init before runApp.

// Nothing of this part of the app is recorded while it is shown.
LotseReplayPause(child: KidsModeScreen());

LotseReplayPause stops recording frames and taps while it is in the tree, for screens that should never be in a replay. Screenshots are not affected; mask those with LotseConfidential.

Conversations

The team can answer a message from the console. The user finds the answer under "My feedback" and can reply there:

// The list of this device's messages with status and unread marker. A tap
// opens the conversation with a reply field.
await Lotse.instance.showMyFeedback(context);

// For a badge on the entry that leads there.
ValueListenableBuilder<int>(
  valueListenable: Lotse.instance.unreadFeedbackCount,
  builder: (context, unread, child) =>
      Badge(isLabelVisible: unread > 0, label: Text('$unread'), child: child),
  child: const Icon(Icons.forum_outlined),
);
  • An open conversation gets new replies live: it keeps a Serverpod streaming method (a WebSocket to your serverUrl) open and reloads as soon as the server says something changed. If that connection cannot be opened or drops, it asks every five seconds instead and tries the live connection again a little later. It marks what it shows as read. Nothing is open or polled while no conversation is on screen or the app is in the background. In a browser the API host refuses the WebSocket handshake of foreign origins, so the SDK on the web polls.
  • unreadFeedbackCount is refreshed at start, when a push announces a reply and after a conversation was read.
  • A team reply arrives as a push. That push is not an app route: it never shows up in initialPush or pushOpened. Tapping it opens the conversation, for which Lotse needs the navigator of the app. Give LotseScope the same key as the router:
final navigatorKey = GlobalKey<NavigatorState>();
final router = GoRouter(navigatorKey: navigatorKey, routes: routes);

runApp(
  LotseScope(
    navigatorKey: navigatorKey,
    child: MaterialApp.router(routerConfig: router),
  ),
);

Without a router the key goes to MaterialApp(navigatorKey: ...). An existing router has its key at router.routerDelegate.navigatorKey. Without a key the push just brings the app to the front. A conversation opened from a push uses the built-in texts for the device locale.

  • While the app is in the foreground, Firebase shows no notification. The badge still updates, and an open conversation shows the reply right away.

Support chat

The same conversations carry a support chat, in which the user asks first:

await Lotse.instance.showSupportChat(context);
  • The chat is switched on per project in the console (project settings) and is off by default. While it is off the page shows a notice instead of the question field; the app does not have to ask before it offers the entry.
  • The page opens the running conversation of this device or, without one, a field for the first question. A conversation is over once the team set it to done or dismissed; the next question starts a new one. Earlier ones stay under "My feedback" with the category "Question".
  • A conversation that runs when the chat is switched off stays open for replies from both sides.
  • Replies count into unreadFeedbackCount and arrive as the same push as replies to feedback. Questions count into the feedback quotas.
  • It is an asynchronous chat: no typing indicator, attachments, opening hours or bot.

Feature board

The team puts ideas on the board in the console, users vote on them:

await Lotse.instance.showFeatureBoard(context);
  • Four tabs: Ideas, Planned, In progress and Shipped, each with the ideas in that status, most votes first. Archived and merged ideas do not show up.
  • One tap votes, a second one takes the vote back. The count moves at once; if the server does not take the vote, it is taken back with a message. A device gets 50 new votes in 24 hours, taking a vote back is never turned away.
  • Pulling a tab down loads the whole board again. An empty tab says so.
  • Users cannot hand in ideas on the board. Offer showFeedback for that; the team turns a message of the category idea into an idea on the board with one click in the console.
  • Votes belong to the install. Each one is also the event feature_voted with the prop featureRequestId, so "voted for X" works as a segment for the push that announces X.

Texts

English and German are built in and picked by the device locale. Override single texts or pass a complete set:

Lotse.instance.showFeedback(
  context,
  texts: LotseFeedbackTexts.english.copyWith(title: 'Tell us'),
);

showMyFeedback, showSupportChat and showFeatureBoard take the same texts; the set covers the sheet, the list, the conversations, the support chat and the feature board.

AI agents

The package ships agent skills in the agentskills.io format, for Claude Code, Cursor, Codex and other agents: lotse-setup, lotse-events, lotse-deep-links, lotse-push, lotse-feedback, lotse-mcp (working with a project through the Lotse MCP server; create the token in the console under project settings → AI agents, and never write it into files in your repository), lotse-server-api (your backend and Lotse: the server API with a server key, pushes to one user, server events, webhooks and their signatures) and lotse-purchases (in-app purchases with the lotse_purchases package).

Install them into your app with the skills CLI, which finds the skills of every package your app depends on:

dart pub global activate skills
skills get --package lotse --all

Run skills get again after updating lotse to update the skills.

Libraries

lotse
Lotse SDK: deep links, attribution and push for Flutter apps.
lotse_internal
Internal API of the Lotse SDK for Lotse's own packages, such as lotse_purchases. Not for apps: it exposes the generated client, and anything here may change in any release.