Skip to content

Architecture And Security

Voxos is organized by product capability under src/voxos/. FastAPI app composition lives in src/voxos/api/main.py, and module routers call typed services rather than embedding business logic in route handlers.

Package Purpose
api FastAPI routers and app composition.
auth Firebase bearer verification and durable local user mapping.
credentials User secret vault service.
db Async engine/session setup and tenant/RLS helpers.
voice LiveKit tokens, settings, worker, and voice tools.
providers Realtime voice provider adapters.
text Text preferences, providers, chat service, and storage.
attachments Tenant-owned chat image metadata, normalization, private object storage, and lifecycle.
browser_runtime General browser-task arguments and profile-free runtime execution.
browser_accounts Optional saved-profile metadata, encrypted credentials/opaque handles, provider lifecycle, request binding, schedule binding, leases, audit, and reconciliation.
mcp User-owned remote MCP server models, auth, clients, and services.
integrations Twilio, Telnyx, and WhatsApp communication records, resources, webhooks, conversations, replies, and tools.
location Typed request-scoped location validation, freshness checks, metadata sanitization, and instruction rendering.
whatsapp Linked-device setup API, encrypted session storage, runtime validation, fenced outbox, and multi-session worker.
contacts Tenant-owned people, phone matching, and contact CRUD.
agent_core Shared agent contracts, instructions, and internal tools.
agent_runtime Runs, schedules, inbound agent webhooks, sessions, events, subagents, and authorization.
agent_identity User agent records and service.
agent_memory Durable memories and memory tools.
agent_skills Developer/user skill catalog and rendering.
agent_planning Durable task plans and task steps.
persona Prompt/persona rendering from agent data.
security Secret encryption backends.

Firebase identities map to local Voxos user ids in auth_firebase_users. AuthenticatedUser.user_id is the only canonical user authority. User-owned database access should use tenant session helpers that set transaction-local app.user_id for Postgres RLS policies.

When adding a user-owned table, include a user owner column, tenant indexes, RLS/forced RLS migration coverage, and tests proving wrong-user access fails.

Public deployments can enable Firebase Admin session-revocation checks after providing project-scoped user-read credentials. Verification fails closed when the revocation service is unavailable. API admission uses the verified local user ID for write/stream rate and concurrency budgets; a separate per-process cap bounds HTTP work. See Account And Auth.

agent_webhooks is a forced-RLS tenant table. Authenticated management uses the Firebase-derived local user id. Public ingress receives only an opaque webhook id and token; a dedicated agent_webhook service scope can select definitions to verify a one-way SHA-256 token hash but cannot insert, update, or delete them. After lookup, the persisted owner is used to enter tenant scope before loading the agent, resolving tool grants, or enqueuing the run. Request JSON never supplies tenant or tool authority.

Production database access should use two separate principals. Application services receive a pooled URL for a least-privileged runtime login that is not an object owner and has NOBYPASSRLS; only the migration process receives the direct URL for the migration/owner login. Remote production connections should use verify-full TLS with an appropriate CA bundle. The Alembic connection creates the required vector extension before applying schema migrations. Local Docker retains the database driver’s local transport behavior.

agent_tool_route_preferences is a forced-RLS tenant table keyed by user, agent, and semantic action. A stored pin contains only an exact tool source and server/integration identifier; it does not contain credentials and does not grant authority. API writes derive the tenant from Firebase authentication and validate the pin against the agent’s current allowed route candidates before persisting it. The user_contacts table follows this model and enforces unique E.164 phone numbers per user.

Device location is optional request data, never identity or tenant authority. Clients send a typed WGS84 snapshot only after a local foreground opt-in. The server validates ranges, rejects malformed text payloads, silently removes an invalid optional voice snapshot, and ignores stale or implausibly future data. Text location stays out of the durable chat prompt and transcript. Voice location is stripped from persisted room metadata and appended only after the durable base prompt snapshot is created. A subagent may receive a run-scoped copy from its parent, but freshness is checked again at child execution; no global location profile or background tracking record is created.

browser_accounts, agent_browser_account_grants, browser_account_run_authorizations, browser_account_audit_events, and browser_account_leases are tenant-owned forced-RLS tables. Browser provider profiles and sessions are not database authority: the service must first bind the authenticated tenant, exact agent/account assignment, trusted MCP connection and tool grant, one single-use run authorization, and an exclusive account lease.

message_attachments follows the same forced-RLS model. The authenticated user and persisted chat are the only ownership authority. Client attachment IDs are looked up inside that scope; clients never provide an object key or user ID. The service atomically claims an ordered ready set into one transcript turn and uses a client turn key to prevent duplicate submission. A dedicated attachment_worker service scope can select and update only deleted attachment rows and is used by the durable run worker to finish object deletion after a process interruption; normal worker access does not bypass tenant policies.

