Install on Angular
Angular apps ship a root index.html that’s served on every route. Angular routing uses the History API under the hood, which the widget auto-detects. One paste, done.
Steps
-
Open
src/index.html -
Paste the snippet before
</body>:<body><app-root></app-root><scriptsrc="https://spelo.ai/spelo.js"data-site-id="YOUR_SITE_ID"async></script></body> -
Run
ng serveand verify the orb appears.
As an Angular service (optional)
If you prefer to control widget lifecycle from Angular:
import { Injectable, Inject, DOCUMENT } from '@angular/core'
@Injectable({ providedIn: 'root' })export class SpeloService { private loaded = false
constructor(@Inject(DOCUMENT) private doc: Document) {}
load(siteId: string): void { if (this.loaded) return if (this.doc.querySelector(`script[data-site-id="${siteId}"]`)) { this.loaded = true return } const s = this.doc.createElement('script') s.src = 'https://spelo.ai/spelo.js' s.setAttribute('data-site-id', siteId) s.async = true this.doc.body.appendChild(s) this.loaded = true }}import { Component, OnInit } from '@angular/core'import { SpeloService } from './spelo.service'import { environment } from '../environments/environment'
@Component({ selector: 'app-root', template: '<router-outlet />' })export class AppComponent implements OnInit { constructor(private vk: SpeloService) {}
ngOnInit() { this.vk.load(environment.speloSiteId) }}Add speloSiteId to your src/environments/environment.ts files so each environment can have a different site.
Angular Router
The widget listens for popstate and patches pushState/replaceState so SPA navigations are detected. Angular’s Router uses these underneath — nothing extra to configure.
Angular Universal (SSR)
On the server side, the widget <script> is emitted into the rendered HTML but does not execute (it’s a browser-only script). In the browser, it runs on hydration. No SSR issues.
If you want to suppress the script on the server (for strictness), wrap the load call in isPlatformBrowser:
import { Inject, PLATFORM_ID } from '@angular/core'import { isPlatformBrowser } from '@angular/common'
constructor(@Inject(PLATFORM_ID) private platformId: object, private vk: SpeloService) {}
ngOnInit() { if (isPlatformBrowser(this.platformId)) { this.vk.load(environment.speloSiteId) }}Zone.js
The widget runs outside Angular’s zone (it doesn’t use any Angular APIs). That means it won’t trigger change detection. You don’t need ngZone.runOutsideAngular — it’s already outside.
CSP
Angular doesn’t add a CSP by default. If you’ve configured one (via your reverse proxy or meta tag), allow:
script-src 'self' https://spelo.ai;connect-src 'self' https://api.spelo.ai https://api.openai.com wss://*;media-src 'self' blob:;Angular’s optional strict CSP (no eval, no inline) works fine with the widget — it doesn’t use eval or inline script.
Verify
ng serve- Hit
http://localhost:4200 - Orb at the bottom; click → allow mic → speak
Troubleshooting
- Widget appears, but route changes remove it → shouldn’t happen. If it does, ensure the
<script>tag is insrc/index.html(outside<app-root>), not inside a component template. - Mic permission denied → Angular is served on
http://localhostby default, which browsers treat as secure for mic access. In production, ensure you’re onhttps://.