Skip to content

Development ​

Editing the source fragments, rebuilding the artifacts, and running the tests.

Building the artifacts ​

openclaw_pipe.py, openclaw_status_action.py and openclaw_thinking_filter.py are built artifacts, and so is docs/configuration.md. Don't hand-edit them. Edit the fragments under src/openclaw_pipe_pkg/, then rebuild:

bash
python3 build.py --all        # rebuild every artifact and docs/configuration.md
python3 build.py --check-all  # verify every committed artifact matches src/ (CI drift guard)

python3 build.py              # rebuild openclaw_pipe.py only
python3 build.py --action     # rebuild openclaw_status_action.py only
python3 build.py --check      # drift guard, Pipe only
python3 build.py --check-action  # drift guard, Action only
python3 build.py --filter     # rebuild openclaw_thinking_filter.py only
python3 build.py --check-filter  # drift guard, filter only

Each single-artifact invocation builds exactly one file, and several fragments feed both artifacts, so prefer --all / --check-all. Using the Pipe-only --check after editing action.py reports "no drift" while the committed Action artifact is stale.

Why a hand-written concatenation instead of a bundler: OWUI reads a function as a single flat .py file, using the frontmatter docstring for metadata and introspecting a top-level Pipe/Action class directly, most bundlers break one or both of those.

When your change breaks streaming ​

SymptomLikely cause
Text appears all at once instead of streamingA code path in the Pipe uses return instead of yield. The Pipe is an async generator: Open WebUI streams each chunk only as it is yielded (see Architecture). Check your code.

User-facing problems are in Troubleshooting.

Docs ​

DocWhat it covers
docs/rehydration-persistence.mdHow Open WebUI 0.12 keeps an assistant message mid-run, so a client reconnecting mid-turn sees the answer so far, and why a done:false message cannot be written. Read this before changing anything about streaming or persistence.
docs/design/ask-user-modal.mdDesign spec for a tool-call-based ask-user flow. Not implemented as written: see the banner at the top; the shipped approach intercepts OpenClaw needs input: text.

scripts/migrate_stale_chat_models.py repoints chats whose saved model the Gateway no longer offers. Open WebUI drops an unlisted model id before the Pipe runs (the browser swaps in the default model, the API answers "Model not found"), so the Pipe cannot map old ids itself; the stored chat has to change. It picks each successor from the live model list (same model under another provider, else the newest model of the same Claude family, else default), is a dry run unless given --apply, writes a backup before changing anything, and --restore <backup> undoes it. Re-run it after the Gateway drops models.

Running the tests ​

bash
uv run --with-requirements requirements-dev.txt pytest -q

This is green on a fresh clone with no setup. requirements-dev.txt pins pydantic, cryptography and websockets to the versions Open WebUI itself locks, because the artifacts run inside Open WebUI's interpreter.

Most of the suite (tests/test_build.py and every tests/test_*_unit.py) is pure unit tests with no external dependencies. What the suites share lives in two modules pytest does not collect: tests/_test_stubs.py (stand-ins for websockets, cryptography and pydantic) and tests/_test_helpers.py (run_page for the fake-DOM page scripts, RecordingEmitter, action_gateway, fake_owui/owui_modules, FakeSio). tests/fixtures/test_fixture_owui_streaming_handler.py is not a suite, it's a verbatim copy of Open WebUI's streaming handler, used as a fixture to pin tool-card rendering against the real thing.

e2e/test_e2e.py and e2e/test_browser_flows.py skip unless E2E_STACK=1: it needs the stack that e2e/start_stack.sh brings up (see E2E below). Two fixture checks in tests/test_pipe_unit.py (-k installed_dist) compare the Gateway source lines the Pipe matches on with an installed OpenClaw: $OPENCLAW_DIST, else the global npm install's. They skip when neither exists; the extractors that rewrite those fixtures (scripts/extract_openclaw_*.py --write) default to the same dist.

CI ​

Every push and PR runs .github/workflows/ci.yml:

JobWhat it guards
Lintruff with correctness rules only (ruff.toml), run on the built artifacts rather than the fragments, so an undefined name is caught where Open WebUI would hit it. Also shellcheck, actionlint, and a parse check of the Gateway plugin.
Unit (py3.10 to 3.13)Drift guard for every artifact, the unit suite (coverage reported on 3.11), and a load check that execs each artifact the way Open WebUI's plugin loader does.
OWUI handler fixture is currentscripts/check_owui_fixture.py compares the vendored handle_responses_streaming_event with Open WebUI's source at the pinned version. A stale copy is how the tool cards once vanished while the suite stayed green. scripts/check_owui_frontend_fixture.py does the same for the chat tab's live reducer (applyResponseStreamEvent, run with bun by the live-order tests).
Secret scangitleaks over the full history.
E2EThe whole stack, real everywhere except the model (below).

