Yet Another Codex Web UI β a focused, editorial web front-end for Codex, with a Gleam (BEAM/OTP) backend and a Svelte SPA front-end.
It talks to Codex over the app-server protocol
(JSON-RPC 2.0 over stdio) and keeps no database of its own β multi-session
state lives entirely in Codex's own persistent sessions, read back via
thread/list / thread/read and continued via thread/resume.
- π₯οΈ Warm, light-only workspace with readable conversation and tool-output surfaces
- π§΅ Multi-session: list, create (in a chosen working directory), switch, and resume Codex threads
- π In-use detection: warns before opening a session another codex instance already has loaded (so two processes don't corrupt one conversation)
- β‘ Live streaming of agent messages, reasoning, command runs, file changes & plans
- π€ Multi-agent visibility: sub-agents spawned by a session appear as link-style buttons in the header (max 5, more behind a menu; inside Session details on mobile), each opening that agent's live transcript read-only
- β¨οΈ Slash commands mirroring the Codex TUI (see below)
- β¨οΈ Up/Down message history in the composer, with the Codex TUI's shell-style recall semantics (a resumed thread seeds history from its prior prompts)
- π Read-only file browser rooted at the session's working directory, with a Monaco viewer (lazy-loaded from jsDelivr, never vendored) and clickable file-change paths in the transcript
- Β± Read-only Git changes inspector with All, Staged, and Unstaged scopes, lazy unified diffs, and responsive file-to-diff navigation
- ποΈ Per-session codex profiles: pick a
$CODEX_HOME/<name>.config.tomlwhen creating a session (or with/profile) - π Alternative backends:
YACWU_BACKENDSregisters other local app-server commands (e.g. claude-codex) that appear in the host picker alongside the default local codex - π One
codex app-serverconnection per machine, multiplexed; events fan out to the browser via Server-Sent Events - π Remote machines over SSH: pick any concrete
Hostalias from~/.ssh/configwhen creating a session. yacwu bootstraps a persistent app-server on the remote machine (--listen unix://+ streamlocal forwarding β no TCP ports), and reconnects with backoff after SSH drops or yacwu restarts while remote turns keep running. Full feature parity: the file browser, Git viewer, images, profiles, and in-use detection all operate on the remote machine. See docs/remote.md - ποΈ No storage layer β Codex is the source of truth
Type these in the composer (anything not starting with / is a normal model turn):
| command | action |
|---|---|
/status |
show account, rate limits & session info |
/model |
show the current model, reasoning effort, and available choices |
/model <model> [effort] |
change the model and optional reasoning effort (--effort <effort> changes effort only) |
/profile |
show the session's codex profile and the available choices |
/profile <name> / /profile clear |
switch this session to a profile / back to the base config |
/goal <objective> |
set the thread goal (--budget N to add a token budget) |
/goal / /goal clear |
show / clear the current goal |
/compact |
compact conversation history |
/review [notes] |
review uncommitted changes (or run a custom review) |
/shell <command> |
run a user-initiated shell command in the thread |
/rollback [turns] |
roll back the last N turns (default 1) |
/fork |
branch this thread into a new session |
/btw [question] |
start an ephemeral side conversation (a throwaway fork that treats inherited history as read-only context; nested under its parent in the sidebar) |
/archive |
archive this session |
/help |
list the commands |
- Gleam β₯ 1.18 with Erlang/OTP β₯ 27 (and
rebar3onPATHfor compiling one transitive Erlang dependency) β.tool-versionspins the versions for asdf users - Bun β₯ 1.3 (builds the web UI)
codexCLI onPATH, already authenticated (codex login)
bun install
bun run build # build the web UI into ./build
YACWU_INSECURE_SKIP_AUTH=1 bun run start # serve UI + API on http://127.0.0.1:3000bun run start runs the Gleam server (server/), which serves the static UI
build, the REST/SSE API, and spawns/manages one codex app-server process
(plus one per configured alternative backend). The server fails closed: it refuses to start unless authentication
is configured (see Authentication) or
YACWU_INSECURE_SKIP_AUTH=1 explicitly opts into running open, as above for
a localhost-only setup. The listening address is configurable:
cd server
gleam run -- 0.0.0.0:8080 # positional host:port
gleam run -- --host 0.0.0.0 --port 8080
gleam run -- --unix /run/yacwu.sock # Unix domain socket instead of TCP
gleam run -- --helpThe server accepts -H/--host, -p/--port, a positional host:port, or
--unix <path> (CLI flags override the HOST/PORT env vars). YACWU_CWD
selects the working directory for new sessions; YACWU_STATIC points at the
web UI build directory (default ./build, relative to where the server runs β
the bun run scripts set it for you). Set YACWU_DEBUG=1 for a per-request
timing log on stderr.
Anything that speaks the codex app-server protocol over stdio can stand in
for codex app-server β for example
claude-codex, which serves
the protocol backed by Claude Code. Declare such backends with
YACWU_BACKENDS, semicolon-separated name=command entries:
YACWU_BACKENDS="claude=node /opt/claude-codex/dist/src/adapter.mjs" bun run startEach name appears in the host picker alongside the default local codex
(local) and the SSH remotes, and runs its command as a child process on
this machine β same working directory, file browser, Git viewer, profiles and
in-use detection as the default. Sessions started on a backend are listed and
resumed through that backend, and merge into the session rail with everyone
else's. The command is split on whitespace (no quoting) and resolved via
/usr/bin/env, so bare program names use PATH and absolute paths work
as-is. Names must be URL-safe (letters, digits, .-_@) and may not be
local; a backend name shadows an identical ~/.ssh/config alias.
For a deployable artifact, cd server && gleam export erlang-shipment
produces a self-contained BEAM release (needs only Erlang on the target), and
bun run build supplies the static build/ directory to serve next to it.
./scripts/build-release.sh # produces dist/yacwu-<version>.tar.gzUses the local toolchain (no Docker): the backend becomes a single-file
escript (gleam export escript) bundled with the web UI build and a yacwu
launcher. The result is portable BEAM bytecode β the target machine needs a
compatible Erlang/OTP (β₯ the version pinned in .tool-versions) and the
codex CLI, but no Gleam or Bun:
tar -xzf yacwu-1.0.0.tar.gz
./yacwu/yacwu 0.0.0.0:8080./scripts/build-appimage.sh # produces dist/yacwu-<architecture>.AppImageRequires Docker (or podman with the docker CLI shim). The whole build runs in
an Ubuntu 20.04 (glibc 2.31) container so the resulting AppImage works on
distros at least that old: it pulls the prebuilt Erlang/OTP for that distro
from hex.pm's build service (the same builds erlef/setup-beam uses), exports
the backend with gleam export erlang-shipment, and bundles the Erlang
runtime, the web UI build, and the non-glibc shared libraries the runtime
needs (libssl, libtinfo, β¦). Both x86_64 and aarch64 Docker hosts are
supported; the output uses the corresponding architecture in its filename.
./dist/yacwu-$(uname -m).AppImage --help
./dist/yacwu-$(uname -m).AppImage 0.0.0.0:8080The AppImage accepts the same flags/env as the server. It still needs the
codex CLI on PATH at runtime (and libfuse2, like any AppImage β or run
it with --appimage-extract-and-run).
bun install
YACWU_INSECURE_SKIP_AUTH=1 bun run dev:server
# Gleam API backend on http://127.0.0.1:3000
bun run dev # Vite dev server on http://127.0.0.1:5173 (proxies /api)The front-end dev server proxies /api/* (including the SSE stream) to the
backend, so edit Svelte code with hot reload while the Gleam server runs
unchanged. Set YACWU_API to proxy to a backend on a different address.
Two mechanisms, usable separately or together. The server fails closed: with
neither configured it refuses to start (and rejects every request with 403)
unless YACWU_INSECURE_SKIP_AUTH=1 explicitly opts into running without
authentication β only do that on a trusted network, e.g. bound to localhost.
Put yacwu behind a reverse proxy that authenticates users and injects a
Remote-User header (Authelia, Traefik forward-auth, oauth2-proxy, β¦). Pass
an allowlist to require that header:
cd server
gleam run -- --remote-user alice,bob # or: YACWU_REMOTE_USERS=alice,bobWhen enabled, every request must carry Remote-User: <user> matching one of the
listed users β otherwise it's rejected (401 if the header is missing, 403 if
the user isn't allowed). This is enforced for pages, the API, and static assets.
When unset, forward auth is disabled.
yacwu can also authenticate users itself against an OAuth 2.0 / OpenID Connect provider (authorization code flow with PKCE), so no authenticating proxy is needed:
cd server
YACWU_OAUTH_ISSUER=https://auth.example.com \
YACWU_OAUTH_CLIENT_ID=yacwu \
YACWU_OAUTH_CLIENT_SECRET=... \
YACWU_OAUTH_USERS=alice,bob \
gleam runRegister https://<your-yacwu-host>/oauth/callback as the redirect URI with
the provider. Unauthenticated page loads bounce to the provider's login page;
after the callback the user stays signed in via an HMAC-signed yacwu_session
cookie β stateless, like everything else in the server. API requests without a
valid session get a plain 401. /oauth/logout signs out.
| variable | meaning |
|---|---|
YACWU_OAUTH_ISSUER |
OIDC issuer; endpoints found via /.well-known/openid-configuration |
YACWU_OAUTH_AUTH_URL / YACWU_OAUTH_TOKEN_URL |
explicit endpoints (override discovery; both required when no issuer is set) |
YACWU_OAUTH_USERINFO_URL |
userinfo endpoint, used when the provider issues no usable id_token |
YACWU_OAUTH_CLIENT_ID / YACWU_OAUTH_CLIENT_SECRET |
client credentials registered with the provider |
YACWU_OAUTH_SCOPES |
requested scopes (default openid profile email) |
YACWU_OAUTH_USER_CLAIM |
claim holding the user identity (default: first of preferred_username, email, login, sub) |
YACWU_OAUTH_USERS |
comma-separated identity allowlist; empty admits any authenticated user |
YACWU_OAUTH_REDIRECT_URL |
explicit callback URL, when the one derived from X-Forwarded-Proto / X-Forwarded-Host / Host is wrong |
YACWU_OAUTH_COOKIE_SECRET |
cookie-signing secret; auto-generated per process when unset (sessions then survive only until a restart) |
YACWU_OAUTH_SESSION_TTL |
session lifetime in seconds (default 604800, 7 days) |
Plain OAuth 2.0 providers without OIDC work via explicit endpoints β GitHub, for example:
YACWU_OAUTH_AUTH_URL=https://github.com/login/oauth/authorize \
YACWU_OAUTH_TOKEN_URL=https://github.com/login/oauth/access_token \
YACWU_OAUTH_USERINFO_URL=https://api.github.com/user \
YACWU_OAUTH_SCOPES=read:user \
YACWU_OAUTH_CLIENT_ID=... YACWU_OAUTH_CLIENT_SECRET=... \
YACWU_OAUTH_USERS=octocat \
gleam runWhen both mechanisms are configured, a valid Remote-User header or a valid
session cookie admits the request; anything else is sent through the OAuth
login.
bun run check # svelte-check / TypeScript (front-end)
bun run test:unit # front-end unit tests (bun test)
bun run test:server # backend unit tests (gleeunit)
bunx playwright test # end-to-end verification (builds the UI, runs the Gleam server, drives the live app)browser ββHTTP/SSEββ> Gleam server (mist, server/)
β server/src/yacwu/codex.gleam (JSON-RPC manager actor)
βββstdioββ> codex app-server ββ> Codex sessions on disk
server/src/yacwu/codex.gleamβ OTP actor owning the singlecodex app-serverport: correlates request/response ids, broadcasts notifications to SSE subscribers, respawns the process if it exits.server/src/yacwu/router.gleamβ thin REST/SSE endpoints over the protocol (/api/threads,/api/threads/[id]/open|message|interrupt,/api/events), plus static serving of the built SPA with anindex.htmlfallback.server/src/yacwu/oauth.gleamβ the built-in OAuth/OIDC login: endpoint discovery, PKCE, the code-for-token exchange, and the signed session cookies backing it (no server-side session storage).server/src/yacwu/session_lock.gleamβ detects whether another codex process has a session's rollout file open before we resume it.server/src/yacwu/model_state.gleamβ per-thread model/effort overrides and the model catalog.src/routes/+layout.svelteβ the terminal-style UI; routes streamed events to the right session bythreadId.
The backend is pure Gleam: interop with the VM (spawning the codex port,
/proc symlink reads, Unix sockets) goes through typed @external bindings
to Erlang built-ins β no Erlang source files, no NIFs.
Threads run in "yolo" mode β approvalPolicy: "never" and
sandbox: "danger-full-access" β so the web UI never blocks on an interactive
approval prompt and commands run with full access.
codex profiles are $CODEX_HOME/<name>.config.toml files layered over the
base config β but codex only applies them via the --profile CLI flag, which
doesn't work with codex app-server, and the app-server protocol has no
profile parameter. yacwu emulates the layering per-thread: the chosen
profile file is parsed and passed as the generic config override map on
thread/start / thread/resume (plus the profile's model/effort on
turn/start). Explicit request params beat that map, so yacwu's forced
approvalPolicy: "never" survives any profile, and an explicit /model
override wins over the profile's model.
Profile files are re-read from disk on every request β nothing is cached, so
edits take effect immediately. Which profile a session uses is remembered
in-memory; after a server restart it is re-inferred as the profile whose
model matches the session's current model, if any.
codex keeps an open file descriptor on a session's rollout .jsonl for as long
as the thread is loaded (resumed) β even while idle. Before resuming, the open
endpoint checks whether any process (outside our own app-server's process tree)
holds that path open; if one exists it returns 409 and the UI shows a warning
with the offending process so you can cancel or "open anyway".
The scan is platform-specific: on Linux it reads /proc/*/fd symlinks; on
OpenBSD (which has no /proc) it runs fstat(1) on the rollout file and
ps for the process tree. Other platforms degrade to no detection rather
than blocking.
--unix <path> accepts connections on a Unix domain socket and relays them
byte-for-byte to the HTTP listener bound on a loopback ephemeral port (mist
itself only speaks TCP). HTTP, SSE, and auth headers/cookies pass through
unchanged.
MIT