Remote-friendly Codex web workspace for continuing sessions from desktop and mobile browsers.
- Python 3.12+
uv- Node.js 20+
pnpm
Backend dependencies:
uv syncCodex workspace support uses the published codex-ipc package from PyPI.
Frontend dependencies:
cd web
pnpm installThis app now supports password protection for deployed environments.
Enable auth with either:
YIER_AUTH_PASSWORDYIER_AUTH_PASSWORD_HASH
Plain password example:
export YIER_AUTH_PASSWORD='change-this-password'Hashed password example:
uv run python -c "from yier_web.auth import hash_password; print(hash_password('change-this-password'))"
export YIER_AUTH_PASSWORD_HASH='paste-generated-hash-here'Optional auth settings:
YIER_AUTH_SECRET: optional extra signing secret for session cookiesYIER_AUTH_SESSION_TTL_HOURS: cookie lifetime in hours, default is168YIER_CODEX_EMBED_TOKEN: token for unauthenticated Codex iframe access
If neither password variable is set, authentication is disabled.
Yier exposes a chat-only Codex iframe at /codex/embed. The iframe does not
show the Codex session list or the main app navigation.
Set an embed token on the Yier server:
export YIER_CODEX_EMBED_TOKEN='change-this-embed-token'Embed the chat frame with only the token in the URL:
<iframe
id="codex-frame"
src="http://127.0.0.1:9999/codex/embed?embed_token=change-this-embed-token"
style="width: 100%; height: 720px; border: 0"
></iframe>All operational parameters are sent with postMessage; the iframe URL only carries
embed_token. Start a thread, set plan mode, create a goal, and send an initial prompt:
const frame = document.querySelector('#codex-frame')
frame.contentWindow.postMessage(
{
type: 'yier:codex-start',
cwd: '/Users/me/project',
mode: 'plan',
goal: {
objective: 'Finish the migration',
tokenBudget: 12000,
},
prompt: 'Inspect this project',
},
'http://127.0.0.1:9999',
)Resume an existing Codex thread:
frame.contentWindow.postMessage(
{
type: 'yier:codex-resume',
threadId: 'thread-id-here',
mode: 'plan',
},
'http://127.0.0.1:9999',
)mode, goal, and prompt are optional on both start and resume. The iframe
selects the thread, applies mode and goal, then sends the prompt. cwd is resolved
by the backend as the Codex working directory. Optional commandId values are
echoed in yier:codex-command-result responses.
The parent can also send these commands after a thread is active:
yier:codex-send-prompt:promptplus optional model, reasoning, attachment, and permission fieldsyier:codex-steer-prompt:promptyier:codex-enqueue-followup:promptyier:codex-remove-followup:messageIdyier:codex-interrupt-turnyier:codex-compact-threadyier:codex-set-mode:mode(buildorplan)yier:codex-set-goal:objective, optionaltokenBudgetyier:codex-update-goal-status:statusyier:codex-clear-goalyier:codex-submit-user-input:requestId,responseyier:codex-rename-thread:nameyier:codex-archive-threadyier:codex-fork-thread
If authentication is enabled, the iframe page is public, but the Codex WebSocket
still requires a valid embed_token unless the browser already has an
authenticated Yier session.
The iframe posts lifecycle messages to its parent window:
window.addEventListener('message', (event) => {
if (event.data?.type === 'yier:codex-ready') {
console.log('Codex iframe is ready')
}
if (event.data?.type === 'yier:codex-thread-created') {
console.log(event.data.threadId, event.data.cwd, event.data.mode)
}
if (event.data?.type === 'yier:codex-thread-resumed') {
console.log(event.data.threadId, event.data.cwd, event.data.mode)
}
if (event.data?.type === 'yier:codex-prompt-sent') {
console.log(event.data.threadId, event.data.cwd, event.data.mode)
}
if (event.data?.type === 'yier:codex-command-result') {
console.log(event.data.commandId, event.data.command, event.data.ok)
}
if (event.data?.type === 'yier:codex-turn-state') {
console.log(event.data.threadId, event.data.turn)
}
if (event.data?.type === 'yier:codex-goal-state') {
console.log(event.data.threadId, event.data.goal, event.data.completedGoal)
}
if (event.data?.type === 'yier:codex-mode-changed') {
console.log(event.data.threadId, event.data.mode)
}
if (event.data?.type === 'yier:codex-user-input-request') {
console.log(event.data.threadId, event.data.request)
}
if (event.data?.type === 'yier:codex-error') {
console.error(event.data.message)
}
})The iframe also emits the compatibility event yier:codex-status and the queue
event yier:codex-followups-changed. Goal completion is reported by
yier:codex-goal-state; turn completion is reported independently by
yier:codex-turn-state.
Development mode is different from production:
- The backend should run with
--debug - The frontend should run with Vite dev server
- In this mode, the backend proxies frontend requests to
http://127.0.0.1:5173
Recommended one-command startup:
uv run yier-devThis starts:
- frontend:
pnpm dev - backend: debug mode with reload
If you prefer split terminals:
Frontend only:
uv run yier-dev-webBackend only:
uv run yier-dev-backendYou can also override backend bind settings:
uv run yier-dev --host 127.0.0.1 --port 9999
uv run yier-dev-backend --host 127.0.0.1 --port 9999Default address:
- App:
http://127.0.0.1:9999 - Vite dev server:
http://127.0.0.1:5173
Notes:
- Keep
pnpm devrunning, otherwise the backend cannot proxy the frontend in debug mode. - API requests still go through the Python server at port
9999.
Production mode does not use the Vite dev server.
You must build the frontend first:
uv run yier-build-webThen start the backend without --debug:
uv run yier-prodIn production mode:
- The backend serves
web/dist - No Vite proxy is used
- Authentication should usually be enabled with
YIER_AUTH_PASSWORDorYIER_AUTH_PASSWORD_HASH
Production example:
export YIER_AUTH_PASSWORD='change-this-password'
uv run yier-build-web
uv run yier-prod --host 0.0.0.0 --port 9999Backend tests:
uv run pytestTargeted backend tests:
uv run pytest tests/test_codex_backend.py tests/test_codex_workspace.py tests/test_app.pyBackend compile check:
uv run python -m compileall yier_webFrontend unit tests:
cd web
pnpm test:unitFrontend type check:
cd web
pnpm type-checkFrontend production build:
uv run yier-build-webDevelopment:
uv run yier-devProduction:
export YIER_AUTH_PASSWORD='change-this-password'
uv run yier-build-web
uv run yier-prod --host 0.0.0.0 --port 9999Development:
uv run yier-dev: start frontend and backend togetheruv run yier-dev-web: start Vite onlyuv run yier-dev-backend: start backend only in debug mode
Production:
uv run yier-build-web: build frontend assetsuv run yier-prod: start backend in production mode