A local, OpenAI-compatible HTTP relay that drives your own muse.ai web chat through a real Chromium tab over the Chrome DevTools Protocol. It exposes /v1/chat/completions, /v1/models, /v1/images/generations, /v1/videos, and a small admin API — so any OpenAI client (opencode, the OpenAI SDK, LangChain, plain curl) can talk to muse.ai without an official API key.
For Educational Purposes Only. See Disclaimer.
- What this is
- What this is not
- Requirements
- Quick start
- Manual install
session.json- Verify the relay
- Connect opencode
- Connect the OpenAI SDK / curl
- API reference
- Architecture
- Configuration
- Account pool
- Latency, honestly
- Troubleshooting
- Known quirks
- Security model
- Disclaimer
- A relay, not a scraper library. It runs a FastAPI server on
127.0.0.1and translates OpenAI request shapes into real browser interactions. - Cookie-based. Authentication is the
hatch_sess/hatch_gw/hatch_native_auth_devicecookies from a normal muse.ai login, stored insession.json. - Browser-driven. A Chromium instance is launched (or attached to) with remote debugging enabled. Prompts are typed into the composer, sent, and the assistant's reply bubbles are polled and streamed back as SSE.
- Multi-account capable.
session.jsoncan hold several accounts; the pool handles rotation, cooldowns, and failover. - Media capable. Image generation, image edits (not implemented upstream), and video tasks are routed through the same browser session.
client → POST /v1/chat/completions
→ gateway.py (pick account, resolve model, build prompt)
→ prompt.py (flatten OpenAI messages into one text block)
→ driver.py (attach CDP → open/reuse thread → type → send)
→ muse.ai web chat (upstream generation — the slow part)
→ DOM polling (read bubbles, detect completion)
→ SSE chunks back to client
- Not an official muse.ai product, SDK, or API. muse.ai has no public API; this wraps the web UI.
- Not a way to bypass authentication. It reuses a legitimate login cookie you already own.
- Not a public or multi-user service. It binds
127.0.0.1only, and the auth dependency is deliberately a no-op. - Not a scraper that fights the UI. It types like a human and reads the rendered DOM.
- Not fast. The upstream model does the work; this relay adds a few seconds of overhead on top.
| Requirement | Notes |
|---|---|
| Windows 10/11 | Only tested OS. PowerShell 5.1+ or 7+. |
| Python 3.10+ | 3.11/3.12 recommended. |
| Chrome or Edge | Auto-detected by drivers/browser/chromium.py. Override with MUSE2API_CHROMIUM_PATH. |
| muse.ai account | Must have webchat access. |
| ~200 MB disk | venv + browser profile + data/ media cache. |
Not required: Docker, Node.js, Redis, Postgres, any database. All state is local JSON under data/.
Set-Location D:\project\muse-opencode
.\start.ps1start.ps1 does the following, in order:
Set-Locationto its own directory.- Sets
PYTHONPATHtosrc(the package is not pip-installed). - Creates
.venvand installs dependencies if.venvis missing. - Warns if
session.jsonis missing. - Frees port
20233from any stale listener. - Runs
python -m museai.
Expected output:
muse-opencode -> http://127.0.0.1:20233/v1 (model: muse-chat)
INFO: Started server process [12345]
INFO: Uvicorn running on http://127.0.0.1:20233 (Press CTRL+C to quit)
The first chat request will launch Chromium (headless by default) and may take 20–40 seconds. Subsequent requests reuse a warm page.
The repo is not installable via pip install museai. pyproject.toml exists for metadata and tooling only; the code runs from src via PYTHONPATH.
Set-Location D:\project\muse-opencode
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
.\.venv\Scripts\python.exe -m pip install fastapi "uvicorn[standard]" pydantic pydantic-settings "httpx[socks]" websockets
$env:PYTHONPATH = "src"
.\.venv\Scripts\python.exe -m museai| Package | Used for |
|---|---|
fastapi |
HTTP routing, request validation, SSE. |
uvicorn[standard] |
ASGI server. |
pydantic |
Request/response schemas. |
pydantic-settings |
MUSE2API_* env + .env loading. |
httpx[socks] |
Session renewal calls (with SOCKS proxy support). |
websockets |
Raw CDP WebSocket transport. |
Start-Process -FilePath ".\.venv\Scripts\python.exe" `
-ArgumentList "-m","museai" `
-WorkingDirectory "D:\project\muse-opencode" `
-RedirectStandardOutput "D:\temp\opencode\relay.log" `
-RedirectStandardError "D:\temp\opencode\relay.err" `
-WindowStyle HiddenTo stop it without killing your normal browser, match on the command line:
Get-CimInstance Win32_Process |
Where-Object { $_.CommandLine -like "*-m museai*" } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }This file is required. Without it, /v1/chat/completions returns 503.
Create session.json in the repo root:
{
"accounts": [
{
"label": "local",
"cookies": {
"hatch_sess": "eyJhbGciOi...",
"hatch_gw": "...",
"hatch_native_auth_device": "..."
}
}
]
}- Open Chrome and log in to https://muse.ai.
- Press
F12→ Application tab → Storage → Cookies →https://muse.ai. - Copy the values of
hatch_sess,hatch_gw, andhatch_native_auth_device. - Paste them into
session.json. - Restart the relay.
Add more objects to the accounts array. Give each a unique label:
{
"accounts": [
{ "label": "main", "cookies": { "hatch_sess": "...", "hatch_gw": "...", "hatch_native_auth_device": "..." } },
{ "label": "alt", "cookies": { "hatch_sess": "...", "hatch_gw": "...", "hatch_native_auth_device": "..." } }
]
}Cookies expire. Symptoms:
- Replies come back empty or truncated.
/v1/chat/completionsreturns503.- Driver logs show a login page instead of the composer.
Fix: grab fresh cookie values, rewrite session.json, restart. MUSE2API_KEEPALIVE_ENABLED=true can renew sessions automatically, but it is off by default and imperfect.
session.jsonis a live credential. It is gitignored. Nevergit add -fit, never paste its contents into a log, an issue, or a chat.
# health
(Invoke-WebRequest http://127.0.0.1:20233/healthz).Content
# → {"ok":true,"version":"0.1.0"}
# models
(Invoke-WebRequest http://127.0.0.1:20233/v1/models).Content
# a real chat
$body = '{"model":"muse-chat","messages":[{"role":"user","content":"Say hello in five words."}]}'
Invoke-RestMethod -Uri http://127.0.0.1:20233/v1/chat/completions `
-Method Post -ContentType 'application/json' -Body $body# admin: account state
Invoke-RestMethod http://127.0.0.1:20233/admin/accounts
# admin: driver + pool status
Invoke-RestMethod http://127.0.0.1:20233/admin/statusAdd a provider to C:\Users\<you>\.config\opencode\opencode.json:
{
"provider": {
"muse-opencode": {
"npm": "@ai-sdk/openai-compatible",
"name": "Muse OpenCode",
"options": {
"baseURL": "http://127.0.0.1:20233/v1",
"apiKey": "dummy"
},
"models": {
"muse-chat": { "name": "Muse chat" }
}
}
}
}Then select muse-opencode/muse-chat as your model.
Two important notes:
apiKeyis ignored.api/deps.pyreturnsNoneunconditionally — loopback binding is the real access control.- Do not point opencode's
small_modelat this provider. Title generation fires an extra chat request per conversation, which costs 25+ seconds and hijacks the browser tab mid-work.
$body = '{"model":"muse-chat","messages":[{"role":"user","content":"Explain CDP in one sentence."}]}'
curl.exe -s -X POST http://127.0.0.1:20233/v1/chat/completions `
-H "Content-Type: application/json" `
--data-binary $bodyFor large payloads, write the JSON to a file and use --data-binary "@file.json" to avoid shell quoting issues.
$body = '{"model":"muse-chat","messages":[{"role":"user","content":"Count to five."}],"stream":true}'
curl.exe -N -X POST http://127.0.0.1:20233/v1/chat/completions `
-H "Content-Type: application/json" `
--data-binary $bodyfrom openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:20233/v1", api_key="dummy")
resp = client.chat.completions.create(
model="muse-chat",
messages=[{"role": "user", "content": "Say hello in five words."}],
)
print(resp.choices[0].message.content)| Method | Path | Notes |
|---|---|---|
POST |
/v1/chat/completions |
stream supported. Only user turns are forwarded. |
GET |
/v1/models |
Returns muse-chat and muse-image. |
POST |
/v1/images/generations |
n bounded 1..4. |
POST |
/v1/images/edits |
Returns FeatureNotImplemented. |
POST |
/v1/videos |
Creates a background video task. |
GET |
/v1/videos/{task_id} |
Poll task state. |
GET |
/v1/media/{name} |
Serve cached media bytes. |
POST |
/v1/responses |
Stub; not implemented. |
| Method | Path | Notes |
|---|---|---|
GET |
/admin/status |
Version, driver, capabilities, pool snapshot. |
GET |
/admin/accounts |
Returns {"data": [...]} — parse .data. |
POST |
/admin/accounts |
Add an account. |
PATCH |
/admin/accounts/{id} |
Update label / cookies / state. |
DELETE |
/admin/accounts/{id} |
Remove an account. |
POST |
/admin/accounts/{id}/reset |
Clear failures and cooldown. |
POST |
/admin/accounts/{id}/renew |
Attempt session renewal. |
GET |
/admin/tasks |
List media/video tasks. |
POST |
/admin/tasks/{task_id}/cancel |
Cancel a running task. |
| Method | Path | Notes |
|---|---|---|
GET |
/healthz |
{"ok":true,"version":"0.1.0"} |
muse-opencode/
├── start.ps1 launcher: venv, PYTHONPATH, port cleanup
├── pyproject.toml metadata + tooling only (not pip-installed)
├── session.json YOUR cookies — gitignored, required
├── data/ runtime state (auto-created)
│ ├── accounts.json pool state
│ ├── tasks.json media/video task records
│ ├── api_key generated key (unused on loopback)
│ ├── media/ cached images and videos
│ └── browser-profile/ Chromium user-data-dir
└── src/museai/
├── __main__.py entrypoint: `python -m museai`
├── app.py FastAPI app assembly
├── config.py Settings — all MUSE2API_* vars
├── errors.py typed exceptions → HTTP status
├── log.py logging setup
├── api/
│ ├── deps.py auth deps (deliberate no-ops on loopback)
│ ├── schemas.py request/response models
│ └── routes/
│ ├── chat.py /v1/chat/completions
│ ├── models.py /v1/models
│ ├── images.py /v1/images/*
│ ├── videos.py /v1/videos*
│ ├── media.py /v1/media/{name}
│ ├── responses.py /v1/responses (stub)
│ ├── admin.py /admin/*
│ └── health.py /healthz
├── core/
│ ├── prompt.py flatten_messages(), message_turns(), strip_harness_noise()
│ ├── media.py media caching + URL building
│ └── models.py model registry and availability
├── drivers/
│ ├── base.py Driver protocol
│ ├── registry.py driver name → class
│ ├── mock.py offline driver for tests
│ ├── http/driver.py unused stub
│ └── browser/
│ ├── chromium.py browser discovery + launch
│ ├── cdp.py CDP WebSocket client
│ ├── dom.py DOM selectors and bubble parsing (a FILE)
│ └── driver.py the real driver: type, send, poll, stream
├── accounts/
│ ├── model.py Account dataclass + is_available()
│ ├── pool.py acquire(), cooldowns, failover
│ ├── store.py load/save accounts.json
│ └── keepalive.py optional session renewal loop
├── services/
│ ├── container.py dependency container
│ ├── gateway.py request orchestration + retry policy
│ └── tasks.py background media/video tasks
└── upstream/
└── muse.py muse.ai endpoint constants
- Prompt flattening. OpenAI messages are flattened into a single text block. With one
userturn, the text is sent verbatim. With two or more, each turn becomes[Role]\ntextand a fixed instruction block is appended.systemanddeveloperturns are dropped — muse.ai's chat UI has no equivalent channel. - Hot page reuse. The driver keeps the last successful thread URL. If the next request's history matches, it types only the new delta instead of starting a new thread. This is what makes multi-turn conversations fast.
- No retry in
_send(). Retrying a prompt send double-sends and burns quota. Retries live only in idempotent layers: CDP reconnect, page reload, state polling. - One tab at a time.
account_max_concurrencydefaults to1because the browser is a single resource. Real throttling happens here, not at the HTTP layer.
All settings come from environment variables prefixed MUSE2API_, or from a .env file in the repo root. Source of truth: src/museai/config.py.
| Variable | Default | Description |
|---|---|---|
MUSE2API_HOST |
127.0.0.1 |
Bind address. Do not change. |
MUSE2API_PORT |
20233 |
Relay port. |
MUSE2API_LOG_LEVEL |
INFO |
DEBUG logs every CDP step. |
MUSE2API_PUBLIC_BASE |
"" |
External base URL for media links. Empty = derive from request. |
MUSE2API_DATA_DIR |
data |
Runtime state directory. |
| Variable | Default | Description |
|---|---|---|
MUSE2API_API_KEY |
"" |
Intended bearer key for /v1/*. Unused — deps.py is a no-op. |
MUSE2API_ADMIN_KEY |
"" |
Intended bearer key for /admin/*. Unused. |
| Variable | Default | Description |
|---|---|---|
MUSE2API_DRIVER |
browser |
browser, mock, or http. |
MUSE2API_SITE_URL |
https://muse.ai |
Upstream base URL. |
MUSE2API_CHROMIUM_PATH |
"" |
Explicit browser binary. Empty = auto-detect. |
MUSE2API_CDP_PORT |
19211 |
Chrome DevTools Protocol port. |
MUSE2API_HEADLESS |
true |
false to watch the browser work. |
MUSE2API_BROWSER_PROXY |
"" |
Proxy for the browser only, e.g. socks5://127.0.0.1:10808. |
MUSE2API_BROWSER_PROFILE_DIR |
None |
Custom Chromium profile dir. Default data/browser-profile. |
MUSE2API_PAGE_READY_TIMEOUT |
45.0 |
Seconds to wait for the composer. |
| Variable | Default | Description |
|---|---|---|
MUSE2API_CHAT_TIMEOUT |
300.0 |
Total budget per chat request. |
MUSE2API_FIRST_TOKEN_TIMEOUT |
45.0 |
Deadline before the first token appears. |
MUSE2API_IMAGE_TIMEOUT |
240.0 |
Per image generation. |
MUSE2API_VIDEO_TIMEOUT |
600.0 |
Per video task. |
| Variable | Default | Description |
|---|---|---|
MUSE2API_POOL_STRATEGY |
affinity |
affinity, lru, or round_robin. |
MUSE2API_POOL_ACQUIRE_TIMEOUT |
60.0 |
Max wait for a free account. |
MUSE2API_ACCOUNT_MAX_CONCURRENCY |
1 |
Concurrent chats per account. Keep at 1. |
MUSE2API_ACCOUNT_COOLDOWN |
120.0 |
Seconds an account rests after a failure. |
MUSE2API_MAX_FAILOVER |
2 |
Retries across other accounts on failure. |
| Variable | Default | Description |
|---|---|---|
MUSE2API_KEEPALIVE_ENABLED |
false |
Background session renewal. |
MUSE2API_KEEPALIVE_INTERVAL |
21600 |
Renewal interval in seconds (6 h). |
MUSE2API_LOG_LEVEL=INFO
MUSE2API_HEADLESS=true
MUSE2API_CHAT_TIMEOUT=300
MUSE2API_ACCOUNT_MAX_CONCURRENCY=1
MUSE2API_POOL_STRATEGY=affinityThe pool decides which account serves a request and when an account rests.
| Strategy | Behaviour |
|---|---|
affinity |
Prefers the account that served the current hot page, so the browser tab is reused. Default and fastest. |
lru |
Least-recently-used among available accounts. |
round_robin |
Rotates in order. |
| State | Meaning |
|---|---|
ACTIVE |
Ready to serve. |
COOLING |
Failed recently; resting until cooldown_until. |
DISABLED |
Manually disabled via admin API. |
- A failure sets
COOLINGforACCOUNT_COOLDOWNseconds. - An
UpstreamTimeoutdoes not trigger cooldown — a slow model is not a broken account. It only increments the failure counter. - Once the cooldown timestamp passes,
is_available()heals the account back toACTIVE. acquire()waits up toPOOL_ACQUIRE_TIMEOUT, capped at the soonest recovery time, so a cooling account that recovers mid-wait becomes usable instead of invisible.- If the pool holds a single account, failover is skipped and the real upstream error surfaces (
504), rather than a misleading503 no usable account.
# see every account and its state
Invoke-RestMethod http://127.0.0.1:20233/admin/accounts
# clear failures + cooldown on one account
Invoke-RestMethod -Method Post http://127.0.0.1:20233/admin/accounts/0/reset
# try to renew a session
Invoke-RestMethod -Method Post http://127.0.0.1:20233/admin/accounts/0/renewMeasured on a warm page, single account, headless Chromium:
| Stage | Typical | Notes |
|---|---|---|
| Relay overhead before send | 2.8–4.8 s | Attach, thread lookup, prompt build. |
| First token | 20–38 s | Upstream muse.ai generation. Not relay cost. |
| Full non-stream reply | 28–50 s | Dominated by upstream. |
| Polling loop | ~14 ms/poll | Negligible. |
| Hot-page reuse saving | ~5–6 s | Compared to opening a new thread. |
Reality: roughly 24 seconds of every request is the upstream model thinking. The relay's controllable overhead is about 5–6 seconds, and hot-page reuse is the single biggest win available. There is no configuration that makes muse.ai generate faster.
If you need sub-second responses, this relay is the wrong tool.
| Cause | Fix |
|---|---|
session.json missing or empty |
Create it — see §6. |
| All cookies expired | Refresh cookie values, restart. |
Every account is COOLING |
Wait out ACCOUNT_COOLDOWN, or POST /admin/accounts/{id}/reset. |
| Single account timed out | Real upstream timeout — check /admin/status, retry. |
The relay raised an exception. Check relay.err first — never guess.
Get-Content D:\temp\opencode\relay.err -Tail 40Common causes: stale code in a long-running process (restart), a driver AttributeError from an in-flight edit, or a malformed request body.
Usually cookie expiry. Confirm with:
Invoke-RestMethod http://127.0.0.1:20233/admin/statusIf the driver reports a login page, refresh session.json.
Something is firing extra requests — most often opencode's small_model title generation. Remove this provider from small_model.
Get-NetTCPConnection -LocalPort 20233 -State Listen |
Select-Object -ExpandProperty OwningProcess -Unique |
ForEach-Object { Stop-Process -Id $_ -Force }Check the CDP port:
(Invoke-WebRequest http://127.0.0.1:19211/json/version).ContentIf empty, the browser died. Restart the relay — it will relaunch.
Your normal browsing session shares the process name. Always pattern-match:
Get-CimInstance Win32_Process |
Where-Object { $_.CommandLine -like "*remote-debugging-port=19211*" -or $_.CommandLine -like "*-m museai*" } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force }Documented so nobody re-discovers them as "bugs":
drivers/browser/chromium.py:25-28— Windows path double-escaping. Works, but untidy.accounts/store.py— duplicated class definitions. Harmless, worth consolidating.drivers/http/driver.py— unused stub. Kept for the driver registry shape.core/models.py:57-62— silent model fallback. Intentional; an unknown model degrades tomuse-chatinstead of erroring.drivers/browser/dom.pyis a file, not a directory.- No
tests/directory despitepyproject.tomldeclaringtestpaths. Verification is done with manual scripts. flatten_messages()appends a fixed[Instruction]block whenever there is more than one turn. Expected behaviour, not duplication.message_turns()intentionally keepssystemanddeveloperturns whileflatten_messages()drops them. History matching depends on this asymmetry — do not "fix" it.
The repo has no tests/ directory. Verification is done with standalone scripts kept outside the repo (this project uses D:\temp\opencode). Run them after touching the driver or prompt layer:
$env:PYTHONPATH = "D:\project\muse-opencode\src"
Set-Location D:\temp\opencode
D:\project\muse-opencode\.venv\Scripts\python.exe cdp_check.py # CDP connectivity
D:\project\muse-opencode\.venv\Scripts\python.exe media_check.py # media serving
D:\project\muse-opencode\.venv\Scripts\python.exe media_cap_check.py # image n bounds
D:\project\muse-opencode\.venv\Scripts\python.exe img_test.py # image generation
D:\project\muse-opencode\.venv\Scripts\python.exe persist_test.py # state persistenceThey expect the relay to already be running on 127.0.0.1:20233.
This is a local developer tool, and the security model is built around that assumption.
| Control | Implementation |
|---|---|
| Network exposure | Binds 127.0.0.1 only. No 0.0.0.0, ever. |
| Authentication | Deliberately absent. api/deps.py returns None — loopback is the boundary. |
| Credentials at rest | session.json, gitignored, plaintext. Protect it like a password. |
| Credentials in transit | Never leave the machine. Cookies go to muse.ai over TLS. |
| Browser isolation | Dedicated Chromium profile under data/browser-profile. |
| Logging | Never logs cookie values. DEBUG logs CDP steps, not secrets. |
Do not expose this relay to a network, tunnel it publicly, or share session.json. If you need remote access, use an SSH tunnel to loopback — never a public bind.
This project is built for education and personal learning: OpenAI-compatible relay design, Chrome DevTools Protocol automation, prompt translation, and local tool integration.
- Not official. Not affiliated with, endorsed by, or supported by muse.ai.
- All requests originate from the operator's own account using the operator's own login cookie. No authentication is bypassed.
- Upstream rate limits and terms of service still apply. Account risk is entirely the operator's.
- Not for commercial use. Not for resale. Not for providing access to third parties.
- No warranty. No support. Use at your own risk.
For Educational Purposes Only.