E2E (.github/workflows/e2e.yml, e2e/): starts a deterministic fake OpenAI-compatible model (e2e/fake_llm.py), a real OpenClaw Gateway pointed at it (with one test-only plugin, e2e/agent-tool: an Agent tool that answers like Claude Code's, which the Gateway's own runtime does not have), and Open WebUI in Docker; installs the Pipe, Filter and Action with uv run install*.py install exactly as the README tells users to (device pairing and the installer's smoke test included); then runs e2e/test_e2e.py against it, and the installed-dist fixture checks against the Gateway it installed. e2e/test_browser_flows.py then drives the same stack in Chromium with Playwright, one test per user flow, asserting on what the page shows (the browser helpers are scripts/screenshots/capture.py's). Because the model is scripted, replies are asserted exactly, the model that served a turn is read from the fake model's request log (the stack's default, e2e-default, differs from the routed e2e-echo), and a real tool call (session_status) is checked all the way into the chat Open WebUI persisted, as a function_call with its matching function_call_output. A third model, e2e-think, is the only reasoning one, so the thinking level a turn picks shows in that request log as reasoning_effort. Gateway, Open WebUI and model logs are uploaded as an artifact on every run.

The E2E job runs as 4 jobs per lane, side by side, each on its own stack and each running one shard of the tests (e2e/shards.py, picked by E2E_SHARD). A new E2E test goes in a shard in the same PR: tests/test_e2e_shards.py, in the Unit job, fails when a test is in no shard or in two, when the workflow's matrix and the shards differ, and when a shard holds more than two of the tests that crash the Gateway (a third crash on one stack waits up to 5 minutes for the Gateway's crash-loop window). The openclaw download and Playwright's Chromium are cached, keyed on their exact versions.

If Open WebUI logs database is locked during an E2E run, e2e/conftest.py asks the database for its write lock from another process, waiting up to 10 s. Still locked: it interrupts the running test and ends the run (exit code 3), naming that test and the log line, since a lock that is never released (ELI-205) would fail every later test at its timeout. Free: it was two writers at once and the lock cleared; the run goes on, and its summary lists the line under "Open WebUI database lock that cleared".

Drop gates. Two Open WebUI bugs have Pipe workarounds: an execute that takes an open input dialog's answer (ELI-195, held by emit.py) and a cancelled chat write that wedges SQLite (ELI-205, shielded by turns.py's _uncancellable). Each has a test that produces the bug's precondition itself and runs two ways: through the workaround (held / shielded), which must pass on any Open WebUI, and without it (raw), marked strict xfail. While the pinned Open WebUI has the bug, raw fails as expected. When a release fixes it, raw passes and the strict xfail fails the run: that is the signal to drop the workaround together with its gate. Compat watch runs the same suites against open-webui:latest, so it shows up there first.

The versions it runs against are pinned in e2e/versions.env. Bump them in the PR that makes the code work with a new release, together with the matching pins in requirements-dev.txt and a re-extracted fixture (python3 scripts/check_owui_fixture.py --ref <tag> --print-upstream).

Compat watch (.github/workflows/compat-watch.yml, daily): the same E2E stack against open-webui:latest and openclaw@latest, and the fixture against upstream's latest release and main. A scheduled failure opens or updates one tracking issue, so a breaking upstream release is visible before the pins are bumped; the next green scheduled run closes it (scripts/compat_watch_issue.sh).

Screenshots and Demos (.github/workflows/screenshots.yml and demos.yml, by hand): the docs' images, from the E2E stack with the scripted model serving real-looking scenes (scripts/screenshots/scenes.json; the demos add scripts/demos/demos.json). capture.py takes the stills; scripts/demos/record.py records each demo in demos.json with the DevTools screencast at 2x, a drawn cursor, a ripple on each click and a badge for each key, then encodes it with ffmpeg into a light GIF, 960 px wide, at most 15 s (a demo that needs longer sets max_seconds) and 1 MB. Both fail when a page shows deny-listed text (words that give away a test stack). A demo waits on what the page shows, never on time: a reply is done when its text is stable and "Thinking…" is gone. Before committing a GIF, look at frames from it (ffmpeg -i x.gif -vf "select=not(mod(n\,40))" -vsync 0 f%02d.png).

One recording is not made by CI: docs/img/demos/live-browser.*, the live view of the agent's browser (docs/examples/live-browser-view.md). It needs a real agent, Google's real results and a live view service behind a login, so it is a real chat on a real setup, recorded by hand and marked "manual": true in docs/img/media.json. To record it again, on a machine where the Pipe's LIVE_VIEW_URL is set for your account:

sh
OWUI_URL=http://localhost:8080 OWUI_TOKEN=<your Open WebUI token> \
  uv run --with-requirements requirements-browser.txt --with imageio-ffmpeg==0.6.0 --with pillow \
  python scripts/demos/record_live.py record --work /tmp/live-browser --folder QA \
  --viewer-origin <the live view's origin> --viewer-header 'Name: value'   # its login, if it has one