Image bytes are not stored in Postgres. Sources are decoded off the async event loop, normalized with metadata removed, and stored behind an object-store protocol. Local development uses a gitignored persistent directory or the shared Compose volume. A deployed installation can use a private S3 bucket with public access blocked and prefix-limited runtime permissions. Authenticated API routes proxy full or thumbnail bytes while holding read locks on the active parent chat and attachment; logs and API summaries omit filenames, hashes, object keys, and image content.

integration_conversations and integration_replies are also forced-RLS tenant tables. They bind a verified provider/resource/remote peer to an active runtime session and one inbound message to one durable reply outcome. The dispatcher derives the reply destination from these records; message text or LLM arguments cannot supply tenant authority or replace that destination.

Service-wide work uses narrow RLS scopes. agent_worker is limited to durable run tables. voice_worker can select/update tenant call-demand rows and own opaque project leases, but it cannot read LiveKit settings or vault plaintext cross-tenant. After a demand is selected, the supervisor uses its persisted user ID to enter a tenant-scoped session before resolving credentials.

The WhatsApp worker follows the same boundary with a distinct whatsapp_worker service scope. That scope can discover and update whatsapp_sessions and whatsapp_outbox, and exclusively owns whatsapp_session_leases; it does not receive a cross-tenant policy for whatsapp_session_keys or the user secret vault. After claiming a persisted session demand, the supervisor enters that exact user’s RLS context and verifies the current owner/generation fence before decrypting auth, accepting an event, or completing an outbox item. The whatsapp_mobile_registrations, whatsapp_sessions, whatsapp_session_keys, and whatsapp_outbox tables also have forced tenant RLS. The worker has no service-scope policy for mobile registrations. A unique tenant constraint currently permits one mobile-registration attempt and one WhatsApp session per Voxos user while the deployment can serve many users.

Provider keys, LiveKit credentials, MCP tokens, OAuth secrets, private keys, and integration credentials flow through UserSecretVaultService. The vault stores ciphertext and encryption metadata in Postgres. Local development uses AES-GCM; deployed environments can use AWS or Google Cloud KMS envelope encryption. In the Google backend, KMS wraps one random AES-256 data key per stored secret and the authenticated tenant/provider/credential/purpose context is supplied to both KMS and AES-GCM.

Plaintext secrets must not be returned from APIs, logged, written to docs, or stored in frontend config.

Browser Accounts reuse this vault rather than adding a password table. Typed, versioned local or Browserless website credentials, opaque Browser Use profiles, and temporary provider session handles use separate authenticated-encryption purposes and can be unsealed only with the same user, provider, credential ID, and purpose. Plaintext website credentials, TOTP seeds, cookies, storage-state JSON, live URLs, and provider handles do not enter Browser Account metadata or API responses. The private Stagehand adapter or Browserless receives the stored username and password only in the backend provider-execution path. Browser Account validation responses strip submitted values before returning field errors. The local browser runtime, Browserless, and Browser Use remain trusted with the active browser content or authenticated state; vault encryption is not end-to-end secrecy from the browser runtime or selected hosted provider. Browserless uses the hosted-agent /chromium/cli contract with a token-only connection URL and the configured fleet endpoint. Browser Use receives its account’s validated two-letter country during setup and retrieval and requires profile setup again when the country changes.

The Monzo MCP has two deliberately separate authentication boundaries. Voxos stores the long-lived MCP endpoint bearer and tenant-owned Monzo OAuth client information and tokens in the user vault. It sends the endpoint bearer to http://monzo-mcp:8000/mcp plus a short-lived HMAC-signed delegation bound to the user, MCP server, provider, audience, and expiry.

The container stores no Monzo credential. During a tool call it presents the delegation to /api/internal/mcp/credential-broker/v1/access-token. The route verifies the signature before entering tenant scope, locks the OAuth token row, refreshes and atomically stores a rotated refresh token when needed, and returns only the usable access token with Cache-Control: no-store. A rejected access token is represented only by its SHA-256 fingerprint, preventing redundant refresh after another request has already rotated it.

The endpoint and broker are private-network interfaces. One container can serve multiple Voxos users statelessly, but neither route should be exposed through untrusted ingress.

Tenant-configured MCP and OpenAI-compatible destinations share one outbound policy. Unregistered destinations require HTTPS on port 443, reject URL credentials and fragments, and must resolve exclusively to global addresses. Text-provider base URLs also reject queries. MCP and text transports do not follow redirects.

Local Docker services are deployment-owned exact exceptions rather than tenant authority. Each registration binds a purpose, complete normalized URL, and the local_container kind. Tenant-configurable MCP API-key headers cannot claim transport, metadata, or proxy authority.

