# Library codegen for Kotlin, Swift, and TypeScript

Avo can emit generated code in two shapes for Kotlin, Swift, and TypeScript sources: the default **single-source** output (one file per source) and the **library mode** output that splits the runtime from the per-source types. Both modes are supported — they serve different use cases and you can generate either from the same source.

Library mode is the better fit when you have multiple Avo sources sharing the same app, when you're building an internal tracking library reused across apps, or when you want the runtime code separated from the per-source event types. If that library is shared between repositories, see [section 4](#4-sharing-one-library-interface-across-repositories) for how to stop each repo regenerating it.

---

## 1. The shape of library mode

`avo pull` emits multiple files per source instead of one. The shared source-independent runtime — `Avo` class, validation helpers, `AvoEvent` protocol/interface, `AvoInvoke` — lives in a separate "library" file. The per-source "app" file contains only the things that change when your tracking plan changes: per-event classes and the codegen-bound IDs. Optionally, a third file is extracted with everything needed for `Avo` initialization, so initialization can be decoupled from the apps too.

### What gets generated

- **Library file**: the runtime. `AvoEnv`, `AvoAssert`, `AvoInvoke`, the `AvoEvent` protocol/interface, and the `Avo` class. Regenerated rarely. Every source generates an identical library file. Shared across all sources/apps. Optionally orchestrated from a single place — "the library".
- **App file**: per-source types and a thin config object. Regenerated whenever the tracking plan source changes.
- In TypeScript, a third file `AvoConfig.ts` holds the source config with `schemaId`/`actionId`/`branchId`/`sourceId` plus destination API keys. It has no runtime imports — just the values.

### Mental model

Events are values you construct, not method calls. You build an instance of `LoginSuccessEvent(timestamp: ..., teamId: ..., ...)` and pass it to `avo.process(event)` (Swift/Kotlin) or `avo.track(event)` (TypeScript). The call returns a map keyed by destination name, with the per-destination payload (event name + properties) for each one.

The boundary: the library file knows nothing about your tracking plan. The app file knows nothing about how validation or invocation metrics work. You decide what to do with the per-destination map the call returns — either fan out manually, or hand off to destination implementations the runtime calls for you.

---

## 2. Quick start

Enable library mode on a per-source basis with the `--forceFeatures` flag on `avo pull`. The flag names match the language:

```bash
avo pull --forceFeatures SwiftLibraryInterface
avo pull --forceFeatures KotlinLibraryInterface
avo pull --forceFeatures TypeScriptLibraryInterface
```

You can also enable these features in your Avo workspace if you'd like them on by default — [reach out](https://www.avo.app/docs/help/troubleshooting.md) and we'll switch them on for your source.

### Kotlin

`avo pull` produces:

- `Analytics.kt` — per-event data classes and `AvoTrackingPlanConfig`
- `AnalyticsLibraryInterface.kt` — runtime (`Avo` class, `AvoEvent`, `AvoAssert`, `AvoInvoke`)

Both files share the same package (inferred from the source path, default `sh.avo`). Treat them like any other Kotlin files in your project — drop them anywhere in your source tree that fits your module layout.

Initialize using the `initAvo` extension that's generated alongside the per-event classes:

```kotlin
import sh.avo.Avo
import sh.avo.AvoEnv
import sh.avo.AvoVerificationError
import sh.avo.LoginSuccessEvent
import sh.avo.initAvo

val avo = Avo.initAvo(env = AvoEnv.DEV)

try {
    val payloads = avo.process(LoginSuccessEvent(
        timestamp = 1730000000,
        teamId = "team_42",
        teamDomain = "acme.example"
    ))

    payloads["custom"]?.let { event ->
        myAnalyticsSdk.logEvent(event.name, event.properties)
    }
} catch (e: AvoVerificationError) {
    // Validation failed and strict mode is on.
    println("[avo] ${e.messages.joinToString(", ")}")
}
```

