How the backend, frontend, voice agent, and Auth0 connect. Covers the layered Go backend, React state management, LiveKit voice pipeline, database schema, and the complete request lifecycle from visitor to AI response.
The Go backend follows a layered architecture: handlers (HTTP), services (business logic), and repositories (data access). A single API binary serves all routes.
The application boots in cmd/api/main.go with a deterministic initialization sequence: load config from embedded .env and environment overrides, connect to PostgreSQL with connection pooling, initialize Auth0 JWT validation with JWKS caching, create S3-compatible storage (DigitalOcean Spaces in production, MinIO locally), set up LiveKit and LLM clients, then wire all services and handlers into the Gin router.
Gin handlers validate request input, extract identity from JWT context, call services, and convert results to OpenAPI-generated response types. No business logic lives here.
Services orchestrate operations across repositories and external systems. They manage transactions, enforce constraints (slug cooldowns, document limits), and trigger async tasks (instruction generation).
A single Repository struct provides all database operations using goqu query builder. Soft-delete filtering (WHERE deleted_at IS NULL) is applied to every query. Returns only commonerrors from gommon.
The middleware pipeline runs in order: structured logging (zerolog), panic recovery, error mapping (commonerrors to HTTP status codes), and CORS. Route-specific middleware handles JWT validation, M2M secret verification, API key checks, and optional JWT extraction for public endpoints.
PostgreSQL 16 with 15 migrations as of this writing. All tables use UUID primary keys, soft-delete timestamps, and automatic created_at/updated_at triggers.
| Table | Purpose and Key Columns |
|---|---|
users |
Core user record. auth0_sub (unique), email, display_name, avatar_url, bio, plan_tier, metadata (JSONB for onboarding_completed, user_types), last_login_at. |
pages |
User's public page. slug (globally unique), title, description, is_published, slug_changed_at (30-day cooldown), metadata (JSONB). FK to users. |
page_themes |
Visual customization (1:1 with pages). primary_color, accent_color, font_family, layout_type, custom_css. ON DELETE CASCADE. |
agent_configs |
AI agent settings (1:1 with pages). agent_name, greeting_message, system_prompt, personality_traits (text array), model, temperature, max_tokens, focus, generation_status, generated_instructions (JSONB). ON DELETE CASCADE. |
documents |
Uploaded knowledge files. filename, content_type, storage_path, context_text, visibility, is_published, processing_status. Max 2 per page, 2MB limit. FK to pages, ON DELETE CASCADE. |
chat_threads |
Conversation history. messages (JSONB array of {role, content, timestamp}), message_count, member_slug, title. FKs to pages and users. |
Indexes enforce soft-delete-aware uniqueness on users.auth0_sub, users.email, and pages.slug. Foreign key cascades ensure child records (themes, configs, documents, threads) are deleted when a page is removed.
React 19 SPA with context-based state management. Auth0 handles authentication; all API calls inject the Bearer token via a shared useApiClient hook.
The provider stack wraps the entire app: BrowserRouter > Auth0ProviderWithNavigate > ToastProvider > UserProvider > OnboardingProvider. Routes are:
| Route | Component and Behavior |
|---|---|
/ |
Landing page. Redirects authenticated users to /dashboard or /onboarding based on metadata.onboarding_completed. |
/signup |
Auth0 social login. Only Google is active; Facebook and LinkedIn buttons appear in the UI but are not yet enabled in Auth0. Redirects to /onboarding after first authentication. |
/onboarding |
Protected. Six-step wizard: name, persona selection, profile/slug, social links, documents, agent instructions. Submits all data as multipart form to POST /v1/onboarding. |
/dashboard |
Protected. Tabbed management console (Profile, Page, Links, Agent, Files). Each tab syncs with the backend on save. |
/:slug |
Public page. Fetches page data from GET /v1/public/pages/:slug. Renders profile sidebar with voice/chat toggle. No authentication required. |
State is managed through three React contexts:
GET /v1/me and provides user, loading, error, and refetch.generation_status is pending or generating.submit() method that sends everything to the backend in a single multipart request.Real-time voice conversations use LiveKit for WebRTC transport, with a Python agent bridging speech-to-text, the backend chat API, and text-to-speech.
The frontend calls POST /v1/pages/:page_id/room-token to get a LiveKit token. The backend creates a LiveKit room and dispatches the ambasdr-voice-agent to it.
The Python agent receives the page_id from dispatch metadata, fetches the full session config from GET /v1/agent/sessions/:page_id/config (agent name, system prompt, personality traits, documents, social links), and builds its voice pipeline.
Silero VAD detects when the visitor is speaking. Deepgram Nova-3 transcribes speech to text. The transcription is sent to POST /v1/agent/chat/stream which streams the LLM response via SSE. ElevenLabs Flash v2.5 converts the response to speech and plays it through LiveKit.
When the visitor disconnects, the agent calls POST /v1/agent/sessions/:page_id/finalize to mark the session as ended.
The Python agent uses a custom AmbasdrLLM class (livekit-agent/ambasdr_llm.py) that implements LiveKit's LLM interface. Instead of calling OpenAI directly, it routes all chat through the Go backend's SSE endpoint, which handles system prompt assembly, conversation history, and thread recording.
Text chat streams responses via Server-Sent Events. The frontend's useAgentChat hook manages message state and thread continuity.
The frontend POSTs to /v1/public/pages/:slug/chat with the message content and optional thread_id for multi-turn conversations.
The service resolves the page by slug and assembles a three-layer system prompt: (1) the LLM-authored personalization from agent_configs.generated_instructions[-1] (or a fallback built from the raw system_prompt, traits, documents, and links when no generation exists), (2) the deterministic operating contract that enforces grounding, voice, refusal patterns, and out-of-scope rules — identical for every member, never overridable by the visitor, (3) the live knowledge context block fetched from Graphiti for the page. See the Agent Prompt Architecture doc for the full layering.
The backend calls the OpenAI-compatible API and streams the response back to the frontend as SSE events: event: content (text chunks), event: thread (thread ID for continuity), and event: done (completion signal).
After the stream completes, both the user message and assistant response are recorded in the chat_threads table as a JSONB array. If no thread existed, a new one is created. The thread ID is returned to the frontend for use in the next message.
After onboarding completes, the backend fires an async goroutine that generates personalized agent instructions using an LLM with structured output. This runs in the background while the user is redirected to the dashboard.
The generation lifecycle uses the generation_status field on agent_configs:
| Status | Meaning |
|---|---|
idle |
Default state. No generation has been triggered. |
pending |
Generation requested but not yet started. Frontend begins polling. |
generating |
LLM call in progress. Frontend continues polling every 3 seconds. |
completed |
Instructions generated and saved to generated_instructions JSONB. Frontend stops polling and shows a success banner. |
failed |
Generation failed. The agent falls back to the manually-written system prompt. |
The generator uses ByteDance's Eino framework with structured output (JSON schema enforced by the LLM). The meta-prompt mandates six bracketed sections in a fixed order — [IDENTITY], [VOICE], [COVERAGE], [BOUNDARIES], [ENGAGEMENT], [CUSTOM] — and explicitly tells the LLM not to include safety / grounding / refusal rules. Those are appended at runtime by the operating contract layer so they never silently drift between regenerations. Generated instructions are versioned and appended to a JSONB array, allowing the system to track multiple generations over time.
For deep-dive coverage of all three prompt layers, the structured sections, and the regenerate-instructions CLI for triggering regenerations from the command line, see the Agent Prompt Architecture doc.
The API contract is defined in multi-file YAML specs under backend/openapi/v1/, organized by module: auth/, m2m/, pages/, livekit/, public/, onboarding/, and common/ (shared error schemas).
Running bt generate openapi -y from backend/ produces Go types in generated/openapi/v1/views/. These types are the contract between handlers and the outside world. Handlers accept OpenAPI request types, call services with domain types, and convert results back to OpenAPI response types.
Never edit the generated files directly. Change the YAML spec, regenerate, then update handlers to match the new types. The OpenAPI spec also serves as the canonical API documentation for frontend developers.
The frontend uses Storybook 10 for component development and visual regression testing. Auth0 is mocked at the Storybook level so all components render without a real authentication session.
frontend/src/stories/..storybook/mocks/auth0.ts) provides a fake authenticated user, getAccessTokenSilently stub, and passthrough withAuthenticationRequired.make run-storybook (port 6006).make storybook-publish or via the GitHub Actions CI pipeline on push to main.cd frontend && npm run test:a11y.Configuration is split across three .env files, one per workspace. The backend embeds its .env at compile time as a fallback; actual environment variables take precedence.
| Variable | File and Purpose |
|---|---|
SERVER_PORT |
backend/.env — API listen port (default 9080). |
DB_* |
backend/.env — PostgreSQL connection: host, port (5438), user, password, name, sslmode, pool sizes. |
AUTH0_DOMAIN |
backend/.env — Auth0 tenant domain for JWT validation. |
AUTH0_AUDIENCE |
backend/.env — Expected JWT audience (https://api.ambasdr.com). |
AUTH0_M2M_SECRET |
backend/.env — Shared secret for M2M provisioning endpoint. Must match the Auth0 Action's secret. |
DO_SPACES_* |
backend/.env — S3-compatible storage (access key, secret, bucket, region, endpoint). Points to MinIO locally. |
LIVEKIT_* |
backend/.env — LiveKit API key, secret, URL, and token TTL. |
LLM_API_KEY |
backend/.env — OpenAI-compatible API key for chat and instruction generation. |
AGENT_API_KEY |
backend/.env — Shared secret for the Python voice agent's API calls. |
VITE_AUTH0_* |
frontend/.env — Auth0 domain, client ID, and audience for the React SPA. |
VITE_API_URL |
frontend/.env — Backend API base URL (http://localhost:9080). |
AMBASDR_SERVICE_URL |
livekit-agent/.env — Backend URL for the Python agent (http://localhost:9080). |
AMBASDR_API_KEY |
livekit-agent/.env — Must match AGENT_API_KEY in backend config. |
ALLOWED_ORIGINS |
backend/.env — Comma-separated CORS allow-list. When set, overrides the per-environment default (localhost in dev, https://ambasdr.com in production). Used by staging to add the ngrok URL without recompiling. |
KG_SERVICE_URL / KG_TIMEOUT_SECONDS |
backend/.env — URL of the Graphiti knowledge-graph FastAPI sidecar and the HTTP timeout for those calls. Use the compose-internal hostname (http://knowledge-graph:8050) in containerized deployments. |
AGENTGEN_* |
backend/.env — LLM model / temperature / max-token tuning for the agent prompt generator (composition pass and document extraction pass). |
STRIPE_SECRET_KEY / STRIPE_PRICE_ID / STRIPE_WEBHOOK_SECRET |
backend/.env — Stripe secret key for the billing client, the monthly subscription price ID, and the webhook signing secret used to verify POST /webhooks/stripe events. |
Every authenticated request flows through a three-step lookup chain that's self-healing: cached user_id claim → Auth0 sub fallback → lazy create. The Auth0 Post-Login Action is an optimization (warms the cache), not a hard dependency.
Auth0 issues a JWT with these claims:
sub — Auth0 user identifier (e.g. google-oauth2|123456789). Always present.email, email_verified, name, picture — OIDC profile claims. Always present for Google logins.https://ambasdr.com/user_id — Custom claim set by the Post-Login Action after it provisions the user. Present on most logins, absent if the Action's fetch failed.https://ambasdr.com/role — Custom claim. Currently always "user".The JWT middleware (internal/auth) decodes the token, validates against the Auth0 JWKS, and stores the full identity.Identity struct in the gin request context. Every authenticated handler then calls userHandler.resolveUser, which delegates to UserService.EnsureFromIdentity.
| Step | Source | Action |
|---|---|---|
| 1 (fast path) | UUID from user_id custom claim |
GetUserByID. Hit returns the user. Miss falls through — the cached UUID is stale (user row was deleted), don't error out. |
| 2 (fallback) | Auth0 sub from sub claim |
GetUserByAuth0Sub. Hit returns the user. Miss falls through. |
| 3 (soft-delete guard) | Auth0 sub against soft-deleted rows | GetSoftDeletedUserByAuth0Sub. If a soft-deleted user with that sub exists, return 404 user account has been deleted — refuse to resurrect. |
| 4 (lazy create) | All Identity fields (sub, email, name, picture, etc.) | CreateUser with a fresh UUID, sub from the token, profile from the OIDC claims, plan_tier=free. Returns the new user. |
Returns 401 Unauthenticated only when the Identity itself is missing (no JWT, or a JWT with no sub claim). Real DB errors bubble up as 500. Everything else — missing custom claim, stale UUID, never-seen sub — is recovered transparently. The integration tests in backend/tests/integration/user_handler_test.go cover all four paths and the soft-delete refusal case.
The "Provision Ambasdr User" Action runs on every login. On the fast path it reads the cached ambasdr_user_id from app_metadata and sets the custom claim from cache — no backend call. On the slow path (cache empty) it POSTs to /internal/users/provision with the user's identity, then caches the returned UUID in app_metadata. The endpoint is M2M-authenticated via AUTH0_M2M_SECRET and delegates to the same EnsureFromIdentity — so the Action and the lazy fallback always return identical user records.
If the Action's fetch fails (backend down, network blip, secret mismatch), the Action logs and proceeds with login. The next authenticated request lazy-creates the user on its own. To force a re-provision for testing: make dev-reset-user EMAIL=<email> clears app_metadata.
Per-environment defaults plus an env-var overlay. cmd/api/main.go calls resolveAllowedOrigins(env, csv):
["http://localhost:3000", "http://localhost:3003"] (dev).ENVIRONMENT=production: ["https://ambasdr.com"].ALLOWED_ORIGINS set: comma-separated list overrides both defaults. Used in staging to add the ngrok URL without recompiling.Unit-tested in cmd/api/main_test.go — tag unit.