Deployment guide
Step-by-step for standing the agent up as a server on a fresh host. There are two supported ways to get the code onto the host — pick one, then follow the shared steps:
| Path A — container from source | Path B — udiagent[server] from PyPI |
|
|---|---|---|
| Use when | deploying a branch or unreleased code; want the exact pinned deps | deploying a released version; no repo checkout; own process manager |
| Host needs | Docker + a clone of this repo | Python ≥ 3.12 and pip/uv |
| Dependency versions | exact, from the workspace uv.lock |
resolved at install time from pyproject.toml ranges |
| Process supervision | --restart unless-stopped |
yours (systemd unit below) |
| Dev-data endpoints | work out of the box | need UDI_DATA_DIR (step 3B) |
Steps 1–2 and 4–5 apply to both, step 3 splits, and step 6 is client-side.
1. Get the code on the host
Section titled “1. Get the code on the host”Path A — clone the repo:
git clone https://github.com/hms-dbmi/udi-yac.gitcd udi-yacgit checkout <tag or branch> # e.g. a release tag, udiagent-vX.Y.ZOnly the Python workspace matters for the agent image — no pnpm install, no
uv sync on the host. The build installs dependencies inside the image.
Path B — install the published distribution:
python -m venv /opt/udiagent/venv # or: uv venv /opt/udiagent/venv/opt/udiagent/venv/bin/pip install "udiagent[server]"The PyPI distribution is named udiagent (udi-yac is the npm chat
package). Add extras as needed — "udiagent[server,langfuse]", plus duckdb /
starrocks for server-side query backends.
2. Write the production env file
Section titled “2. Write the production env file”Copy the template and fill it in. Environment variables are the server’s entire configuration surface — there is no config file format beyond this.
# Path A passes it to `docker run --env-file`; Path B finds it in the service's# working directory, so keep it there.mkdir -p /opt/udiagentcp packages/agent/.env.template /opt/udiagent/.envchmod 600 /opt/udiagent/.envPath B has no repo checkout, so grab the template from GitHub or just write the values below by hand.
Minimum production values:
# Auth — REQUIRED. The server refuses to start with an empty signing key# unless INSECURE_DEV_MODE=1. Never set INSECURE_DEV_MODE in production.INSECURE_DEV_MODE=0JWT_SECRET_KEY=<paste output of: openssl rand -hex 32>
# LLM backend — either a server-held key, or leave blank to require callers# to send their own via the X-OpenAI-Key header.OPENAI_API_KEY=sk-...GPT_MODEL_NAME=gpt-5.4Full variable list: Server Environment Variables.
Where the values come from, in precedence order: real environment variables
(Docker --env-file, systemd EnvironmentFile, shell exports) win, then
<package root>/.env (the repo checkout), then ./.env from the process
working directory. On Path B the first and third are the usable ones.
(Optional) point at a third-party OpenAI-compatible backend
Section titled “(Optional) point at a third-party OpenAI-compatible backend”To serve from Azure AI Foundry, Bedrock, OpenRouter, or a self-hosted Ollama/vLLM, add the backend root and its model id:
OPENAI_BASE_URL=https://openrouter.ai/api/v1GPT_MODEL_NAME=openai/gpt-oss-120bOPENAI_API_KEY=<that backend's key> # may be blank for Ollama/vLLMTwo deployment consequences worth deciding on deliberately:
OPENAI_BASE_URLis global. The OpenAI SDK reads that env var for every client it builds, so requests that bring their ownX-OpenAI-Keyare routed to this backend too — a user’s personal OpenAI key would be sent to it. If you host a bring-your-own-key deployment, leaveOPENAI_BASE_URLunset.- Capability floor. The backend must support function calling and
JSON-schema structured outputs (
strict: true); the orchestrator also usestool_choice: "required". Backends missing these degrade to fallback paths instead of failing loudly, so run step 4’s visualization request after switching backends. Details: Third-party backends.
(Optional) Amazon Bedrock with the instance’s IAM role
Section titled “(Optional) Amazon Bedrock with the instance’s IAM role”To drop the static key entirely and authenticate with the EC2 instance profile or ECS task role:
UDI_BEDROCK=1AWS_REGION=us-east-1GPT_MODEL_NAME=openai.gpt-oss-120b-1:0# Leave OPENAI_API_KEY and OPENAI_BASE_URL unset — the server refuses to start# if either is set alongside UDI_BEDROCK.Callers cannot opt out of Bedrock: a request carrying X-OpenAI-Key is
rejected with a 403 rather than served from api.openai.com, so no prompt or
data schema leaves your AWS account. Pair this with
VITE_UDI_REQUIRE_API_KEY=false in the chat frontend.
Behavior and constraints: Bedrock with an IAM role. Three infrastructure preconditions have to hold, and none of them fail loudly in an obvious way:
-
The role needs Bedrock permissions. Grant the instance profile or task role
bedrock:InvokeModelandbedrock:InvokeModelWithResponseStream. Models reachable only through a cross-region inference profile also need the action on the profile ARN and on the underlying foundation-model ARNs in every region the profile spans. -
On EC2, a bridged container needs an IMDS hop limit of at least 2. With the AWS default
HttpPutResponseHopLimit: 1, Docker’s bridge network adds a hop, the IMDSv2 token response never reaches the container, and credential resolution fails. SetHttpPutResponseHopLimit: 2in the launch template’sMetadataOptions(it affects only newly launched instances) or fix a running instance in place:Terminal window aws ec2 modify-instance-metadata-options --instance-id <id> \--http-put-response-hop-limit 2 --http-tokens requiredECS tasks are not affected: they get the task role’s credentials from the ECS credentials endpoint, not from IMDS.
-
AWS_REGIONmust be in the env file. There is no instance-metadata fallback for the region. Put it in the env file rather than passing-e AWS_REGION=...todocker run— Docker applies-eafter--env-fileregardless of order, so a flag would override the file and remove the operator’s ability to invoke cross-region.
If any of the three is wrong, requests return 503 with a generic message
and the server log carries the specific cause at ERROR — start there rather
than from the client response.
A local run with AWS_PROFILE set exercises the identical code path (same
default credential chain), so the setup can be validated before deploying.
3A. Path A — build and run the container
Section titled “3A. Path A — build and run the container”docker build -f packages/agent/Dockerfile -t udi-agent . # from repo root
docker stop udi-agent 2>/dev/null; docker rm udi-agent 2>/dev/nulldocker run -d \ --name udi-agent \ --restart unless-stopped \ -p 80:8000 \ --env-file /opt/udiagent/.env \ udi-agentThe container listens on port 8000 as a non-root user — an unprivileged port,
so it binds on runtimes that don’t let non-root processes take ports below 1024.
Map it wherever your reverse proxy expects it (-p 8007:8000
for a local-only port). Logs go to
docker logs udi-agent plus a rotating file at
/app/packages/agent/logs/udi_agent.log inside the container.
3B. Path B — run the installed distribution
Section titled “3B. Path B — run the installed distribution”Serve the app module with uvicorn (installed by the server extra). The
fastapi run command used for local development takes a file path into the
source tree, which an installed wheel doesn’t have; uvicorn takes the import
path instead:
cd /opt/udiagent # .env and relative paths resolve hereUDI_LOG_DIR=/var/log/udiagent \ ./venv/bin/uvicorn udiagent.server.app:app --host 0.0.0.0 --port 8007Two path-related settings matter only on this path, because _PACKAGE_ROOT
resolves into site-packages when the code is installed rather than checked out:
UDI_LOG_DIR— without it the rotating log file is written next tosite-packages. If that location isn’t writable (a system-wide install run by a non-root service user), file logging is skipped with a one-line notice on stdout and the server still boots.UDI_DATA_DIR— only theudiagentpackage’s own data (skills, grammar schema) ships in the wheel; the repo’spackages/agent/data/does not. Without this variable/v1/yac/examplesreturns404, which the chat UI tolerates by showing no example-prompt suggestions. Point it at a copy of that directory if you want them (it is ~2 MB, mostly benchmark fixtures)./v1/yac/benchmark_analysislikewise serves the newestout/**/benchmark_analysis.jsonunder the package directory, which a wheel install doesn’t have; it is a benchmarking-only endpoint.
A minimal systemd unit:
[Unit]Description=UDIAgent serverAfter=network.target
[Service]User=udiagentWorkingDirectory=/opt/udiagentEnvironmentFile=/opt/udiagent/.envEnvironment=UDI_LOG_DIR=/var/log/udiagentExecStart=/opt/udiagent/venv/bin/uvicorn udiagent.server.app:app --host 0.0.0.0 --port 8007Restart=always
[Install]WantedBy=multi-user.targetEnvironmentFile does not do shell quoting or ${VAR} expansion — keep values
plain (JWT_SECRET_KEY=abc123, no surrounding quotes).
To upgrade: pip install -U "udiagent[server]" then restart the unit. Pin the
version (udiagent[server]==X.Y.Z) if you want reproducible redeploys, since
this path resolves dependency ranges fresh at install time rather than using the
repo’s lockfile.
4. Verify
Section titled “4. Verify”Substitute your port (80 for the container as run above, 8007 for the
systemd unit):
curl -fsS http://localhost/ # {"service":"UDIAgent API","status":"running","version":...,"git_sha":...}curl -fsS http://localhost/v1/yac/structured_functions # 200 — bundled package data loadedThen exercise the LLM path end to end, which is the only check that catches a bad key, wrong model id, or a backend that can’t do structured outputs:
curl -fsS -X POST http://localhost/v1/yac/completions \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d '{ "messages": [{"role": "user", "content": "bar chart of penguins by species"}], "dataSchema": "{\"resources\":[{\"name\":\"penguins\",\"schema\":{\"fields\":[{\"name\":\"species\",\"type\":\"string\"},{\"name\":\"body_mass_g\",\"type\":\"number\"}]}}]}", "dataDomains": "[]" }'A healthy response is a JSON list of one or more tool calls. $TOKEN is a
JWT signed with JWT_SECRET_KEY (HS256 by default) — mint one with the
python-jose that the server extra already installed:
# Path A (inside the container)TOKEN=$(docker exec udi-agent uv run --frozen --no-sync python -c \ "import os; from jose import jwt; print(jwt.encode({'sub':'smoke-test'}, os.environ['JWT_SECRET_KEY'], algorithm='HS256'))")
# Path B (in the venv, with JWT_SECRET_KEY exported)TOKEN=$(/opt/udiagent/venv/bin/python -c \ "import os; from jose import jwt; print(jwt.encode({'sub':'smoke-test'}, os.environ['JWT_SECRET_KEY'], algorithm='HS256'))")Two different failures both return 401; tell them apart by the body —
{"detail":"Invalid or expired token"} is a JWT problem, whereas
{"error":"No OpenAI API key..."} means auth passed and the LLM credential is
the thing that’s missing.
Logs: docker logs udi-agent (Path A) or journalctl -u udiagent (Path B),
plus the rotating file described in step 3.
5. Terminate TLS in front of it
Section titled “5. Terminate TLS in front of it”The server speaks plain HTTP on both paths. The chat frontend is served over
HTTPS (GitHub Pages at /udi-yac/), and browsers block HTTPS pages from calling
an http:// API — so an HTTP-only agent is unreachable from the deployed chat,
not merely insecure. Put nginx/Caddy/an ALB in front with a certificate and
proxy to the port from step 3.
CORS allows any origin by default, with the X-Usage-* headers exposed. In
production, set UDI_CORS_ORIGINS to the origins that embed the chat (see
Server Environment Variables).
6. Point the chat at it
Section titled “6. Point the chat at it”In packages/chat/.env.local (or the Pages build environment):
VITE_UDI_API_BASE_URL=https://agent.example.orgVITE_UDI_REQUIRE_API_KEY=false # true = users supply their own OpenAI keySet VITE_UDI_REQUIRE_API_KEY=true when the server has no OPENAI_API_KEY and
expects per-request X-OpenAI-Key headers. The chat’s full variable list lives
in packages/chat/.env.example (generated from
packages/chat/src/app/envVars.ts).