← Ambasdr Docs
Architecture

Platform Architecture and Data Flow

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.

01

Backend Architecture

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.

Handlers

HTTP Layer

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

Business Logic

Services orchestrate operations across repositories and external systems. They manage transactions, enforce constraints (slug cooldowns, document limits), and trigger async tasks (instruction generation).

Repositories

Data Access

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.

02

Database Schema

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.

03

Frontend Architecture

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:

04

Voice Pipeline

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.

Visitor requests voice session

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.

Agent joins and configures

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.

Voice processing loop

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.

Session cleanup

When the visitor disconnects, the agent calls POST /v1/agent/sessions/:page_id/finalize to mark the session as ended.

Custom LLM Adapter

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.

05

Chat Pipeline

Text chat streams responses via Server-Sent Events. The frontend's useAgentChat hook manages message state and thread continuity.

Visitor sends message

The frontend POSTs to /v1/public/pages/:slug/chat with the message content and optional thread_id for multi-turn conversations.

Backend assembles prompt

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.

LLM streams response

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).

Thread recorded

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.

06

Agent Instruction Generation

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.

07

OpenAPI Code Generation

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.

Single Source of Truth

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.

08

Storybook

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.

09

Environment Variables

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.
10

Authentication and User Identity Resolution

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.

Token shape

Auth0 issues a JWT with these claims:

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.

UserService.EnsureFromIdentity — the lookup chain

StepSourceAction
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.

Auth0 Post-Login Action

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.

CORS

Per-environment defaults plus an env-var overlay. cmd/api/main.go calls resolveAllowedOrigins(env, csv):

Unit-tested in cmd/api/main_test.go — tag unit.