Financial tool results are still application data after authorized execution. Balances and transactions can enter model context and durable chat, run, transcript, or tool-event storage. The preset therefore grants no tools automatically and the supported Compose service disables writes.

WhatsApp uses two encryption layers. A random per-integration data key is sealed through the normal tenant vault. The Baileys credential object, Signal keys, and temporary link challenge are then AES-GCM encrypted in their tenant tables with the user, integration, logical key, and revision bound as authenticated context. A keyed fingerprint records successful number validation without persisting a second plaintext number.

The mobile-registration precursor uses a separate vault purpose. After a Twilio or Telnyx SMS webhook passes its signature, account, route, and exact resource checks, a code-bearing normalized envelope can be encrypted into the owning user’s bounded registration inbox for ten minutes. The ordinary message/event/agent-reply path is skipped for a captured candidate. Authenticated no-store endpoints expose only the code, full sender number, and timestamps; the frontend keeps them in component memory. Completing setup discards the selected WhatsApp code and deletes the temporary record and secret. Rejecting a candidate replays it through the provider’s idempotent normal SMS ingestion path.

The Python supervisor owns one private Node.js child over bounded, version-checked stdio frames. The child runs the exactly pinned Baileys package, has no HTTP listener, database or vault credentials, filesystem auth store, or tenant API, and receives only an allowlisted process environment. Stdout is reserved for protocol frames; diagnostics must not contain JIDs, numbers, challenges, message bodies, upstream response bodies, or auth values.

One bridge process multiplexes several tenant sockets instead of spawning one process per tenant. Lease generations fence ownership across worker replicas and are carried on every command, response, event, auth mutation, and outbox claim. Auth writes block until their encrypted tenant transaction is acknowledged. Control and inbound events are independently ordered and are acknowledged only after durable processing. Stale-generation events fail closed. The supervisor renews active database leases in an independent loop, so a slow challenge, reset, or session-start request for one tenant cannot delay ownership renewal for the other active sessions.

A connected event is not enough to authorize traffic. The runtime requires a PN-bearing identity reported by the linked session and compares it with the selected tenant-owned E.164 resource. Only the exact match enables the integration and resource. LID-only, unresolved, or mismatched identities remain disabled and trigger confirmed logout/cleanup or a visible relink state.

Inbound WhatsApp contact identities are stored separately from encrypted session credentials in whatsapp_contact_identities. The table is tenant owned, uses forced RLS, and scopes each PN, LID, or username mapping to the specific connected integration. Only the backend may link one of these records to a saved contact, and only after an authoritative PN maps to the contact’s exact current E.164 number. LID-only observations and alias conflicts remain unlinked, so a bridge payload, display name, stale contact edit, or another tenant’s observation cannot grant contact-derived permissions.

Direct senders are selected from Baileys’ remote JID fields; group senders are selected from its participant fields. The bridge classifies both primary and alternate identifiers with Baileys’ PN/LID predicates, then resolves LID-only identities through contact data, group participant metadata, and the encrypted Signal LID mapping store. It never derives a phone number from LID digits. Group conversations use the group JID for routing and context, while each message retains its sender identity and contact snapshot. Contact-derived admin permissions are disabled for group messages because replies and tool-derived information are visible to the group.

Disconnect and relink also fail closed. Remote logout must be confirmed before Voxos deletes encrypted auth. An uncertain logout retains the state for a safe retry. A completed relink advances the lease generation before starting a new controller, preventing delayed events from the previous linked device from reopening the integration.

Tool execution derives authority from AgentRunContext and ToolExecutionContext. Generic tool arguments are validated so model-supplied fields cannot smuggle user ids, headers, credentials, server ids, tokens, browser-account IDs, provider profile IDs, or provider session authority. The purpose-built use_browser contract deliberately accepts a validated public HTTPS start URL and exact public origins for profile-free browsing; local, private, credential-bearing, and non-standard targets are rejected.

Web chat and authenticated in-app voice expose one high-risk use_browser capability. It accepts a complete browser instruction, action mode, and either a public start URL or an opaque handle for one of the saved profiles assigned to the exact agent. The description contains only safe profile labels, primary origins, and handles. The backend reloads assignment and hosted provider-tool authority at execution, so an invented handle cannot select an account and no model argument can provide stored credentials, provider state, or a raw account ID. Saved-profile authority is excluded from subagents and phone callers.

BrowserRuntimeService owns profile-free task execution, model transport, and session cleanup. BrowserAccountService is the optional profile broker: it adds server-owned origins and named secret variables to the same provider-neutral task request. The private Stagehand service receives the agent instruction unchanged and contains no website or objective-specific workflow. The built-in browser-use skill guides instruction composition and profile selection at the agent layer.

The private task service requires a deployment-owned bearer credential from each API or worker caller. A separate credential protects Steel’s HTTP and DevTools interfaces. These credentials authorize service access; the caller must still establish tenant, agent, tool, and account authority before sending a task. Health endpoints remain available for process readiness checks.

