Audience: engineers running memQL locally or operating it in lab/prod.
Last updated: 2026-04-25 (post env-var refactor; Phase 8 complete)
Companion doc:copresent/docs/public/operate/env-vars.md covers the frontend side.
Bootstrap envelope -- a small set of OS environment variables the
process must see before it can read anything else. Things like
"where is Postgres", "what node am I", "what's the master encryption
key". These are set in docker-compose.full.yml /
docker-compose.cluster.yml (dev) or in the Cloud Run service
manifest (prod). There is no encrypted-at-rest path for these --
they live in plain env.
Concept storage -- everything else. API keys, OAuth client
secrets, model defaults, feature flags, mail-sender addresses, and
any tunable a tenant might want to override. These live in four
memQL concepts and are seeded via the make secrets-* /
make variable-* workflow rather than env files.
The bootstrap envelope is intentionally tiny so that rotating an API
key, changing a default model, or adding a new tenant's BYOK
credential never requires a redeploy -- only a make variable-set
or a re-seed of the operator's local yaml.
Vendor-only names without a component prefix.AZURE_TENANT_ID
is opaque -- Azure could mean storage, identity, OpenAI-on-Azure,
or anything else. Always pair the vendor with the subsystem
(EMAIL_AZURE_TENANT_ID).
Two prefixes for the same thing. We had MAIL_* and
AZURE_* for the same (email) integration; merging onto EMAIL_*
with the vendor as the second segment removes that ambiguity.
The MEMQL_ prefix where it's redundant. Inside the memQL
repo, every var is "memQL's" -- prefixing every one of them with
MEMQL_ is noise. Reserve MEMQL_ for things that are about
memQL itself (master key, node identity, engine tuning), not for
things memQL happens to call (OPENAI_API_KEY reads cleaner than
MEMQL_OPENAI_API_KEY).
When a name changes (like AZURE_* -> EMAIL_AZURE_* in 2026-04),
the consumer accepts both forms during a transition window so
existing installs don't break. The pattern is:
Update the manifest + .env.local + docs to the new name.
Add a fallback in the consumer (Go integration / DSL provider)
that tries the legacy name if the new one is empty.
Remove the legacy fallback in a follow-up commit once everyone
has re-seeded.
Search for Legacy*EnvKeys / "legacy fallback" in the Go code to
find the active migration shims.
These would tighten the naming scheme but the change radius is too
wide to justify in the same commit as the doc:
MEMQL_SI_*_API_KEY -> SI_*_API_KEY. The MEMQL_ prefix is
redundant inside the memQL repo and the dev manifest already
seeds the bare form. Touches 6 provider .memql files plus Go
bridge-agent and STT bootstrap; coordinate with manifest +
user-yaml renames.
VITE_BYPASS_AUTH -> VITE_AUTH_BYPASS,
VITE_ENABLE_ADMIN -> VITE_FEATURES_ADMIN_ENABLED for stricter
prefix consistency on the frontend.
If you're touching these areas anyway, fold the rename in. Don't
do them as drive-by churn.
Secrets are sealed with NaCl secretbox (XSalsa20-Poly1305) under
MEMQL_MASTER_KEY (32-byte hex). The cleartext is never stored; only
base64(nonce || ciphertext) plus a 4-character fingerprint for UI
display. See component/secret/encryption.go.
When a .memql provider file references a placeholder like
env("MEMQL_SI_OPENAI_API_KEY"), the resolver in
component/memql/si_providers.go (resolveAuthPlaceholders) walks:
Provider .memql files historically reference
MEMQL_SI_<VENDOR>_API_KEY while the dev manifest seeds the bare
form (OPENAI_API_KEY, ANTHROPIC_API_KEY, ...). To bridge that
gap without renaming either side, every layer of the chain tries
both names in priority order:
text
authConceptLookupNames("MEMQL_SI_OPENAI_API_KEY")
-> ["MEMQL_SI_OPENAI_API_KEY", "OPENAI_API_KEY"]
So a provider asking for MEMQL_SI_OPENAI_API_KEY will pick up a
value seeded as OPENAI_API_KEY automatically. The same elision
applies to the OS env fallback.
Providers are loaded eagerly during engine initialization. On a
fresh make dev-refresh, the database wipe runs before the seed,
so when providers first try to resolve their auth keys the concept
storage is empty. The OS env fallback (populated from .env.local
in dev or from the deploy manifest in prod) keeps providers alive
through that bootstrap window until the seed completes.
Future work to retire the OS env fallback cleanly: either lazy
per-request provider auth resolution, or a post-seed engine reload
hook so providers retry concept storage once seeding finishes.
Postgres+TimescaleDB connection string. No default; the process exits if missing.
component/database/database.go
MEMQL_MASTER_KEY
32-byte hex key for NaCl secretbox. Required as soon as any encrypted secret is read; a binary that never decrypts (rare) can boot without it but every realistic deployment needs it.
Public origin (e.g. https://auth.example.com). Used as JWT iss and in outbound email links.
IDENTITY_KEY_ENCRYPTION_KEY
identity binary in non-localhost prod
Master secret (>=16 bytes) wrapping the on-disk Ed25519 signing keypair. Sourced from v1:platform:globalSecret of the same name in production.
IDENTITY_VERIFIER_BASE_URL
non-identity binaries, prod auth
URL the per-node verifier fetches JWKS from. Empty -> dev no-auth identity (local-dev@memql.local).
MEMQL_WORKER_PEERS
cluster mode, BFF first boot
Comma-separated type=host:port seed list (e.g. agent=agent:50055,cognition=cognition:50054,planner=planner:50056). DB-based discovery via v1:cluster:node takes over once peers register. Without it the BFF can't find workers on first boot.
MEMQL_PARENT_ADDRESS
cluster mode, every non-BFF node
bff:50058 -- so the worker's outbound stream reaches BFF for event forwarding.
Affects webhook step behavior; used by demo deployments.
MEMQL_COGNITION_FIT_THRESHOLD
0.4
Float in [0,1]; cognition turn-fit cutoff. Higher = stricter "should I respond?" gate.
MEMQL_QUERY_MAX_RESULTS
10000
Per-query row cap.
MEMQL_QUERY_MAX_WINDOW
100
Query optimizer lookahead window.
MEMORY_ENGINE_CACHE_SIZE
1000
Concept-schema cache size.
MEMORY_ENGINE_CACHE_MAX_TTL
300
Cache entry TTL (seconds).
MEMORY_ENGINE_SI_TOOL_LOOP_MAX_ITERATIONS
10
Max SI tool-calling iterations per turn.
MEMQL_DSL_PATH
unset
Optional on-disk root for the .memql tree. When set and <root>/<typeName> exists, that DSL type reads from disk instead of the embedded copy. Per-type partial overrides supported.
MEMQL_POLICYTRACE_RETENTION_DAYS
90
Retention window (days) for v1:platform:policyTrace rows. Surfaced by purgeExpiredPolicyTraces cron.
#STT / voice (only if Polyphon or streaming STT is enabled)
Variable
Default
Purpose
MEMQL_STT_PROVIDER
auto (deepgram when MEMQL_DEEPGRAM_API_KEY is set, else openai-realtime)
deepgram / openai-realtime / openai-whisper.
MEMQL_STT_LANGUAGE
en
Hard-pinned transcription language for the streaming chat-mic path (AiTranscribeStreamStart). One knob drives BOTH providers: expanded to en-US on the Deepgram stream URL and to en on the OpenAI Realtime session config. Overrides any client-supplied language_hint -- pinning English is what stops the wrong/mixed-language + short-word-hallucination failure mode.
MEMQL_STT_MIN_CONFIDENCE
0.6
Floor a streaming FINAL transcript's confidence must clear to be emitted. Deepgram exposes real per-alternative confidence (noise/silence hallucinations come back below this); OpenAI Realtime finals carry 1.0 and always pass, relying on server-VAD + the empty/denylist filters. Also gates a no-speech denylist of well-known silence hallucinations ("thank you", "thanks for watching", ...) so they're dropped only when confidence is low. 0 disables the confidence + denylist gates (empty-text drop still applies).
MEMQL_DEEPGRAM_API_KEY
none
Deepgram API key. Required for the Deepgram path. Auto-selects Deepgram as the default ASR + TTS provider when present.
POLYPHON_DEEPGRAM_ASR_MODEL
nova-3
Deepgram ASR model id.
POLYPHON_DEEPGRAM_TTS_MODEL
aura-2-thalia-en
Default Deepgram TTS model id; per-voice form (e.g. aura-2-thalia-en) resolved from the canonical voice catalog when the agent has a voice assigned.
POLYPHON_DEEPGRAM_TTS_VOICE_OVERRIDE
unset
Force every Deepgram TTS synthesis to a specific Aura-2 voice id (e.g. aura-2-asteria-en), bypassing the canonical-voice catalog. A/B-testing voices.
POLYPHON_DEEPGRAM_LANGUAGE
en-US
BCP-47 language tag for Deepgram requests.
POLYPHON_DEEPGRAM_ENDPOINTING_MS
500
Silence (ms) before Deepgram fires is_final=true. Doubles as the end-of-utterance trigger in the default mode. Lower = faster STT tail latency; higher = better tolerance for mid-sentence pauses (less splitting of one user turn into multiple agent turns).
POLYPHON_DEEPGRAM_UTTERANCE_END_MS
unset (off)
Set to a non-zero value (Deepgram minimum 1000) to opt into UtteranceEnd-driven EOU. Trades latency (>= 1000ms floor) for no-split tolerance of long pauses. Leave unset for fastest behavior.
MEMQL_OPENAI_REALTIME_MODEL
empty
Realtime model id; falls back to POLYPHON_OPENAI_ASR_MODEL.
MEMQL_WHISPER_MODEL
whisper-1
Used when MEMQL_STT_PROVIDER=openai-whisper.
POLYPHON_VOICE_PROVIDER
auto
deepgram (default when MEMQL_DEEPGRAM_API_KEY is set) or openai. Consumed by the /memql/audio WebSocket path.
Identity-issued class="voice_agent" JWT the Go voice-agent presents on MemqlService.Stream. When empty the agent self-bootstraps via /node/bootstrap (dev). See docs/public/operate/auth/voice-agent-jwt.md.
MEMQL_VOICE_EXECUTOR
realtime
Go voice-agent executor: realtime (OpenAI gpt-realtime speech-to-speech, the default since #483) or cascade (Deepgram STT -> cognition -> Deepgram TTS). Realtime degrades cleanly to the cascade when its preconditions fail (no OPENAI_API_KEY / persona build), logging the reason -- so a fresh run uses realtime and there is no silent cascade surprise. Set cascade to opt out. The active executor is logged loudly at session start (voice-agent voice executor: ...).
MEMQL_VOICE_ROOM_NAME
unset
LiveKit room the Go voice-agent joins (memQL convention: polyphon-<spaceId>). Falls back here when no --room flag is passed.
MEMQL_AVATAR_VENDOR
anam
Avatar vendor on the voice-agent side: anam, simli, or none.
ANAM_API_KEY
unset
Anam (CARA-3) API key. Required when avatar vendor=anam.
This is the table to look at when you ask "where do I put a new API
key" or "where do I change the default model".
The authoritative manifest is
scripts/secrets/manifest.yaml.
Every entry in the manifest is what make secrets-init will prompt
for and what make secrets-seed will push into the running memQL.
These aren't in the manifest yet -- operators add them via
make variable-set -- but they're documented here because they live
in v1:platform:globalVariable and are read by the CoPresent runtime
config layer (src/lib/publicConfig.tsx):
Name
Typical value
Consumer
VITE_OPENAI_MODEL
gpt-5
Default chat model on the frontend.
VITE_OPENAI_REALTIME_MODEL
gpt-realtime
Realtime voice model.
VITE_OPENAI_STT_MODEL
gpt-4o-transcribe
Speech-to-text model.
VITE_OPENAI_TTS_MODEL
tts-1-hd
Text-to-speech model.
VITE_OPENAI_VOICE
shimmer
TTS voice.
VITE_OPENAI_PROJECT_ID
proj_...
OpenAI org / billing project id.
VITE_DEFAULT_LANGUAGE
en-US
UI language.
VITE_ENABLE_ADMIN
true / false
Admin panel feature flag.
MEMQL_DEFAULT_CHAT_PROVIDER
chat54Mini
Forward-looking; whitelisted but not yet read by a consumer.
MEMQL_DEFAULT_STREAM_PROVIDER
stream54Mini
Same.
MEMQL_DEFAULT_TTS_PROVIDER
tts1Hd
Same.
MEMQL_DEFAULT_USER_LANGUAGE
en-US
Same.
The exact name on the memQL side has to match the entry in the
publicConfig whitelist
(src/lib/publicConfig.tsx)
exactly. To add a new one: add it to the whitelist, then
make variable-set NAME=... VALUE=... SCOPE=global.
Anything in v1:platform:globalSecret / v1:platform:globalVariable can be
overridden per-tenant by writing the same name into
v1:platform:partitionSecret / v1:platform:partitionVariable with the tenant's partition
stamped on the row.
The resolver always tries the partition-scoped row first and falls
back to the global one. So a tenant with OPENAI_API_KEY in their
partition's v1:platform:partitionSecret will use their own key; everyone else
keeps using the platform default.
This is the BYOK ("bring your own key") path. The DSL surface is
resolveSecret("OPENAI_API_KEY") and resolveVariable("..."); see
component/memql/sense/builtins.go for the builtin docs.
require_master_key in scripts/dev/lib.sh calls
go run ./scripts/secrets master-key, which reads
~/.memql/dev-secrets.yaml and prints the masterKey field.
The refresh script exports it as MEMQL_MASTER_KEY before
docker compose up, so every container has the key in env.
After the stack is up, the same script runs
go run ./scripts/secrets seed, which encrypts each yaml entry
under the master key and upserts the row into the right concept
over gRPC.
Interactive walk through the manifest. Generates a master key on first run, prompts only for empty entries on subsequent runs.
make secrets-seed
Encrypt + push every entry from the yaml into the running memQL.
make secrets-list
Print the manifest, scope, and whether each entry has a value locally.
make secret-set NAME=X VALUE=Y SCOPE=global
One-off; doesn't touch the yaml.
make variable-set NAME=X VALUE=Y SCOPE=global
Same for plaintext variables.
make secrets-export
Pull every active secret + variable from the running memQL, decrypt locally, merge into the yaml (memQL wins on conflict). Used to back state up before a dev-refresh wipes the database.
dev-refresh does export -> wipe -> restart -> seed in one shot,
so the yaml stays in sync as long as you go through that target.
component/secret/encryption.go reads MEMQL_MASTER_KEY from the OS
env at first encrypt/decrypt call. There is no fallback. If absent
when an encrypted secret is accessed, the process logs a fatal error.
The yaml passthrough above is purely operator tooling -- it puts the
key into the env before docker compose up. Inside the container,
the value is just an env var.
For non-dev installs, set MEMQL_MASTER_KEY directly on the deploy
target (Cloud Run env, Kubernetes secret, etc.). The yaml is never
deployed.
In cluster mode (multiple node-typed binaries), each non-BFF node
needs to know how to reach BFF, and BFF needs to know how to reach
each worker:
MEMQL_PARENT_ADDRESS -- set on every worker (cognition, agent,
planner, voice). Tells the worker to dial BFF for outbound event
forwarding.
MEMQL_WORKER_PEERS -- set on BFF (and on cognition for its
agent-only narrowing). Comma-separated type=address list. First-
boot seed only; once peers register themselves into
v1:cluster:node (a global concept), DB-based discovery takes
over.
Both are bootstrap envelope vars -- they have to be in the env
before the gRPC server starts.
docker-compose.full.yml and docker-compose.cluster.yml have full
worked examples. The full compose is the BFF + cognition + agent +
planner shape; the cluster compose adds voice.
│ ├── Tenant-overridable (BYOK)? → v1:platform:partitionSecret (default), with v1:platform:globalSecret as the global default
│ └── Instance-only? → v1:platform:globalSecret only
└── No → variable
├── Tenant-overridable? → v1:platform:partitionVariable (default), with v1:platform:globalVariable as the global default
└── Instance-only? → v1:platform:globalVariable only
If the value has to be available before memQL connects to its
database (i.e. it controls how memQL connects), it's a bootstrap
envelope var, not a concept entry. There's a strong bias against
adding new entries to the bootstrap envelope -- it requires a
deploy-config change every time it rotates.
make secret-set NAME=OPENAI_API_KEY VALUE='sk-proj-newvalue' SCOPE=global
# Or for prod, point the same target at the prod gRPC endpoint by
# setting MEMQL_GRPC_ENDPOINT in the calling shell.
The old row is soft-deleted (active=false); lastUsedAt /
rotatedAt get stamped on the new row. The next decrypt picks the
new value; nothing else has to restart.
Pulls every active row from the running memQL, decrypts secrets
locally with the master key, and merges the result into the yaml.
Conflict resolution: memQL wins. Run this before any
make dev-refresh that resets the database.
Is the row in v1:platform:globalSecret /
v1:platform:globalVariable?
shell
make secrets-list
or in DSL:
getQuery("queryConfigSecret", { name: "OPENAI_API_KEY" }).
Does the running memQL have MEMQL_MASTER_KEY set in env?
Is the master key the same one that encrypted the row? If
you regenerated it, the existing rows are unreadable -- run
make secrets-seed again to overwrite with the new key.
make secret-set / make variable-set write directly to the
running memQL without modifying the yaml. Useful for one-off
experiments. Note that on the next dev-refresh the wipe-and-reseed
will replace the value with whatever's in the yaml -- export first
if you want to keep it.
The current shape is the result of an 8-phase env-var refactor
completed 2026-04-25. Decision summary:
Two concept trees (globalSecret / globalVariable and
partitionSecret / partitionVariable) so per-tenant BYOK overrides
fall back cleanly to the platform default.
NaCl secretbox (XSalsa20-Poly1305) over AES-GCM for the
encrypted half because it has a smaller surface, no nonce-reuse
pitfalls when keys aren't rotated, and the Go stdlib has no native
AES-GCM with built-in random nonces.
OS-env fallback stays because providers initialize eagerly at
engine boot, before the seed step has populated concept storage.
The fallback keeps the BFF alive through that bootstrap window. A
lazy per-request resolver or a post-seed engine-reload hook would
let us retire it.