`process()` returns `Map<String, AvoDestinationEvent>` keyed by destination name (lowercased identifier from your Avo workspace). In development and staging it throws `AvoVerificationError` when validation fails and `strict = true` (the default), otherwise it logs a warning and continues. In production, runtime validation is skipped entirely — `process()` never throws or logs, and always returns the destination map. Returns an empty map in noop mode.

### Swift

`avo pull` produces:

- `Analytics.swift` — `AvoTypes` namespace, per-event structs, `AvoTrackingPlanConfig`, and an `extension Avo` with a no-config `initAvo`
- `AnalyticsLibraryInterface.swift` — runtime

The app file imports `Library`. You decide where the library file lives: package it as a Swift module called `Library` (SPM package, Xcode target, internal framework — whichever fits your project) so the import resolves, or drop both files into the same module and remove the `import Library` line from the app file by hand. Either approach works; the library file is just Swift source.

Initialize:

```swift
import Library

let avo = Avo.initAvo(env: .dev)

do {
    let payloads = try avo.process(event: LoginSuccessEvent(
        timestamp: 1730000000,
        teamId: "team_42",
        teamDomain: "acme.example"
    ))

    if let event = payloads["custom"] {
        MyAnalyticsSDK.logEvent(name: event.name, properties: event.properties)
    }
} catch let error as AvoVerificationError {
    // Validation failed and strict mode is on.
    print("Avo verification: \(error.messages.joined(separator: \", \"))")
}
```

`process(event:)` returns `[String: AvoDestinationEvent]`. In development and staging it throws `AvoVerificationError` when validation fails and `strict` is on, otherwise it logs via `NSLog` and continues. In production, runtime validation is skipped entirely — `process(event:)` never throws or logs. Returns an empty dictionary in noop mode.

### TypeScript

TypeScript library mode has two file layouts. By default `avo pull` emits one file for all events; with the **"Generate one file per event"** source setting on (Source → Avo Codegen Setup), each event gets its own module. Both layouts expose the same `./Avo` import surface, so the init, destination, and tracking code below applies to either one.

#### One file for all events

`avo pull` produces three files:

- `Avo.ts` — per-event classes (`LoginSuccessEvent`, etc.) extending `BaseAvoEvent`, plus re-exports of the public runtime surface
- `AvoLibrary.ts` — runtime (`Avo` class, `AvoEvent`, `AvoAssert`, `BaseAvoEvent`)
- `AvoConfig.ts` — codegen-bound config (default export), `DestinationKey` union, and per-destination API keys

`AvoConfig.ts` is the only file that contains your `schemaId`/`actionId`/`branchId`. Move it (and update the import path) wherever you want `Avo.init` to live — at the app level, a library level, or any module boundary.

There are no runtime dependencies outside the standard library and `fetch` (used by `AvoInvoke`).

`Avo.init` takes two arguments: a runtime `AvoConfig` and the codegen-bound config (default-exported from `AvoConfig.ts`). The recommended starting point is to wire all destinations through the runtime:

```ts
import { Avo, AvoEnv, AvoVerificationError, LoginSuccessEvent } from './Avo';
import codegenConfig from './AvoConfig';

const avo = Avo.init(
  {
    env: AvoEnv.Dev,
    destinations: {
      Custom: {
        make(env, apiKey) { /* initialize your SDK */ },
        logEvent(event) { myAnalyticsSDK.logEvent(event.name, event.properties); },
        setUserProperties(userId, props) { myAnalyticsSDK.identify(userId, props); },
      },
    },
  },
  codegenConfig,
);

try {
  avo.track(new LoginSuccessEvent(1730000000, 'team_42', 'acme.example'));
} catch (e) {
  if (e instanceof AvoVerificationError) {
    console.error('[avo]', e.messages.join(', '));
  } else {
    throw e;
  }
}
```

`AvoCustomDestination` has the same shape as the custom destination interface in the single-source output — `make` (optional), `logEvent`, `setUserProperties`, `identify`, `unidentify`, `logPage`, and `revenue` — so an existing single-source destination implementation is drop-in compatible. `destinations` is all-or-nothing: if you provide any, you must provide an implementation for every `DestinationKey` or `Avo.init` will throw.