Steel sessions are ephemeral and force-closed after each task. Saved Browser Accounts supply vault-backed credentials or hosted profile references rather than a shared Chromium profile directory. One local browser task runs at a time, with at most four waiting tasks and a 30-second queue deadline by default. The 570-second service task budget includes queue time, and rejected or expired queue admissions return 429 with Retry-After.

The Steel image runs as a non-root user with the Chromium sandbox enabled. Container hosts must provide seccomp and AppArmor profiles that permit the sandbox’s user-namespace operations. Configure the chart’s browser.seccompProfile and browser.appArmorProfile for the host. Network policies should admit only the required service connections and restrict browser egress from private and infrastructure addresses. See the repository’s helm/README.md deployment guide for the workload and network settings.

Scheduled use remains an explicit stored-credential local or Browserless account binding snapshotted onto the run at enqueue time. The first provider round is narrowed to that browser capability and requires its execution; missing authority fails the run. Provider and website credentials, profile, origins, persisted scheduled task, and budgets are supplied by the server. The generic MCP schema sanitizer removes authority fields recursively; if a provider requires one from the model, the tool fails closed. Browser Use profile execution remains on the provider task API that accepts an allowed-domain list so it preserves the server-owned navigation boundary.

The authorization service checks persisted grants or a run/session effective tool policy snapshot, then writes audit events for allow and deny decisions. Provider phone calls derive caller access from the RLS-protected call-session snapshot. Unknown and non-admin callers fail closed to a narrow MCP intersection that accepts web_search_exa only from the tenant’s enabled, trusted, connected server whose persisted preset metadata and URL match the canonical Exa catalog identity and whose exact server/tool pair is already allowed and granted. Tool name equality alone is not authority. Their prompt also omits owner persona details, memories, skills, plans, and custom voice instructions.

Subagent model routing follows the same explicit-authority design. The LLM may request a model name but cannot supply user, parent-run, target-agent, provider, credential, or session authority. SubAgentService receives those values from the persisted parent and trusted run context, validates the model against the user-owned allowlist, and intersects child tool permissions before enqueueing.

The contact admin flag is a convenience trust decision based on caller ID, not strong authentication. Provider routing authenticates the integration path, not the human caller. Inbound SMS and WhatsApp share one tenant-scoped contact admission check. Unknown numbers, unresolved WhatsApp identities, stale contact snapshots, and alias conflicts are recorded but receive a durable suppressed reply disposition without an agent run, transcript, or outbound delivery. An exact saved Admin contact may retain the selected agent’s existing search_contacts grant, while non-admin senders cannot access contacts. Group messages never receive contact-derived Admin elevation because the response is visible to the group. Admin status does not add mutating, task-creation, integration-delivery, credential, or arbitrary outbound tools.

Product docs are global application docs. They are safe for all users to read and must not contain private user data. Docs retrieval tools are read-only internal tools and still flow through the same tool policy and audit path.

History tools derive account authority from ToolExecutionContext, retained sessions, and persisted root-run policy. Owner voice proof is persisted from the verified LiveKit route. Current grant revocations and cancellation are checked again before learning commits. Source identifiers select evidence within the verified tenant; they cannot supply account identity or permission.

agent_learning_settings and agent_improvement_evidence use forced RLS. Composite evidence foreign keys bind source sessions and proposals to the same user. Candidate fingerprints deduplicate within an account. Account row locks serialize knowledge changes, consent, grant updates, source withdrawal, and application. Independent assessment runs outside the transaction; final checks compare complete knowledge versions and freshly resolved evidence. Target changes and proposal application commit together, and versioned snapshots protect review and undo from overwriting later edits.

Retrieved history is untrusted data. Secret assignments, quoted credential fields, bearer tokens, and sensitive URL components are sanitized. Retrieval outputs and arguments are withheld from durable tool events so deleting an original source cannot leave a second cached history copy. Learning retains source references rather than complete copied transcripts. Model calls use the existing credential vault and provider client with no tools.

Public source and chart packages contain reusable application code and sanitized examples. Operators provide hostnames, certificate references, Firebase project settings, image registries, storage and network selectors through private Helm values and referenced Secrets. Vault encryption keys and database credentials must survive redeployments and be included in encrypted off-cluster recovery procedures. The frontend obtains its server’s public client configuration at runtime; credentials remain in the backend vault or deployment Secrets.

Published images retain component license notices and source archives under /usr/share/voxos/source. Steel’s /source/ route serves these static build inputs; session, browser and DevTools operations require bearer authentication. The runtime contains compiled service assets and production dependencies, with test captures and dependency test directories excluded from the service tree. See Developer Setup for source access and the component-specific redistribution terms.