FastAPI + web UI + Telegram wrapper for Codex CLI, ported from the local
claude-orchestrator project.
cd codex-orchestrator
cp .env.example .env
# Edit .env and replace every placeholder secret.
set -a
. ./.env
set +a
python -m uvicorn server:app --host 0.0.0.0 --port 8765Open http://localhost:8765/, log in.
On startup, if the web-user database is empty, the server bootstraps the first admin user from environment variables:
export WEB_AUTH_USERNAME=admin
export WEB_AUTH_PASSWORD='<at least 12 chars>'Use WEB_AUTH_PASSWORD_HASH instead of WEB_AUTH_PASSWORD if you want to pass
a precomputed password hash. Set WEB_AUTH_TOTP_SECRET to require 2FA for the
bootstrap admin from the first login.
After logging in as the admin user, open Users in the web UI to create more
users, reset passwords, enable or reset 2FA, grant admin access, or disable
accounts. Each authenticated username gets its own isolated workspace under
/opt/data/users/<user>/.
After login, open Passkeys to enroll a passkey for the current user.
For production passkeys behind a real domain, set WEB_AUTH_ORIGIN and
WEB_AUTH_RP_ID to match the public HTTPS origin and relying-party ID.
Telegram support is optional. Set TELEGRAM_BOT_TOKEN and
TELEGRAM_ALLOWED_CHAT_ID to enable it. Users can manage recurring schedules
with /schedule, one-time reminders with /remind, list work with /jobs,
and remove work with /unschedule.
Agent sessions can create schedules and reminders directly through the bundled
orchestrator-jobs CLI instead of asking the human to type a command.
Run the tick endpoint once per minute from cron, Kubernetes CronJob, or another scheduler:
curl -fsS -X POST -H "X-API-Key: $ORCHESTRATOR_API_KEY" \
http://localhost:8765/jobs/tickcodexmust be onPATH, or setCODEX_BIN=/path/to/codex.- Each orchestrator user gets isolated
HOMEandCODEX_HOMEunder/opt/data/users/<user>/. - Seed auth/config into
/opt/data/users/<user>/.codex/before using the service in a container or Kubernetes. - Default model can be set with
CODEX_DEFAULT_MODEL. - Default sandbox can be set with
CODEX_SANDBOX=workspace-write.
Codex ChatGPT login tokens (/opt/data/users/<user>/.codex/auth.json) auto-
refresh when the CLI runs, but an idle pod never triggers the refresh and
tokens expire. A background warmer in sessions.oauth_warmer_loop checks each
user's auth.json every 30 minutes and fires a no-op session when
expires_at is within 1 hour.
To bootstrap a fresh pod, seed creds from your host:
kubectl -n codex-orchestrator cp ~/.codex/auth.json \
codex-orchestrator/$(kubectl -n codex-orchestrator get pod \
-l app.kubernetes.io/name=codex-orchestrator \
-o jsonpath='{.items[0].metadata.name}'):/opt/data/users/<user>/.codex/auth.jsonModels with the :cloud suffix (glm-5.1:cloud, deepseek-v4-flash:cloud,
etc.) route through an in-pod Ollama daemon sidecar, which proxies to Ollama
Cloud via your signed-in account. The sidecar listens on 127.0.0.1:11434.
When a :cloud model is selected, the orchestrator writes an ollama-launch
profile to ~/.codex/config.toml and passes --profile ollama-launch -m <model> to the codex CLI (env OPENAI_API_KEY=ollama).
The ed25519 signing key at /root/.ollama/id_ed25519 (PVC subPath
ollama-state) survives pod restarts, so this is a one-time step per
orchestrator deployment:
kubectl -n codex-orchestrator exec -it -c ollama \
deploy/codex-orchestrator -- ollama signinThe command prints a https://ollama.com/connect?... URL. Open it in a
browser, approve, and the command unblocks.
kubectl -n codex-orchestrator exec deploy/codex-orchestrator -c orchestrator -- \
curl -sS http://127.0.0.1:11434/v1/chat/completions \
-H 'content-type: application/json' -H 'authorization: Bearer ollama' \
-d '{"model":"glm-5.1:cloud","max_tokens":20,"messages":[{"role":"user","content":"hi"}]}'A 200 with "role":"assistant" content confirms the daemon → Ollama Cloud
path is wired. Then in Telegram:
/model glm-5.1:cloud
/new
<prompt>
Telegram supports both recurring jobs and one-time reminders:
/schedule "0 9 * * *" Check postgres backup status on the k3s cluster
/remind "2026-05-18 18:00" "America/Fortaleza" Check the backup report
Agents can also create them directly from natural language. Recurring jobs use
UTC cron expressions. One-time reminders store a UTC run_at timestamp and
disable themselves after dispatch.
Inside the pod, agents can use the helper CLI:
orchestrator-jobs create --chat-id 19401922 --user linard --cron "0 9 * * *" --prompt "Check disk usage"
orchestrator-jobs remind --chat-id 19401922 --user linard --at "2026-05-18 18:00" --timezone "America/Fortaleza" --prompt "Meeting with Moises"
orchestrator-jobs list --chat-id 19401922
orchestrator-jobs delete --chat-id 19401922 --id 4All endpoints accept either an authenticated browser session cookie
(orchestrator_session, set by /auth/login) or an X-API-Key header /
?api_key= query string. Service-to-service callers using X-API-Key
must also send X-User: <slug> to scope the request.
| Method | Path | Auth | Notes |
|---|---|---|---|
| GET | /auth/status |
cookie (optional) | returns {authenticated, user, is_admin, totp_required} |
| POST | /auth/login |
body {username, password, totp?} |
sets orchestrator_session cookie. Rate-limited: 5/60s per source IP. |
| POST | /auth/logout |
none | clears the session cookie |
| GET | /auth/passkeys |
cookie/api-key | list passkeys for the current user |
| DELETE | /auth/passkeys/{credential_id} |
cookie/api-key | remove a passkey owned by the current user |
| POST | /auth/passkeys/register/options |
cookie | start WebAuthn registration challenge |
| POST | /auth/passkeys/register/verify |
cookie | finish WebAuthn registration |
| POST | /auth/passkeys/login/options |
body {username?} |
start WebAuthn login challenge |
| POST | /auth/passkeys/login/verify |
challenge | finish WebAuthn login, sets cookie. Rate-limited: 5/60s per source IP. |
| GET | /auth/users |
admin | list web users |
| POST | /auth/users |
admin | create a web user (optional TOTP secret returned once) |
| PATCH | /auth/users/{username} |
admin | update password / admin / disabled / reset TOTP |
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /sessions |
cookie/api-key | body {cwd?, system_prompt?, permission_mode, allowed_tools[], max_turns?, model?}. Cwd defaults to /opt/data/users/<user>/workspace. |
| GET | /sessions |
cookie/api-key | list active sessions owned by the caller |
| DELETE | /sessions/{sid} |
cookie/api-key | stop and delete persisted metadata |
| POST | /sessions/{sid}/query |
cookie/api-key | NDJSON stream of events: text, tool, done |
| Method | Path | Auth | Notes |
|---|---|---|---|
| POST | /jobs/tick |
api-key | Internal-only. Caller source IP must be in INTERNAL_CIDRS (default 10.42.0.0/16,10.43.0.0/16,127.0.0.0/8). Called by the cluster CronJob. Returns {fired:[job_id...], checked_at}. |
- Web auth uses an
HttpOnly,Secure,SameSite=Laxcookie signed withWEB_SESSION_SECRET(defaults toORCHESTRATOR_API_KEYif unset). TTL controlled byWEB_SESSION_TTL_SECONDS(default 12h). LOGIN_RATE_LIMITandLOGIN_RATE_WINDOWenv vars tune the brute-force guard. Rate-limit key is the leftmostX-Forwarded-Forentry, falling back to the TCP peer.INTERNAL_CIDRS(comma-separated CIDRs) overrides the default internal allow-list for/jobs/tick.X-API-Keyis effectively a root token: any holder can impersonate any user viaX-User. Treat it as a service credential, not a user token.
This uses codex exec --json and codex exec resume under the hood. It does
not use a Python SDK client because the local Codex installation exposes a CLI
interface for this workflow.