If the all-or-nothing model doesn't fit (for example, you want to fan out manually for some destinations), omit `destinations` and fan out yourself:

```ts
const avo = Avo.init({ env: AvoEnv.Dev }, codegenConfig);

try {
  const payloads = avo.track(new LoginSuccessEvent(1730000000, 'team_42', 'acme.example'));

  const custom = payloads['Custom'];
  if (custom) {
    myAnalyticsSDK.logEvent(custom.name, custom.properties);
  }
} catch (e) {
  if (e instanceof AvoVerificationError) {
    console.error('[avo]', e.messages.join(', '));
  } else {
    throw e;
  }
}
```

`track()` returns `Record<string, AvoDestinationEvent>` keyed by `DestinationKey` from `AvoConfig.ts`. It throws `AvoVerificationError` the same way Kotlin/Swift do.

#### One file per event

With the **"Generate one file per event"** source setting on, each event is split into its own module for better tree‑shaking. `avo pull` then emits an `AvoEvents/` directory and one extra file:

```text
One file for all events
├─ Avo.ts          # per-event classes + runtime re-exports
├─ AvoLibrary.ts   # runtime
└─ AvoConfig.ts    # codegen-bound config

One file per event
├─ Avo.ts                    # thin barrel — re-exports everything below
├─ AvoLibrary.ts             # runtime (unchanged)
├─ AvoConfig.ts              # codegen-bound config (unchanged)
├─ AvoAppSystemProperties.ts # AppSystemProperties singleton
└─ AvoEvents/
   ├─ index.ts
   ├─ types.ts               # shared object and enum types
   └─ <Event>.ts             # one module per event
```

The public import surface does not change. `Avo.ts` becomes a thin barrel that re‑exports the runtime (`Avo`, `AvoEnv`, `AvoVerificationError`, …), every per‑event class, `AppSystemProperties`, and the shared types — so `import { Avo, AvoEnv, AvoVerificationError, LoginSuccessEvent } from './Avo'` keeps working exactly as in the one-file layout above. You don't need to change any call sites.

`AppSystemProperties` moves out of `Avo.ts` into its own `AvoAppSystemProperties.ts` file so the per‑event modules can import it from a sibling — importing it from the `Avo.ts` barrel would create an import cycle, since the barrel re‑exports the per‑event files. You still import `AppSystemProperties` from `./Avo`; this is only an internal file split.

---

## 3. Differences from the single-source output

If you're switching an existing source from the single-source output to library mode, here's what changes — and what stays the same.

### Before / after

#### Kotlin

```kotlin
// Single-source
val avo = Avo(env = AvoEnv.DEV, customDestination = MyCustomDestination())
avo.loginSuccess(
    timestamp = 1730000000,
    teamId = "team_42",
    teamDomain = "acme.example",
)

// Library mode
val avo = Avo.initAvo(env = AvoEnv.DEV)
try {
    val payloads = avo.process(LoginSuccessEvent(
        timestamp = 1730000000,
        teamId = "team_42",
        teamDomain = "acme.example",
    ))
    payloads["custom"]?.let { event ->
        myAnalyticsSdk.logEvent(event.name, event.properties)
    }
} catch (e: AvoVerificationError) {
    println("[avo] ${e.messages.joinToString(", ")}")
}
```

#### Swift

```swift
// Single-source
let avo = Avo(env: .dev, customDestination: MyCustomDestination())
avo.loginSuccess(
    timestamp: 1730000000,
    teamId: "team_42",
    teamDomain: "acme.example"
)

// Library mode
let avo = Avo.initAvo(env: .dev)
do {
    let payloads = try avo.process(event: LoginSuccessEvent(
        timestamp: 1730000000,
        teamId: "team_42",
        teamDomain: "acme.example"
    ))
    if let event = payloads["custom"] {
        MyAnalyticsSDK.logEvent(name: event.name, properties: event.properties)
    }
} catch let error as AvoVerificationError {
    print("Avo verification: \(error.messages.joined(separator: \", \"))")
}
```

