Angular CLI

This guide assumes an Angular 20 or later project built with the Angular CLI. It covers standalone component apps (the default) and classic NgModule apps. By the end you will have flag reads as signals or observables, template-level gating, and exposure tracking you control.

@avsbhq/angular is built with ng-packagr and published in the Angular Package Format, so it compiles under AOT in a normal CLI application. @angular/core (20 or later) and rxjs (7 or later) are peer dependencies and come from your app. @avsbhq/core and @avsbhq/browser are regular dependencies, so you do not install them yourself. @angular/common is not a peer dependency: the package never imports it.

1

Install

Add @avsbhq/angular.

2

Obtain your SDK key

Open Environments in your A vs B project's sidebar and copy the key for the environment this build serves. Keys are shaped sdk_<environment>_<id>.

3

Call provideAvsb once, at bootstrap

In app.config.ts for a standalone app, or AvsbModule.forRoot(...) in your AppModule. Exactly once: a second call in a lazy route's providers builds a second client.

4

Read a flag

Every helper is prefixed avsb, so nothing collides with a track or identify of your own. Signals and observables read the same flags.

5

Gate a template

The *avsbFlag structural directive renders a branch when the flag is on, with an else template and variation matchers.

6

Record the exposure

Reads fire no exposure event. avsbExposure (or recordExposure) marks the moment the visitor actually saw the decision.

7

Identify a user

Call identify after sign-in; every subscribed signal and observable re-evaluates.

Install the package:

Shell
npm install @avsbhq/angular
Shell1 line

Add the SDK key to your environment files (src/environments/environment.ts):

TypeScript
export const environment = {  production: false,  avsbSdkKey: 'sdk_development_xxxxxxxxxxxxxxxx',}
TypeScript4 lines
Environments is its own item in the sidebar. Click Reveal, then Copy, to get the SDK key for this environment.
  1. Environments lives in the sidebar on its own, not inside Settings.
  2. Click Reveal to see the full key, then Copy to copy it.
Your SDK key is public
Your SDK key is a public identifier, not a secret: it is safe to ship in browser and mobile bundles, it can only fetch that environment's flag configuration and send events, and it can never read or change anything in your dashboard. Credentials covers all four A vs B credentials and which one to reach for.

Configure once, at bootstrap

Standalone apps configure the SDK in src/app/app.config.ts:

TypeScript
import type { ApplicationConfig } from '@angular/core'import { provideRouter } from '@angular/router'import { provideAvsb } from '@avsbhq/angular'import { environment } from '../environments/environment'import { routes } from './app.routes'export const appConfig: ApplicationConfig = {  providers: [    provideRouter(routes),    provideAvsb({      sdkKey: environment.avsbSdkKey,      context: { kind: 'user', key: 'anonymous' },    }),  ],}
TypeScript15 lines
NgModule apps and lazy routes

For an NgModule app, put AvsbModule.forRoot({ sdkKey, context }) in your AppModule imports array instead of calling provideAvsb. Feature modules import plain AvsbModule (no forRoot) to get the directives and pipes; that adds no providers, so they reuse the root client.

Call the root form exactly once. Calling it again in a lazy route's providers builds a SECOND client for that route: deliberate when you want a different context there, a duplicate datafile fetch and a duplicate event queue when you do not.

Injecting AvsbService in an application that called neither provideAvsb() nor AvsbModule.forRoot() throws, with a message naming the call to add. The alternative was an application that silently served default values forever.

provideAvsb also accepts a client you built yourself: provideAvsb({ client }). In that mode the package never closes the client, because it is yours. That is also the whole testing story, below.

Reading a flag

Every helper is the terse form of a matching AvsbService method, and every name carries the avsb prefix:

TypeScript
// docs-example: not typechecked here, because an Angular component is compiled// by the Angular compiler under experimentalDecorators, which the standalone// TypeScript program this documentation gate builds does not enable.import { Component } from '@angular/core'import type { Signal } from '@angular/core'import { avsbBoolFlagSignal, AVSB_TEMPLATE_FEATURES } from '@avsbhq/angular'import type { Flag } from '@avsbhq/angular'@Component({  selector: 'app-checkout',  standalone: true,  imports: [AVSB_TEMPLATE_FEATURES],  template: `    @if (checkout().isEnabled()) {      <app-new-checkout avsbExposure="checkout_v2" />    } @else {      <app-legacy-checkout />    }  `,})export class CheckoutComponent {  readonly checkout: Signal<Flag<boolean>> = avsbBoolFlagSignal('checkout_v2', false)}
TypeScript23 lines

Three things are true of that component and of every read in this package:

  1. Reading a flag fires no exposure event, which is what makes reads safe to call during change detection. avsbExposure records that the visitor was shown the decision.
  2. Before the datafile arrives, checkout().value is the default you passed and checkout().source is 'not_ready'. Nothing is ever undefined.
  3. The signal updates by itself when the flag changes in the dashboard.

The typed helpers return a Flag<T>, not a bare value. Use the Value forms when the value is all you want:

TypeScript
import type { Signal } from '@angular/core'import type { Observable } from 'rxjs'import {  avsbStringFlagSignal,  avsbFlagValueSignal,  avsbBoolFlag$,  avsbFlagValue$,} from '@avsbhq/angular'import type { Flag } from '@avsbhq/angular'// Every helper below resolves AvsbService through the current injector, so call// this from a field initializer, a constructor, or runInInjectionContext().function readsInsideAnInjectionContext(): void {  const hero: Signal<Flag<string>> = avsbStringFlagSignal('homepage_hero', 'control')  const heroValue: Signal<string> = avsbFlagValueSignal('homepage_hero', 'control')  const checkout$: Observable<Flag<boolean>> = avsbBoolFlag$('checkout_v2', false)  const checkoutValue$: Observable<boolean> = avsbFlagValue$('checkout_v2', false)}
TypeScript19 lines

The same reads are available as methods on an injected AvsbService, which is the form to reach for outside an injection context:

TypeScript
import type { Observable } from 'rxjs'import type { AvsbService, Flag } from '@avsbhq/angular'function readsAnywhere(avsb: AvsbService): void {  // Observables and snapshot() are safe anywhere.  const hero$: Observable<Flag<string>> = avsb.getStringFlag('homepage_hero', 'control')  const now: Flag<boolean> = avsb.snapshot('checkout_v2', false)  // Each observable emits the current value on subscribe, then once per change  // to that flag. It never errors and never completes.}
TypeScript11 lines
Which calls need an injection context

Every standalone helper and every *Signal form asks Angular for the current injector, so they belong in a field initializer, a constructor, a factory function, or runInInjectionContext(). They are not valid in ngOnInit, an event handler, a setTimeout callback, or a promise continuation. Pass { injector } to use one there. Called outside an injection context with no injector, a helper throws Angular's NG0203 naming the helper you called.

Everything on an injected AvsbService that returns an Observable, plus snapshot(), track, identify, alias, reset, and recordExposure, is safe anywhere.

Gating a template

AVSB_TEMPLATE_FEATURES carries all four template features: the *avsbFlag structural directive, the avsbExposure directive, and the avsbFlag and avsbFlagState pipes. Add it to a standalone component's imports, or import plain AvsbModule into a feature module:

HTML
<!-- the flag is on for this visitor --><section *avsbFlag="'new-dashboard'">...</section><!-- with a fallback branch --><section *avsbFlag="'new-dashboard'; else legacyDashboard">...</section><ng-template #legacyDashboard>...</ng-template><!-- one variation of a multivariate flag, by variation key --><section *avsbFlag="'hero'; default: 'control'; whenVariation: 'variant-a'">...</section><!-- or by the value that variation carries --><section *avsbFlag="'hero'; default: 'control'; whenValue: 'blue'">...</section><!-- record the exposure when this branch is shown --><section *avsbFlag="'new-dashboard'; exposure: true">...</section>
HTML15 lines

With no matcher, the directive renders when flag.isEnabled() is true. whenVariation compares Flag.variationKey, the name shown in the dashboard. whenValue compares Flag.value with Object.is, so a falsy value such as 0 or '' matches correctly. Setting both means both must match, and the package logs a warning saying so.

The two pipes cover the same ground inside an expression:

HTML
<h1>{{ 'homepage_hero' | avsbFlag: 'Welcome' }}</h1>@let state = 'checkout_v2' | avsbFlagState: false;@if (state.source === 'not_ready') {  <app-skeleton />} @else if (state.isEnabled()) {  <app-new-checkout />}
HTML8 lines

avsbFlag gives the value, typed from the default you pass. avsbFlagState gives the whole Flag, which is how a template tells "no datafile yet" apart from a real false. Both are impure and re-subscribe when the key or the default changes, so hoist object and array defaults to a field rather than writing them inline.

The Flag<T> object

TypeScript
import type { Flag, EvaluationSource, RuleType } from '@avsbhq/angular'function formatFlag(flag: Flag<boolean>): string {  const value: boolean = flag.value  const variationKey: string | null = flag.variationKey  const source: EvaluationSource = flag.source  const ruleId: string | null = flag.ruleId  const ruleType: RuleType | null = flag.ruleType  const reasons: string[] = flag.reasons  return `${String(value)} from ${source} (${variationKey ?? 'no variation'}) ${reasons.join(', ')}`}
TypeScript12 lines

source is one of datafileOverride, runtimeOverride, sticky, rule, holdout, bandit, default, disabled, not_found, or not_ready. isEnabled() is true only when a real decision produced a truthy value, so it is always false for default, disabled, not_found, and not_ready. exists() is false for not_found and for not_ready.

getBoolFlag, getStringFlag, getNumberFlag and their signal and helper forms check the value against the type the platform declared for that flag. A mismatch never throws: the SDK logs one warning naming the flag and the getter, then answers with your defaultValue and source: 'not_found'.

Exposure

Reads in this package fire no exposure event, so change detection can never inflate your results. Record the exposure where the decision is actually shown, with the avsbExposure directive, with exposure: true on *avsbFlag, or from TypeScript:

TypeScript
import type { AvsbService } from '@avsbhq/angular'function showVariation(avsb: AvsbService): () => void {  // Called before the SDK is ready, recordExposure waits for the first datafile  // and records then. It returns a cancel function for that wait, which the  // avsbExposure directive calls if the element is destroyed first: a visitor  // who never saw the variation should not appear in the results.  return avsb.recordExposure('checkout_v2')}
TypeScript9 lines

Identity and events

TypeScript
import type { AvsbService } from '@avsbhq/angular'function onSignIn(avsb: AvsbService, userId: string, anonymousId: string): void {  avsb.identify({ kind: 'user', key: userId, plan: 'pro', country: 'GB' })  avsb.alias({ kind: 'user', key: anonymousId }, { kind: 'user', key: userId })}function onSignOut(avsb: AvsbService): void {  avsb.reset()}function onCheckout(avsb: AvsbService, amount: number): void {  avsb.track('checkout_clicked')  avsb.track('purchase_completed', {    value: amount,    properties: { currency: 'GBP', productId: 'prod_123' },  })}
TypeScript18 lines

identify replaces the whole context and re-evaluates every flag immediately, so subscribed components and signals update. Target several dimensions at once with a multi-context:

TypeScript
import type { AvsbService } from '@avsbhq/angular'function identifyAccount(avsb: AvsbService, userId: string, orgId: string): void {  avsb.identify({    kind: 'multi',    user: { kind: 'user', key: userId, plan: 'pro' },    organization: { kind: 'organization', key: orgId, tier: 'enterprise' },  })}
TypeScript9 lines

Events are batched. avsb.flush() sends what is queued now, which is worth doing before a full page navigation.

Status, readiness and errors

TypeScript
import type { Signal } from '@angular/core'import type { AvsbService, AvsbStatus, InitResult } from '@avsbhq/angular'function surfaces(avsb: AvsbService): { status: Signal<AvsbStatus>; error: Signal<Error | null> } {  return { status: avsb.status, error: avsb.error }}async function waitOnce(avsb: AvsbService): Promise<InitResult> {  // Resolves when the init attempt settles, and never rejects. The timeout  // bounds how long you wait, not what the SDK does: loading continues behind it.  return avsb.onReady({ timeout: 2000 })}
TypeScript12 lines

status is 'loading', 'ready', or 'error'. degraded being true means a cached datafile is being served after a failed refresh: the values are real, they may be out of date, and polling continues behind them. It is a warning, never a failure.

Observables in a component

TypeScript
// docs-example: not typechecked here, because an Angular component is compiled// by the Angular compiler under experimentalDecorators, which the standalone// TypeScript program this documentation gate builds does not enable.import { Component, inject } from '@angular/core'import { AsyncPipe } from '@angular/common'import { AvsbService } from '@avsbhq/angular'import type { Flag } from '@avsbhq/angular'import type { Observable } from 'rxjs'@Component({  selector: 'app-hero',  standalone: true,  imports: [AsyncPipe],  template: `    @if (hero$ | async; as hero) {      @if (hero.value === 'variant-a') {        <app-hero-variant-a />      } @else {        <app-hero-control />      }    }  `,})export class HeroComponent {  private readonly avsb = inject(AvsbService)  readonly hero$: Observable<Flag<string>> = this.avsb.getStringFlag('homepage_hero', 'control')}
TypeScript27 lines

async is Angular's own AsyncPipe, from @angular/common. Import it in your component: this package does not import @angular/common, which is why it is not a peer dependency here.

Shutdown

When you passed sdkKey, the client is closed as the injector that provided it is destroyed, which flushes queued events. When you passed client, nothing is closed: the client is yours. avsb.destroy() does the same work by hand.

Server rendering

There is no Angular Universal integration yet. Under SSR the SDK renders default values, then the browser evaluates for real once the datafile arrives. If you already fetch the datafile on the server, pass it as bootstrap on provideAvsb so the browser starts ready instead of fetching again.

Testing

Provide a client and every read in your components answers from it. createMockClient from @avsbhq/test satisfies AngularAvsbClient directly, so it drops straight into provideAvsb:

TypeScript
import { TestBed } from '@angular/core/testing'import { provideAvsb } from '@avsbhq/angular'import { createMockClient, flagsFromTestData, TestData } from '@avsbhq/test'import { CheckoutComponent } from './checkout.component'const client = createMockClient(  flagsFromTestData([    TestData.flag('checkout_v2')      .booleanFlag()      .variationForUser('u_paying', true)      .fallthroughVariation(false)      .build(),  ]),)TestBed.configureTestingModule({  imports: [CheckoutComponent],  providers: [provideAvsb({ client })],})// The mock records what your code asked for, so assertions are about decisions// rather than about rendered markup.client.identify({ kind: 'user', key: 'u_paying' })expect(client.getBoolFlag('checkout_v2', false).value).toBe(true)expect(client._evaluations()).toHaveLength(1)
TypeScript25 lines

createMockClient({ flags: { 'checkout_v2': true } }) is the shorthand when you do not need per-user variations. Either way the mock implements the whole client surface, not a convenient subset: AvsbService calls getInitResult(), isReady(), onReady(), and on() while it is being constructed, so a stand-in missing one of those is a TypeError on injection rather than a weaker mock.

What's next

Was this helpful?