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.
Install
Add @avsbhq/angular.
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>.
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.
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.
Gate a template
The *avsbFlag structural directive renders a branch when the flag is on, with an else template and variation matchers.
Record the exposure
Reads fire no exposure event. avsbExposure (or recordExposure) marks the moment the visitor actually saw the decision.
Identify a user
Call identify after sign-in; every subscribed signal and observable re-evaluates.
Install the package:
npm install @avsbhq/angularAdd the SDK key to your environment files (src/environments/environment.ts):
export const environment = { production: false, avsbSdkKey: 'sdk_development_xxxxxxxxxxxxxxxx',}- Environments lives in the sidebar on its own, not inside Settings.
- Click Reveal to see the full key, then Copy to copy it.
Configure once, at bootstrap
Standalone apps configure the SDK in src/app/app.config.ts:
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' }, }), ],}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:
// 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)}Three things are true of that component and of every read in this package:
- Reading a flag fires no exposure event, which is what makes reads safe to call during change detection.
avsbExposurerecords that the visitor was shown the decision. - Before the datafile arrives,
checkout().valueis the default you passed andcheckout().sourceis'not_ready'. Nothing is everundefined. - 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:
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)}The same reads are available as methods on an injected AvsbService, which is the form to reach for outside an injection context:
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.}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:
<!-- 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>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:
<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 />}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
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(', ')}`}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:
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')}Identity and events
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' }, })}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:
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' }, })}Events are batched. avsb.flush() sends what is queued now, which is worth doing before a full page navigation.
Status, readiness and errors
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 })}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
// 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')}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:
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)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
- Multi-context identity
- SDK installation: the browser client
@avsbhq/angularwraps,Flag<T>, and every evaluation source. @avsbhq/angularon npm: the package README, with every export.