#### TypeScript

```ts
// Single-source
import Avo from './Avo';
Avo.initAvo({ env: Avo.AvoEnv.Dev }, /* destinations */ {});
Avo.loginSuccess({
  timestamp: 1730000000,
  teamId: 'team_42',
  teamDomain: 'acme.example',
});

// Library mode
import { Avo, AvoEnv, AvoVerificationError, LoginSuccessEvent } from './Avo';
import codegenConfig from './AvoConfig';

const avo = Avo.init({ env: AvoEnv.Dev }, codegenConfig);

try {
  const payloads = avo.track(new LoginSuccessEvent(1730000000, 'team_42', 'acme.example'));

  const custom = payloads['Custom'];
  if (custom) {
    myAnalyticsSDK.logEvent(custom.name, custom.properties);
  }
} catch (e) {
  if (e instanceof AvoVerificationError) {
    console.error('[avo]', e.messages.join(', '));
  } else {
    throw e;
  }
}
```

### What stays the same

- **Event names, property names, and validation rules.** The tracking plan is unchanged — only the shape of the generated code differs.
- **Strict / noop modes.** `strict` (throw on validation failure in development and staging) and `noop` (drop network calls) behave the same as in the single-source output — runtime validation is skipped entirely in production, and `noop` is force-disabled in production.
- **Inspector integration.** Construct your Avo Inspector instance from the SDK and pass it into the init call — same as the single-source output.
- **Implementation status tracking.** The CLI reports event-usage status the same way regardless of mode.
- **Destination interface.** TypeScript's `AvoCustomDestination` has the same shape as the single-source custom destination (`make`, `logEvent`, `setUserProperties`, `identify`, `unidentify`, `logPage`, `revenue`) — an existing single-source implementation is drop-in compatible.
- **The `avo pull` workflow.** Same command, same authentication, same source/destination configuration in your Avo workspace.
- **Destination API keys.** Still embedded by codegen. In TypeScript they're in `AvoConfig.ts`; in Swift/Kotlin they're inside `AvoTrackingPlanConfig` and used by the `initAvo` extension.

### What changes

1. **`Avo` constructor signature.** The single-source `Avo(env:, customDestination:, ...)` (Swift/Kotlin) and the free-function `Avo.initAvo(config, destinations)` (TypeScript) become `Avo.initAvo(env:)` (Swift/Kotlin) and `Avo.init(config, codegenConfig)` (TypeScript).
2. **Event method calls become event constructors and a single `process` / `track` call.** Each `avo.someEvent(...)` becomes `avo.process(SomeEvent(...))` (Swift/Kotlin) or `avo.track(new SomeEvent(...))` (TypeScript).
3. **`process()` / `track()` throws `AvoVerificationError`.** In development and staging, library mode in all three languages throws a typed `AvoVerificationError` when `strict` is on — wrap calls in `try` / `do-catch`, see the quick-start examples. In production, runtime validation is skipped entirely, so the call never throws.
4. **TypeScript: how custom destinations are wired.** Custom destinations are passed in the `destinations` map of the runtime config, keyed by `DestinationKey`, instead of as separate `Avo` constructor arguments. All-or-nothing — provide an implementation for every `DestinationKey`, or omit `destinations` entirely and fan out manually. The `AvoCustomDestination` interface itself has the same shape as the single-source custom destination.
5. **System properties.** `setSystemProperties(...)` / `Avo.setSystemProperties(...)` becomes `AppSystemProperties.configure(...)` (Kotlin) or `AppSystemProperties.shared.configure({...})` (Swift, TypeScript). `verify()` and `process()` / `track()` throw `AvoVerificationError` with the message "AppSystemProperties.configure() must be called before sending events." if a tracked event needs system properties and `configure()` hasn't run.
6. **Imports.** Add the library file to your build and import its types where you previously imported from the single file. In Swift this is `import Library` (if you packaged it that way); in Kotlin it's `import sh.avo.*` (or whatever package your source path resolves to); in TypeScript it's `import { ... } from './Avo'` (the app file re-exports the public library surface).