python scripts/demos/record_live.py encode --work /tmp/live-browser --out docs/img/demos \
  --cut 6-27=3,27-49=4.5,57-95=2.5   # play the waiting stretches faster; the docstring says how
python3 scripts/media.py stamp --manual docs/img/demos/live-browser.{gif,mp4,webm}

The phone screenshot beside it, docs/img/live-view-light.png, comes from a second run of the same request in a phone-sized page: record_live.py still --work /tmp/live-view-still (same options as record) saves a PNG every 1.5 s while the live view is up, and you pick the frame with the results and the LIVE badge. Check the corner of the viewer for the account's avatar and paint it white (it sits on a white header), then python3 scripts/media.py stamp --manual docs/img/live-view-light.png.

record makes one new chat in your Open WebUI (put in the --folder folder, so it is not among your own chats), types the request and keeps every frame. Look at the frames before encoding: the run is real, so it varies. Take another run when the browser shows a signed-in account, a stray tab or the viewer's own address. encode refuses a recording whose chat text shows a deny-listed word or an email address, or whose frames show a Google account's avatar. Delete the chat afterwards.

The images can't go stale silently. docs/img/media.json records, for every file under docs/img, fingerprints of the UI it shows: the composer's Claw button and the Status icon (every frame), the Status dialog's scripts, and the pinned Open WebUI image. When a pull request changes one of them, the docs unit tests fail and list the files to record again. Run the two workflows with only set to those names, put the files from their artifacts in docs/img, then uv run --with pillow scripts/media.py posters if a README tile changed, and python3 scripts/media.py stamp <files>. stamp refuses a file whose bytes are the same as before the change. A screenshot of a state the change didn't touch can come out identical: once you've recorded it again, pass --same.

CodeQL runs on Python, JavaScript and the workflows themselves. Dependabot updates the GitHub Actions and the test tooling weekly.

To run the E2E stack on your own Linux machine (Docker, uv and a Node in the range the pinned openclaw declares, npm view openclaw@$OPENCLAW_VERSION engines.node; it installs openclaw globally and writes ~/.openclaw/openclaw.json, so use a throwaway VM or container, never a host running a real Gateway):

bash
set -a; . e2e/versions.env; set +a
bash e2e/start_stack.sh        # last line is an `export ...` for the test env
export OWUI_URL=... OWUI_EMAIL=... OWUI_PASSWORD=... E2E_STACK=1   # paste it
uv run --with-requirements requirements-dev.txt pytest -v e2e/test_e2e.py
E2E_SHARD=2 uv run --with-requirements requirements-dev.txt pytest -v e2e/test_e2e.py   # one shard only

The docs site ​

docs/ is the source of the docs site, eliav2.github.io/openclaw-openwebui-integration, built with VitePress. Pages stay plain Markdown that also reads on github.com:

  • Every page starts with a # Title, then its one-line summary.
  • Images are <img src alt width> tags, as the docs test requires. Callouts are GitHub's > [!NOTE] form, which VitePress renders too.
  • Link to other pages by their relative .md path. A link to a file outside docs/ (a function, SECURITY.md, skills/) works on GitHub as it is; the site points it at the file on GitHub.
  • No Vue components or VitePress-only syntax in a page. The home page's hero and feature cards live in docs/.vitepress/config.mts, so docs/index.md stays a plain list on GitHub.
  • A new page goes in the sidebar in docs/.vitepress/config.mts.

Run it locally with bun:

bash
bun install
bun run docs:dev     # live preview at http://localhost:5173/openclaw-openwebui-integration/
bun run docs:build   # what CI runs; fails on a dead internal link

The build also writes llms.txt, llms-full.txt and a raw .md next to every page, for agents. A demo is a GIF in the Markdown (what github.com shows); when scripts/demos/record.py wrote its MP4 and WebM next to it, the site plays that muted, looping video instead, at twice the GIF's resolution. The Docs site workflow builds every pull request that touches the docs and deploys main to GitHub Pages.

The repository's social preview, the card a link to the repo shows, is docs/img/social-preview.png. scripts/social_preview.py makes it from the hero demo's last frame; run it again after recording the hero, then upload the file in the repository's Settings > General > Social preview.

The social preview: the claw logo, OpenClaw for Open WebUI and a one-line tagline, next to a finished reply with two tool cards

Driving the integration over the API ​

You can send messages through the integration as if using the OWUI frontend by calling the internal /api/chat/completions endpoint:

bash
# 1. Sign in to get a token
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auths/signin \
  -H "Content-Type: application/json" \
  -d '{"email":"admin@example.com","password":"***"}' \
  | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])")

# 2. Send a message through the integration
curl -s -X POST http://localhost:8080/api/chat/completions \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "openclaw_gateway.default",
    "messages": [{"role": "user", "content": "Hello!"}],
    "stream": false
  }'

This triggers the full pipeline (pipe function, Gateway, agent, response) and the conversation is saved to OWUI chat history automatically.

Released under the MIT License.