DATABASE_URL=postgres://synaptodesk:synaptodesk@localhost:5432/synaptodesk # Credential vault encryption key. The example value is public and only suitable for local # development. Production refuses to start with this key. # openssl rand -base64 32 KEY_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= # How many days of audit trail to keep. Unset keeps everything, which is the safe default and the # one to leave alone until somebody has decided otherwise: the trail is append-only and nothing else # can remove a row, so this is the only way it ever shrinks. # AUDIT_RETENTION_DAYS=365 # Two names for one number, and they must agree when both are set. # # The server accepts either PORT or SERVER_PORT (server/src/config.ts), preferring PORT when both # are present and refusing to start if they disagree, so a single edit is enough. An empty value # is an unset one, so `PORT=` with SERVER_PORT set moves the server too. scripts/start.sh # and the app's Vite proxy read SERVER_PORT/APP_PORT, and docs/configuration.md documents # SERVER_PORT as the setting. Only PORT shipped here historically, so moving the server by # editing one line left the script still looking at 3001: it found whatever else was there, # accepted the first 200 as proof, and failed several stages later parsing that stranger's # HTML as JSON. Setting both to the same value remains valid. PORT=3001 SERVER_PORT=3001 TENANT_PACKAGE_DIR=../examples/fintech # Let a Bot answer with an interface it wrote itself: markup, styles and a script it generates for # that one answer, streamed into the transcript and rendered in a sandboxed iframe with no # same-origin access to this app. On by default; set this to `false` or `0` to opt out. # # This is not the component catalogue. A component is something this deployment holds and an # administrator grants per Bot; this has nothing to grant, because the Bot writes it on the spot and # it is gone when the conversation moves on. It is a deployment switch rather than a per-Bot grant # because the SDK offers no seam for one — see DeploymentConfig.generativeUi in server/src/config.ts. # # What the interface can reach is what the sandbox hands it, and this deployment hands it nothing: no # session, no same-origin access, and no route into your data. It can load libraries from a CDN, so a # deployment that must not reach the public internet from a browser tab should set this to false. # SYNAPTODESK_GENERATIVE_UI=false # What this deployment calls itself, when more than one shares an Intelligence project. A copy of a # deployment made for development uses the same project key, and threads are listed per Bot with # nothing to say which deployment a conversation came from. The name goes into every thread id this # deployment mints, so its own conversations stay identifiable. Unset, the tenant package's id is # used, which tells two packages apart but not two copies of one. # DEPLOYMENT_ID= # Sign-in. The line below runs the deployment as one administrator with no sign-in at all, which is # how you reach the product without registering an OAuth client first. Delete it and configure a # provider before anybody else can reach this: while it is set, every visitor is an administrator. # With no provider and this line gone, the deployment refuses to start rather than guessing. SYNAPTODESK_SINGLE_USER=true # Optional desktop employee sign-in authority: an SynaptoDesk deployment configured with a provider # below. Separate from Intelligence URLs/project credentials; secrets stay on the authority. # When set, verified employee identity replaces the standalone actor even if SINGLE_USER is true. # SYNAPTODESK_ORGANIZATION_AUTH_URL=https://synaptodesk.company.example # # Configure ANY ONE of the three providers to turn sign-in on. Configure several and the sign-in # screen offers several, which is the normal shape for a company mid-migration. # # These four are needed whichever provider you pick: # BETTER_AUTH_URL is where OAuth callbacks come back to, which is the API on port 3001. # BETTER_AUTH_SECRET signs session cookies. At least 32 characters: openssl rand -base64 32 # TRUSTED_ORIGINS is where the app is served from, which is port 3010 locally. # INITIAL_ADMIN_EMAILS names who is an administrator. Required, because nothing else grants the # role and no screen can promote somebody later. Re-read on every sign-in, so editing it works. # BETTER_AUTH_URL=http://localhost:3001 # BETTER_AUTH_SECRET= # INITIAL_ADMIN_EMAILS=admin@example.com # # Google. Redirect URI: http://localhost:3001/api/auth/callback/google # GOOGLE_OAUTH_CLIENT_ID= # GOOGLE_OAUTH_CLIENT_SECRET= # # Microsoft (Entra ID). Redirect URI: http://localhost:3001/api/auth/callback/microsoft # MICROSOFT_OAUTH_TENANT_ID defaults to `common`, which admits personal Microsoft accounts as well # as work ones. Put your directory GUID here if you mean only your own company. # MICROSOFT_OAUTH_CLIENT_ID= # MICROSOFT_OAUTH_CLIENT_SECRET= # MICROSOFT_OAUTH_TENANT_ID=common # # Okta. Redirect URI: http://localhost:3001/api/auth/callback/okta # The issuer is what makes it your Okta rather than Okta in general. # OKTA_OAUTH_CLIENT_ID= # OKTA_OAUTH_CLIENT_SECRET= # OKTA_OAUTH_ISSUER=https://example.okta.com/oauth2/default # Email domains admitted at sign-in, comma separated. Empty admits everybody, which is what the # providers above already do on their own: the Entra tenant is `common` unless changed, which is any # Microsoft account including personal ones, and Okta has no equivalent. Google has one, `hd`, which # is stronger than this because Google verifies Workspace domain ownership; narrow it there as well # rather than instead. # # A filter, not a boundary: it is checked against the address the provider returns, and SynaptoDesk does # not require a verified one. The server refuses this combined with MICROSOFT_OAUTH_TENANT_ID=common, # where that address is written by the tenant signing in. # # Matched exactly, no wildcards, so name every domain you use. `example.com` admits neither # `sub.example.com` nor `evil-example.com`. # SIGNIN_ALLOWED_EMAIL_DOMAINS=example.com,example.co.uk # Where this deployment is reached from outside. Only needed for connectors that a person connects # their own account to, such as Google Drive: it builds the redirect URI the vendor sends them back # to, which has to match what an administrator registered character for character. Defaults to # BETTER_AUTH_URL, so most deployments never set it; set it where the API is behind a different # public address than the one sign-in uses. Without either, the Plugins page says so and nobody can # connect an account. # SYNAPTODESK_PUBLIC_URL=https://synaptodesk.example.com # Where the browser app is served from. The OAuth callback lands on the API and has to send the # person back to a page, and locally those are two different ports: the app is 3010 and the API is # 3001. Defaults to the first TRUSTED_ORIGINS entry, then to SYNAPTODESK_PUBLIC_URL, which is right for a # deployment serving both from one origin. # SYNAPTODESK_APP_URL=http://localhost:3010 TRUSTED_ORIGINS=http://localhost:3010 # CopilotKit Intelligence. Required: the server refuses to start without all four because # Intelligence owns durable threads and memory and a deployment without it forgets every # conversation. There is no degraded mode. # # Do not run Intelligence yourself. These two point at the managed service and should be left # unchanged; the CLI below provisions a free licence for it. Running Intelligence on your own # infrastructure is an Enterprise Intelligence Platform feature deployed by Helm chart, and is not # self-serve: https://docs.showcase.copilotkit.ai/premium/self-hosting INTELLIGENCE_API_URL=https://api.intelligence.copilotkit.ai INTELLIGENCE_GATEWAY_WS_URL=wss://realtime.intelligence.copilotkit.ai # # Get both of the next two from the CopilotKit CLI: # # npx --yes copilotkit@latest login # browser sign-in # npx --yes copilotkit@latest project select # prints the cpk-... runtime key -> INTELLIGENCE_API_KEY # # The runtime key is also under "API Keys" in your project at https://intelligence.copilotkit.ai INTELLIGENCE_API_KEY= # Optional, and blank on purpose. Managed Intelligence derives entitlement from the project key # above and issues no licence token. A self-hosted Intelligence with its own licence sets this and # it is forwarded to the runtime; startup does not require it. COPILOTKIT_LICENSE_TOKEN= # Learning defaults on. Fresh managed setup: login, project select, then bun scripts/setup-learning.ts. # The helper creates/reuses synaptodesk and writes the verified key and container; custom targets stay. # Fresh self-hosted setup: set its API URL, install Chrome/Edge, then run the same helper to sign in. # Existing key-only setup: create a container in that project before assigning its ID below. # Admin → Automatic Learning can enable/disable, override per Bot, and save settings in the DB. # Saved settings take precedence, including off. Without an assigned container, collection and # delivery stay inactive; SynaptoDesk still starts and chats. # Project credentials remain server-side; review and publish skills in the Intelligence UI. # CPK_INTELLIGENCE_LEARNING_CONTAINER_ID=support-learning # Optional opaque published revision ID; omit to follow latest. # CPK_INTELLIGENCE_SKILLS_REVISION=exact-revision-id # See docs/automatic-learning.md for managed/self-hosted setup and permissions. # How long a Bot's stream may say nothing before this deployment gives up on the turn, in # milliseconds. A Bot is any AG-UI endpoint, which means it will be redeployed mid-answer, its own # upstream will time out, and it will sometimes accept a connection and then write nothing at all. # Without this the channel stays busy, the composer stays locked, and the only way out is a reload. # # Silence, not duration. A turn may legitimately run for an hour while events keep arriving; what is # measured here is the gap between them. Nothing about how long a Bot is allowed to work changes. # # A minute, because this deadline is in a race it has to win. Every Bot in this repository serves on # Bun with `idleTimeout: 120`, and Bun tears a wedged streaming response down at roughly a second # past that. A watchdog set to the same two minutes lands within a second of the socket dying, and # whichever gets there first decides what the person sees: this deployment's sentence naming the Bot # and saying the turn was ended, or "The socket connection was closed unexpectedly" and no audit row # at all. Half the Bot's own idle timeout is far enough clear that the answer is always the first. # # A minute is still far longer than any real silence inside a run. The longest legitimate one is the # wait for a model's first token, which is seconds. Browser tool calls do not need allowing for: # they run between turns, not during one, because the run ends before the browser executes the tool # and a second run carries the result back. # # 0, or leaving this unset, switches the watchdog off. Nothing is watched and no turn is ever ended. AGENT_STALL_TIMEOUT_MS=60000 # Model key. Required by the proof-of-concept Bot, which speaks OpenAI's API directly, and by the # framework Bot unless you point it at another provider below. OPENAI_API_KEY= # Where that key is spent. Unset, it is OpenAI. Set, it is any endpoint speaking the same # `/v1/chat/completions` API: a gateway in front of several providers, a proxy, or a model on # hardware you control. The API server reads it for the built-in agents and both shipped Bots read # it too, so one line moves the whole deployment rather than one Bot. # # Model names travel verbatim, so use whatever the endpoint publishes. An endpoint that namespaces # its catalogue wants both halves of the name, in `BOT_MODEL` and in the tenant package's # `default_model` alike: # # OPENAI_BASE_URL=https://gateway.internal/v1 # OPENAI_API_KEY=... # BOT_MODEL=openai/gpt-5.5 # # OPENAI_BASE_URL= # Optional dictation, independent of every agent/chat provider. Set provider, base URL, # and model together. The endpoint must implement POST /audio/transcriptions under this # base URL (include /v1 if needed). No chat endpoint or API key is inherited. # TRANSCRIPTION_PROVIDER=openai-compatible # TRANSCRIPTION_BASE_URL=https://api.openai.com/v1 # TRANSCRIPTION_MODEL=gpt-transcribe # TRANSCRIPTION_API_KEY= # The key may be omitted for an explicitly configured local service without authentication. # Optional live voice calls. Independent from dictation and each agent's chat provider. # Choose openai-realtime (WebRTC) or xai-realtime (Grok Voice over WebSocket). # VOICE_PROVIDER=openai-realtime # VOICE_MODEL=gpt-realtime # VOICE_NAME=marin # VOICE_API_KEY=$OPENAI_API_KEY # Grok alternative: VOICE_PROVIDER=xai-realtime, VOICE_MODEL=grok-voice-latest, # VOICE_NAME=ara, VOICE_API_KEY=$XAI_API_KEY (define XAI_API_KEY separately). # Optional. Leave empty unless containers need a different route to the same compatible endpoint, # such as a locally hosted model whose host URL is localhost but whose Compose-network URL is a # service name. Empty means containers use OPENAI_BASE_URL too. # OPENAI_CONTAINER_BASE_URL= # The same for the other two providers, under the names the API server already reads. They are # different APIs rather than different URLs for this one, so each has its own. # ANTHROPIC_BASE_URL= # GOOGLE_GENERATIVE_AI_BASE_URL= # Framework Bot provider: openai, anthropic or google. It reads that provider's own # key and refuses to start without it, so a deployment on Anthropic never needs an OpenAI key for it. # The proof-of-concept Bot is OpenAI only by construction: it speaks that API directly. # BOT_PROVIDER=openai # ANTHROPIC_API_KEY= # GOOGLE_API_KEY= # A model sign-in in place of a key: the Anthropic token from `claude setup-token`, and the file a # signed-in ChatGPT plan writes. The desktop app sets both when a provider is connected that way; a # box you configure yourself sets them here. The API server reads them for built-in agents, and # compose passes them to the picked harness. See docs/configuration.md. # CLAUDE_CODE_OAUTH_TOKEN= # CHATGPT_AUTH_FILE= # Which model the framework Bot uses. Defaults per provider: gpt-5.5, claude-sonnet-4-5, # gemini-2.5-flash. A 5.6 tier works here: set one and the Responses API is switched on # automatically. It used to answer nothing at all on those models — RUN_STARTED, RUN_FINISHED, no # text — because that API streams content blocks rather than a string and the run read only the # string. The default is left at 5.5 so the two shipped Bots stay comparable out of the box. # BOT_MODEL=gpt-5.5 # OpenAI only, and rarely needed: the framework Bot turns the Responses API on by itself for models # that require it. Set it when you are using a model this build has not heard of that needs it too. # BOT_RESPONSES_API=false # How hard the framework Bot thinks, on models that reason. OpenAI's setting, and one the Responses # API carries, so it needs a model running on that API — a 5.6 tier does by itself, anything else # wants BOT_RESPONSES_API=true. Unset, the model keeps its provider's own default. # # One of: none, minimal, low, medium, high, xhigh, max. Anything else and the Bot refuses to start # and says so, rather than starting with a setting that quietly went nowhere. # BOT_REASONING_EFFORT= # The Bot computer. Absent means the feature is off and its routes are not mounted. AGENT_COMPUTER_URL=http://localhost:4100 # Secret every computer requires from its caller. `agent-computer` drives a browser holding real # logins, while policy, audit and sign-in live in the API server. The computer refuses to start # without this value and refuses every request that does not present it. Use a long random value; # `scripts/start.sh` sets a development one for you. COMPUTER_TOKEN= # Local only, and commented out because this file gets copied. Uncommenting it lets a Bot browse this # machine's own services, which is what a laptop wants and what a deployment must never have: it # lifts the private-address floor in browsing and in the endpoint a Bot may be registered against. # The server refuses to start with it set under NODE_ENV=production. # AGENT_COMPUTER_ALLOW_PRIVATE_HOSTS=true # # Private addresses an agent may be registered at, named one at a time, comma separated. This is what # a deployment uses instead of the switch above: bring your own agent, running on your own network, # without lifting the floor for browsing or for anything else. # # A host, optionally with a port. `agents.internal` covers any port on that host; `10.0.0.42:9000` # pins that one. Matching is exact — no wildcards, no suffixes — and a URL or a `*` is refused at # startup with the entry named, rather than silently never matching. The never-allowed addresses, # cloud metadata among them, cannot be named back in. Unset means none, which is the default posture. # # AGENT_ENDPOINT_ALLOWED_HOSTS=agents.internal,10.0.0.42:9000 # # What a Bot may do on its computer, as one JSON object. Absent uses the built-in default, which # permits the acting tools and forbids nothing, and records every action either way. # # `deny` is evaluated first and beats `allow`. An empty `allow` permits nothing, a missing policy # permits nothing, and a rule that fails to parse denies rather than letting the action through. The # server refuses to start if this is set and malformed, so an invalid restriction never falls back to # permissive behavior. # # Workspace and browser profile per Bot. Each Bot's computer is its own container with its own # volumes, so one Bot cannot read another's files or use another's logins, and every action records # which Bot took it. A rule can still restrict a single Bot with `bot.id`. # # Attributes: tool.name, bot.id, actor.id, page.url, page.host, element.ref/role/name/type, # key, file.path, file.name, file.extension. # # Name every route to the same effect. A form submits from a keypress in any of its fields, so a rule # that only blocks a Submit button does not block Enter from another field. The example below refuses # Enter outright for that reason, from both tools that can press it: a key action, and a type action # carrying `submit`. # Functions: contains(haystack, needle) and matches(value, pattern), both case-insensitive. # `enforce` blocks; `dry-run` decides and records but lets everything through, so a new rule can be # tried against real traffic before it starts refusing anybody's work. # # AGENT_COMPUTER_POLICY={"mode":"enforce","deny":["(intent == \"activate\" && contains(element.name, \"submit\")) || ((tool.name == \"computer_key\" || tool.name == \"computer_type\") && key == \"Enter\")"],"allow":["true"]} # How long one action waits for its element, in ms. Read by agent-computer, not the server. # ACTION_TIMEOUT_MS=10000 # Extra environment variables a command on the computer may see, by name, comma-separated. The # shell already receives PATH, locale, terminal and proxy variables. Proxy userinfo is stripped, so # a password in HTTP_PROXY is not in `env`. It does not inherit the rest of this process, so # KEY_ENCRYPTION_KEY and the other deployment secrets are not in `env`. Naming a secret or a # credentialed proxy here is an operator's decision. # COMPUTER_SHELL_ENV=JAVA_HOME,GOPATH # --------------------------------------------------------------------------- # The computer's profile and where its traffic leaves from # --------------------------------------------------------------------------- # Chromium profile directory inside agent-computer, mounted from the `agent-profiles` volume in # docker-compose.yml. Pointing it somewhere unmounted disables login persistence. # PROFILES_DIR=/profiles # Browser process used by a Bot's computer. Managed mode always uses full Chromium. `headless` is # the default; Linux `headed` uses a private virtual display with the same live-screen controls. # COMPUTER_BROWSER_BACKEND=managed # COMPUTER_BROWSER_MODE=headless # Opt-in installed Chrome on the same machine as a local API deployment: # bun scripts/start-local-chrome-computer.ts # The helper requires COMPUTER_TOKEN, binds 127.0.0.1:4101, and keeps dedicated per-Bot profiles. # Set AGENT_COMPUTER_URL=http://127.0.0.1:4101 on the API, clear COMPUTER_SUPERVISOR_URL and # COMPUTER_SANDBOX_NAMESPACE, then restart the API. See docs/configuration.md for full setup. # Local mode requires headed; do not leave an explicit headless override set in its environment. # COMPUTER_BROWSER_BACKEND=local-chrome # COMPUTER_BROWSER_MODE=headed # Optional absolute app-owned data root; defaults to the platform's SynaptoDesk user-data directory. # SYNAPTODESK_LOCAL_COMPUTER_DIR= # Which Bot this computer belongs to, and therefore which profile directory it uses. # COMPUTER_BOT_ID=shared # Per-Bot egress identity. A proxy the Bot's browser sends everything through, so the far side sees a # stable address it can allow-list or attribute to that Bot. `EGRESS_PROXY_` names one Bot, # `EGRESS_PROXY_DEFAULT` covers the rest, and absent means the browser goes out directly. # # Credentials may be in the URL and are split out before Playwright sees them. Only the HOST is ever # reported back on the admin page or the API, so the password does not end up on a screen. # # This is attribution, not anonymity, and it is not a boundary by itself: it gives a security team a # per-Bot address for network rules alongside AGENT_COMPUTER_POLICY. # # These go in `egress.env` beside this file, NOT here. The names are per-Bot, so Compose cannot # list them the way it lists every variable below, and it hands a container only what it is told to. # In `.env` they reach no process and the browser goes out directly with nothing saying so. # # EGRESS_PROXY_DEFAULT=http://user:password@proxy.internal:8080 # EGRESS_PROXY_SALES_BOT=http://sales.proxy.internal:8080 # The managed coworker AG-UI endpoint. Optional: use an HTTP(S) URL, and set MANAGED_AGENT_TOKEN # with it. A URL with no token refuses to start; a leftover token with no URL is ignored. # # Unset in the one-container image, which does not carry a Bot. `scripts/start.sh` points this at # agent-langgraph on 4201 for a laptop. The proof-of-concept on 4200 hand-writes the protocol and # leaves the loop to whatever is watching, so it is a reference rather than something to build a # deployment on. # MANAGED_AGENT_AG_UI_URL=http://localhost:4201/ag-ui # Shared secret sent by the server on every call to a managed Bot, as the `x-synaptodesk-agent-token` # header. Required together with the URL above; the server starts without either. # # `scripts/start.sh` generates one and writes it back here on first run, so leaving this empty is # fine locally. Set it yourself for a deployment: openssl rand -base64 32 MANAGED_AGENT_TOKEN= # The second Bot in the box runs on http://localhost:4201/ag-ui, on a framework rather than # proof of concept, and is reached the same way: point MANAGED_AGENT_AG_UI_URL at it, or add it as a # Bot of its own in the tenant package or at /agents. # Which model the Bots use. BOT_MODEL is the framework Bot's: it defaults to gpt-5.5 and switches # to the Responses API by itself for a model that needs it — a 5.6 tier does, because 5.6 rejects # function tools on /v1/chat/completions. # # The proof-of-concept Bot has its own, AGENT_BOT_MODEL, defaulting to gpt-5.5, because it writes # that endpoint by hand and refuses to start on a model whose tools it cannot use. One variable for # both would mean setting the framework Bot's model quietly took the other one's tools away. # AGENT_BOT_MODEL=gpt-5.5 # BOT_RESPONSES_API=false # One computer per Bot. Unset, every Bot shares the computer at AGENT_COMPUTER_URL, suitable on a # laptop and explicit about being one shared machine. Set, the supervisor gives each Bot a container # of its own, with its own /workspace and its own browser profile. COMPUTER_SUPERVISOR_URL= # Shared with the supervisor. Not the security boundary, its four verbs are, but it keeps anything # else that can reach the port from cycling a Bot's computer. SUPERVISOR_TOKEN= # Set to runsc to run every computer under gVisor, if the host has it. Unset, a computer is an # ordinary container and shares the host kernel, which is worth knowing when the Bot is not ours. COMPUTER_RUNTIME= # The secret a framework Bot presents when it calls a tool back through this server. A Bot runs its # own loop, in its own process, and still may not reach a vendor directly: it calls the deployment # that granted the tool, which is where the grant, the policy and the audit row are. Absent, no Bot # may call tools back and it is told so rather than being quietly allowed. AGENT_TOOL_TOKEN= # The secret the routine worker presents when it hands a run back to this server. It authenticates # the handoff of one routine run id, and nothing else: the server re-reads the routine, the Bot and # everything else it needs from the database, so the worker cannot use this to inject an instruction. # # Left empty, the internal endpoint refuses every call and no routine ever fires — the correct state # for a deployment that has not stood up a worker. Set for one that has: openssl rand -base64 32. # Do not accept a default in production. WORKER_SHARED_SECRET= # Composio, the broker that holds people's accounts for a few hundred apps, so a Bot can act in Gmail # or Slack or Notion without this deployment registering an OAuth client with each of them. # # Optional. Without this key there is nothing to connect, nothing to grant, and no Composio tool for # a Bot to call. What does remain is one row that goes nowhere, under "More apps" on the admin # Plugins page, saying that adding a Composio key enables a catalogue of tools and naming this # variable — the feature is named rather than hidden, so an administrator who has heard of Composio # can find out what it wants. There is no directory to browse, no picker, and no brokered app on # anybody's connected-accounts page. # # This variable is the whole of the setting. There is no screen anywhere in the product that sets it: # it is read from this environment at startup, and an administrator with every permission there is # cannot turn Composio on from a page. # # One key for the whole deployment, not one per person. Composio keeps people apart by a user id sent # with every call, so one account's connections are never reachable from another's. COMPOSIO_API_KEY=