---

## 4. Sharing one library interface across repositories

When one repository owns the library file and others only consume it, a plain `avo pull` on either side generates files that do not belong there. The `libraryInterfaceFileFilter` setting in `avo.json` (Avo CLI 3.6.0+) tells `avo pull` which side to generate. It only affects sources in library mode.

| Value | Generates | Use in |
|---|---|---|
| `all` (default) | Every file, as before | Repos holding both the interface and the events |
| `interface-only` | Only the library file: `AnalyticsLibraryInterface.kt` / `.swift`, `AvoLibrary.ts` | The repo that owns the shared interface |
| `events-only` | Everything else: the app file, and in TypeScript also `AvoConfig.ts`, `AvoEvents/` and `AvoAppSystemProperties.ts` | Client repos importing the interface from a shared module |

`AvoConfig.ts` is on the events side despite its name: it holds the source-specific ids and keys a shared interface must not contain.

### Configuring it

Add the keys to `avo.json` by hand — `avo init` does not prompt for them. A top-level key applies to every source; the same key on a `source` entry overrides it for that source. `avo pull --libraryInterfaceFileFilter <value>` overrides both for a single run without writing to `avo.json`. Each source resolves: flag → source key → top-level key → `all`.

Client repos also need **`libraryInterfaceSharedModule`** — the module the generated app files import the interface from, placed top-level or per-source the same way:

| Language | Value |
|---|---|
| Kotlin | Package name of the shared interface, e.g. `"sh.avo.shared"` |
| TypeScript | Module specifier. A bare specifier (`"@acme/analytics"`) is emitted verbatim in every generated import of the library interface. A relative one (`"../shared/AvoLibrary"`) is resolved relative to the output folder, so it only works within one repository |
| Swift | Not used — the app file already does `import Library`; ship the shared interface as that module |

The value is passed to codegen verbatim and only has an effect when the resolved filter is not `all`. The repo that owns the interface does not need it.

Shared repo:

```json
{
  "libraryInterfaceFileFilter": "interface-only",
  "sources": [{ "id": "...", "name": "Web", "path": "src/Avo.ts", "actionId": "...", "branchId": "...", "updatedAt": "..." }]
}
```

Each client repo:

```json
{
  "libraryInterfaceFileFilter": "events-only",
  "libraryInterfaceSharedModule": "@acme/analytics",
  "sources": [{ "id": "...", "name": "Web", "path": "src/Avo.ts", "actionId": "...", "branchId": "...", "updatedAt": "..." }]
}
```

In a monorepo with a single `avo.json`, put the same keys on the individual `sources` entries instead. `path` stays as usual: an `interface-only` source with `path: "src/Avo.ts"` writes only `src/AvoLibrary.ts`.

### Good to know

- **Stale files are reported, never deleted.** After a filter change, `avo pull` warns `[avo] Warning: <file> is no longer generated here because of the current libraryInterfaceFileFilter…` for each file it used to write, on every pull until you remove it.
- **`avo.json` conflicts:** if the two sides disagree on `libraryInterfaceFileFilter`, your current branch's value is kept and the discarded one is printed.
- **Invalid values** are rejected by every command that reads `avo.json`.
- **Which sources can share one interface:** the library file has no source-specific ids or keys, but its `AvoEnv` cases come from the source's destination environments — share it between repos pulling the same source, or sources with an identical set of destination environments.

> Avo is the analytics governance platform for product data that lets you define it consistently, implement it correctly and trust it everywhere. Using an AI agent? Connect it to the [Avo MCP](https://www.avo.app/docs/reference/avo-mcp/overview.md) at `https://mcp.avo.app/mcp` and it can read and change your tracking plan on a branch.
