oix 0.0.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,27 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main, release]
6
+ pull_request:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ test:
13
+ runs-on: ubuntu-latest
14
+ strategy:
15
+ matrix:
16
+ python: ['3.11', '3.12', '3.13', '3.14']
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+ - uses: astral-sh/setup-uv@v6
20
+ with:
21
+ python-version: ${{ matrix.python }}
22
+ - run: uv sync --locked --extra fastapi
23
+ - run: uv run ruff check .
24
+ - run: uv run ruff format --check .
25
+ - run: uv run pytest -q
26
+ - run: uv build
27
+ - run: uv run twine check dist/*
@@ -0,0 +1,224 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ branches: [release]
6
+ workflow_dispatch:
7
+
8
+ concurrency:
9
+ group: pypi-${{ github.ref }}
10
+ cancel-in-progress: false
11
+
12
+ permissions:
13
+ contents: read
14
+
15
+ jobs:
16
+ preflight:
17
+ runs-on: ubuntu-latest
18
+ outputs:
19
+ version: ${{ steps.pkg.outputs.version }}
20
+ publish_needed: ${{ steps.pypi.outputs.publish_needed }}
21
+ release_assets_needed: ${{ steps.release.outputs.release_assets_needed }}
22
+ steps:
23
+ - name: Checkout
24
+ uses: actions/checkout@v4
25
+
26
+ - name: Read version
27
+ id: pkg
28
+ run: |
29
+ VERSION=$(python3 -c "
30
+ import re, pathlib
31
+ text = pathlib.Path('pyproject.toml').read_text()
32
+ m = re.search(r'^version\s*=\s*\"([^\"]+)\"', text, re.M)
33
+ print(m.group(1))
34
+ ")
35
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
36
+
37
+ - name: Check PyPI
38
+ id: pypi
39
+ env:
40
+ VERSION: ${{ steps.pkg.outputs.version }}
41
+ run: |
42
+ python3 - <<'PY'
43
+ import os
44
+ import urllib.error
45
+ import urllib.request
46
+
47
+ version = os.environ["VERSION"]
48
+ exists = False
49
+ try:
50
+ with urllib.request.urlopen(f"https://pypi.org/pypi/oix/{version}/json", timeout=15):
51
+ exists = True
52
+ except urllib.error.HTTPError as exc:
53
+ if exc.code != 404:
54
+ raise
55
+
56
+ with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
57
+ output.write(f"publish_needed={'false' if exists else 'true'}\n")
58
+ print(f"Version {version} {'already exists on PyPI' if exists else 'is not on PyPI'}")
59
+ PY
60
+
61
+ - name: Check GitHub release assets
62
+ id: release
63
+ env:
64
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
65
+ GH_REPO: ${{ github.repository }}
66
+ VERSION: ${{ steps.pkg.outputs.version }}
67
+ run: |
68
+ set -euo pipefail
69
+ expected=(
70
+ "oix-${VERSION}-py3-none-any.whl"
71
+ "oix-${VERSION}.tar.gz"
72
+ "SHA256SUMS"
73
+ )
74
+
75
+ assets="$(gh release view "v${VERSION}" --json assets --jq '.assets[].name' 2>/dev/null || true)"
76
+ missing=false
77
+ for name in "${expected[@]}"; do
78
+ if ! grep -Fx "$name" <<< "$assets" >/dev/null; then
79
+ missing=true
80
+ echo "Missing release asset: $name"
81
+ fi
82
+ done
83
+
84
+ echo "release_assets_needed=$missing" >> "$GITHUB_OUTPUT"
85
+
86
+ build:
87
+ needs: preflight
88
+ if: github.ref == 'refs/heads/release' && (needs.preflight.outputs.publish_needed == 'true' || needs.preflight.outputs.release_assets_needed == 'true')
89
+ runs-on: ubuntu-latest
90
+ steps:
91
+ - name: Checkout
92
+ uses: actions/checkout@v4
93
+
94
+ - name: Set up Python
95
+ uses: actions/setup-python@v5
96
+ with:
97
+ python-version: "3.12"
98
+
99
+ - name: Set up uv
100
+ uses: astral-sh/setup-uv@v6
101
+
102
+ - name: Test
103
+ run: |
104
+ uv sync --locked --extra fastapi
105
+ uv run ruff check .
106
+ uv run ruff format --check .
107
+ uv run pytest -q
108
+
109
+ - name: Build package
110
+ run: |
111
+ uv build
112
+ uv run twine check dist/*
113
+
114
+ - name: Generate checksums
115
+ run: |
116
+ mkdir -p release-assets
117
+ cp dist/* release-assets/
118
+ cd release-assets
119
+ sha256sum *.whl *.tar.gz > SHA256SUMS
120
+
121
+ - name: Upload dist artifacts
122
+ uses: actions/upload-artifact@v4
123
+ with:
124
+ name: dist
125
+ path: dist/
126
+
127
+ - name: Upload release artifacts
128
+ uses: actions/upload-artifact@v4
129
+ with:
130
+ name: release-assets
131
+ path: release-assets/
132
+
133
+ publish:
134
+ needs: [preflight, build]
135
+ if: github.ref == 'refs/heads/release' && needs.preflight.outputs.publish_needed == 'true'
136
+ runs-on: ubuntu-latest
137
+ environment: pypi
138
+ permissions:
139
+ id-token: write
140
+ steps:
141
+ - name: Download dist artifacts
142
+ uses: actions/download-artifact@v4
143
+ with:
144
+ name: dist
145
+ path: dist/
146
+
147
+ - name: Check if version already on PyPI
148
+ id: check
149
+ env:
150
+ VERSION: ${{ needs.preflight.outputs.version }}
151
+ run: |
152
+ python3 - <<'PY'
153
+ import os
154
+ import urllib.error
155
+ import urllib.request
156
+
157
+ version = os.environ["VERSION"]
158
+ exists = False
159
+ try:
160
+ with urllib.request.urlopen(f"https://pypi.org/pypi/oix/{version}/json", timeout=15):
161
+ exists = True
162
+ except urllib.error.HTTPError as exc:
163
+ if exc.code != 404:
164
+ raise
165
+
166
+ with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output:
167
+ output.write(f"skip={'true' if exists else 'false'}\n")
168
+ print(f"Version {version} {'already exists on PyPI, skipping publish' if exists else 'is not on PyPI'}")
169
+ PY
170
+
171
+ - name: Publish to PyPI
172
+ if: steps.check.outputs.skip != 'true'
173
+ uses: pypa/gh-action-pypi-publish@release/v1
174
+ # No API token needed - uses OIDC trusted publishing.
175
+ # Configure at: https://pypi.org/manage/project/oix/settings/publishing/
176
+
177
+ github-release-assets:
178
+ needs: [preflight, build]
179
+ if: github.ref == 'refs/heads/release' && needs.preflight.outputs.release_assets_needed == 'true'
180
+ runs-on: ubuntu-latest
181
+ permissions:
182
+ contents: write
183
+ steps:
184
+ - name: Download release artifacts
185
+ uses: actions/download-artifact@v4
186
+ with:
187
+ name: release-assets
188
+ path: release-assets/
189
+
190
+ - name: Attach GitHub release artifacts
191
+ env:
192
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
193
+ GH_REPO: ${{ github.repository }}
194
+ VERSION: ${{ needs.preflight.outputs.version }}
195
+ run: |
196
+ set -euo pipefail
197
+ ls -lah release-assets
198
+ mapfile -t files < <(find release-assets -maxdepth 1 -type f -print | sort)
199
+ if [ "${#files[@]}" -eq 0 ]; then
200
+ echo "No release asset files found" >&2
201
+ exit 1
202
+ fi
203
+
204
+ for attempt in {1..60}; do
205
+ if gh release view "v${VERSION}" >/dev/null 2>&1; then
206
+ gh release upload "v${VERSION}" "${files[@]}" --clobber
207
+
208
+ assets="$(gh release view "v${VERSION}" --json assets --jq '.assets[].name')"
209
+ for expected in \
210
+ "oix-${VERSION}-py3-none-any.whl" \
211
+ "oix-${VERSION}.tar.gz" \
212
+ "SHA256SUMS"
213
+ do
214
+ if ! grep -Fx "$expected" <<< "$assets" >/dev/null; then
215
+ echo "Missing release asset after upload: $expected" >&2
216
+ exit 1
217
+ fi
218
+ done
219
+ exit 0
220
+ fi
221
+ sleep 10
222
+ done
223
+ echo "GitHub release v${VERSION} was not found" >&2
224
+ exit 1
@@ -0,0 +1,53 @@
1
+ # -----------------------------------------------------------------------------
2
+ # Release - Create GitHub release from CHANGELOG
3
+ # Runs on pushes to the release branch when version changes.
4
+ # -----------------------------------------------------------------------------
5
+ name: Release
6
+
7
+ on:
8
+ push:
9
+ branches: [release]
10
+
11
+ concurrency:
12
+ group: release-${{ github.ref }}
13
+ cancel-in-progress: false
14
+
15
+ jobs:
16
+ # Create the release used by pypi.yml for package assets.
17
+ publish:
18
+ runs-on: ubuntu-latest
19
+ timeout-minutes: 10
20
+ permissions:
21
+ contents: write
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+
25
+ - name: Read version
26
+ id: pkg
27
+ run: |
28
+ VERSION=$(python3 -c "
29
+ import re, pathlib
30
+ text = pathlib.Path('pyproject.toml').read_text()
31
+ m = re.search(r'^version\s*=\s*\"([^\"]+)\"', text, re.M)
32
+ print(m.group(1))
33
+ ")
34
+ echo "version=$VERSION" >> $GITHUB_OUTPUT
35
+
36
+ - name: Extract release notes from CHANGELOG
37
+ run: |
38
+ VER="${{ steps.pkg.outputs.version }}"
39
+ awk "/^## \[${VER}\]/{found=1; next} /^## \[/{if(found) exit} found{print}" \
40
+ CHANGELOG.md > /tmp/release-notes.md
41
+
42
+ - name: Publish GitHub release
43
+ env:
44
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
45
+ run: |
46
+ if gh release view "v${{ steps.pkg.outputs.version }}" &>/dev/null; then
47
+ echo "Release v${{ steps.pkg.outputs.version }} already exists - skipping creation"
48
+ else
49
+ gh release create "v${{ steps.pkg.outputs.version }}" \
50
+ --target "$GITHUB_SHA" \
51
+ --title "v${{ steps.pkg.outputs.version }}" \
52
+ --notes-file /tmp/release-notes.md
53
+ fi
oix-0.0.1/.gitignore ADDED
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ *.egg-info/
8
+ .env
9
+ *.pem
@@ -0,0 +1 @@
1
+ 3.11
oix-0.0.1/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [0.0.1] - 2026-10-12
4
+
5
+ - Initial external app SDK: authenticated events, signed runtime client, Markdown replies and actions.
6
+ - Optional FastAPI adapter and manifest discovery.
7
+ - uv packaging, CI, GitHub releases and PyPI trusted publishing.
oix-0.0.1/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Open WebUI
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
oix-0.0.1/PKG-INFO ADDED
@@ -0,0 +1,142 @@
1
+ Metadata-Version: 2.5
2
+ Name: oix
3
+ Version: 0.0.1
4
+ Summary: Build external apps for Open WebUI
5
+ Author-email: Tim Baek <tim@openwebui.com>
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: aiohttp<4,>=3.12
10
+ Requires-Dist: cryptography>=44
11
+ Requires-Dist: pydantic<3,>=2.10
12
+ Requires-Dist: pyjwt<3,>=2.10
13
+ Provides-Extra: fastapi
14
+ Requires-Dist: fastapi<1,>=0.115; extra == 'fastapi'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # oix — Open WebUI SDK
18
+
19
+ A small Python SDK for building external apps for Open WebUI. Version **0.0.1** targets the current external-app protocol in the accompanying Open WebUI implementation; older WebUI releases without that API are not supported.
20
+
21
+ Python 3.11+. The SDK handles authentication, connection proofs, verified events, and signed runtime requests. Your app handles its own business logic and authorization.
22
+
23
+ ## Install
24
+
25
+ Once published:
26
+
27
+ ```sh
28
+ uv add 'oix[fastapi]'
29
+ ```
30
+
31
+ For local development against this checkout:
32
+
33
+ ```sh
34
+ uv add --editable '../sdk[fastapi]'
35
+ ```
36
+
37
+ The FastAPI extra is optional. The core SDK does not require a web framework.
38
+
39
+ ## A minimal app
40
+
41
+ ```python
42
+ import os
43
+ from pathlib import Path
44
+
45
+ from fastapi import FastAPI
46
+ from oix import App, AppReply, Client, Event
47
+ from oix.fastapi import create_router
48
+
49
+ app = App(
50
+ id="hello",
51
+ name="Hello",
52
+ api_key=os.environ["API_KEY"],
53
+ private_key=Path(os.environ["APP_PRIVATE_KEY_FILE"]).read_text(),
54
+ scopes=["mentions:read"],
55
+ )
56
+
57
+
58
+ @app.on("mention")
59
+ async def hello(event: Event, client: Client) -> AppReply:
60
+ return AppReply(content="Hello! Only you can see this reply.")
61
+
62
+
63
+ server = FastAPI()
64
+ server.include_router(create_router(app, path="/events"))
65
+ ```
66
+
67
+ Generate an Ed25519 key once and keep it in persistent deployment secrets:
68
+
69
+ ```sh
70
+ umask 077
71
+ openssl genpkey -algorithm ED25519 -out app-private-key.pem
72
+ export APP_PRIVATE_KEY_FILE="$PWD/app-private-key.pem"
73
+ export API_KEY="$(uv run python -c 'import secrets; print(secrets.token_urlsafe(32))')"
74
+ uv run --extra fastapi uvicorn examples.hello:server --port 8001
75
+ ```
76
+
77
+ Persist the API key too; do not regenerate either secret on each startup. The example reads `APP_PRIVATE_KEY_FILE`; the SDK itself takes values explicitly and does not read environment variables.
78
+
79
+ In WebUI, enable Apps, add your public HTTPS event URL and the API key, approve the scopes, then connect. Invite the app into a channel. GET on the event URL provides the public manifest; POST receives authenticated events. The public key is intentionally public.
80
+
81
+ Any route is valid, including `/manifest`. Pass `manifest=False` to `create_router` for a POST-only endpoint and enter the app details manually in WebUI. `app.manifest` supplies those details.
82
+
83
+ For localhost development, pass the exact WebUI origin in `allowed_private_origins=("http://127.0.0.1:8080",)`. The example accepts the comma-separated `OPEN_WEBUI_ALLOWED_PRIVATE_ORIGINS` setting. Separately configure WebUI's `APP_ALLOWED_PRIVATE_ORIGINS` to allow your app origin. Production callbacks require HTTPS and public destinations by default; DNS results, IP literals, redirects, response size and timeouts are checked.
84
+
85
+ ## Events and replies
86
+
87
+ Register async handlers with `@app.on(...)` for:
88
+
89
+ - `mention`: explicit app mentions; text is in `event.data["text"]`.
90
+ - `action`: a saved message button; `event.data` contains `message_id`, `action_id`, and `context`.
91
+ - `operation_status`: a status request for deferred work.
92
+ - `message.created`, `message.updated`, `message.deleted`: ordinary channel events, including messages without mentions. Request `messages:read` for these.
93
+
94
+ All callbacks receive `(event, client)`. Events are verified against the originating WebUI **before** your handler runs. For interactions, the actor's admin status comes from current authorization, not the incoming payload. Unsigned source metadata is not exposed. Ordinary message events do not authorize commands on behalf of their authors.
95
+
96
+ Return `AppReply(content="...")` for an inline private reply, or set `visibility="channel"` to post publicly in the channel. `AppReply` also supports `update`, `download`, `error`, and `deferred`. `Action(action_id="...", label="...", context={...})` buttons are supported only on saved channel messages. Button handlers must enforce any operation-specific rules themselves.
97
+
98
+ Return `AppReply(type="deferred", operation_id=event.id)` for background work and implement an `operation_status` handler. Keep status replies private. Your app owns job storage and restart recovery; the SDK does not start a worker or persist jobs. Recheck `await client.authorize(event.execution_context)` before later sensitive work, and apply your app's own permissions.
99
+
100
+ Ordinary channel-event handlers return `None`; use the runtime client to send a message. A mention can produce both a channel event and a mention interaction. Check `event.message["author"]` to avoid responding to your own messages. Delivery is best effort, can arrive out of order, and has no replay guarantee.
101
+
102
+ ## Runtime client
103
+
104
+ ```python
105
+ from oix import AppChannelMessage
106
+
107
+ message = await client.create_message(
108
+ event.channel["id"], AppChannelMessage(content="Working on it...")
109
+ )
110
+ await client.update_message(
111
+ message["id"], AppChannelMessage(content="Done."), etag=message["etag"]
112
+ )
113
+ ```
114
+
115
+ Available methods: `get_me()`, `list_channels()`, `list_messages(channel_id, limit=50, before=None, parent_id=None)`, `create_message(...)`, `update_message(...)`, `authorize(execution_context)`, and `verify_event(event_context)`. Responses are WebUI JSON dictionaries/lists. `APIError.status` exposes HTTP failures, including `412` for stale edits. Transport failures use aiohttp exceptions or `TimeoutError`. Message creation is never automatically retried.
116
+
117
+ One `App` can serve multiple independent WebUI instances. Each event supplies its WebUI origin and local installation ID; its client is bound to that pair. There is no required installation registry or single `OPEN_WEBUI_URL`. For proactive messages, save the destination URL and app ID alongside your own subscription, then call `app.client(webui_url, app_id)` after restart. API-key holders are trusted to connect instances; a shared key does not provide tenant-specific business authorization.
118
+
119
+ For another web framework, call `app.authenticate(authorization)` before reading a body, enforce a 256 KiB body limit, parse JSON, then `await app.handle_event(payload, authorization=authorization)`. Return the resulting JSON. No SDK is required on the WebUI server.
120
+
121
+ ## Development and publishing
122
+
123
+ ```sh
124
+ uv sync --extra fastapi
125
+ uv run ruff check .
126
+ uv run ruff format --check .
127
+ uv run pytest
128
+ uv build
129
+ uv run twine check dist/*
130
+ ```
131
+
132
+ `uv.lock` locks development/CI dependencies. Wheels retain normal library dependency ranges. No sibling repository is needed to build or install the package.
133
+
134
+ The GitHub workflows are adapted from the sibling **oikb** repository:
135
+
136
+ - `ci.yml`: lint, tests and package builds on PRs and pushes to `main`/`release`.
137
+ - `release.yml`: a push to `release` creates `v<version>` at that commit, with notes from `CHANGELOG.md`.
138
+ - `pypi.yml`: a push to `release` (or manual run on that branch) tests/builds, publishes missing versions with PyPI trusted publishing, and attaches the wheel, source archive and checksums to the GitHub release. Existing PyPI versions are skipped.
139
+
140
+ Before the first release, configure a PyPI pending/trusted publisher for **oix**, your GitHub owner/repository, workflow **pypi.yml**, and environment **pypi**. Create that GitHub environment and restrict publishing to the `release` branch. No long-lived PyPI API token is required. The SDK does not need oikb's Docker builds or dependency wheelhouse.
141
+
142
+ To release, update the version in `pyproject.toml`, update the changelog and lockfile, then push the reviewed commit to your `release` branch. You can instead build locally and run `uv publish` with your own publishing credentials. This checkout has not been published.
oix-0.0.1/README.md ADDED
@@ -0,0 +1,126 @@
1
+ # oix — Open WebUI SDK
2
+
3
+ A small Python SDK for building external apps for Open WebUI. Version **0.0.1** targets the current external-app protocol in the accompanying Open WebUI implementation; older WebUI releases without that API are not supported.
4
+
5
+ Python 3.11+. The SDK handles authentication, connection proofs, verified events, and signed runtime requests. Your app handles its own business logic and authorization.
6
+
7
+ ## Install
8
+
9
+ Once published:
10
+
11
+ ```sh
12
+ uv add 'oix[fastapi]'
13
+ ```
14
+
15
+ For local development against this checkout:
16
+
17
+ ```sh
18
+ uv add --editable '../sdk[fastapi]'
19
+ ```
20
+
21
+ The FastAPI extra is optional. The core SDK does not require a web framework.
22
+
23
+ ## A minimal app
24
+
25
+ ```python
26
+ import os
27
+ from pathlib import Path
28
+
29
+ from fastapi import FastAPI
30
+ from oix import App, AppReply, Client, Event
31
+ from oix.fastapi import create_router
32
+
33
+ app = App(
34
+ id="hello",
35
+ name="Hello",
36
+ api_key=os.environ["API_KEY"],
37
+ private_key=Path(os.environ["APP_PRIVATE_KEY_FILE"]).read_text(),
38
+ scopes=["mentions:read"],
39
+ )
40
+
41
+
42
+ @app.on("mention")
43
+ async def hello(event: Event, client: Client) -> AppReply:
44
+ return AppReply(content="Hello! Only you can see this reply.")
45
+
46
+
47
+ server = FastAPI()
48
+ server.include_router(create_router(app, path="/events"))
49
+ ```
50
+
51
+ Generate an Ed25519 key once and keep it in persistent deployment secrets:
52
+
53
+ ```sh
54
+ umask 077
55
+ openssl genpkey -algorithm ED25519 -out app-private-key.pem
56
+ export APP_PRIVATE_KEY_FILE="$PWD/app-private-key.pem"
57
+ export API_KEY="$(uv run python -c 'import secrets; print(secrets.token_urlsafe(32))')"
58
+ uv run --extra fastapi uvicorn examples.hello:server --port 8001
59
+ ```
60
+
61
+ Persist the API key too; do not regenerate either secret on each startup. The example reads `APP_PRIVATE_KEY_FILE`; the SDK itself takes values explicitly and does not read environment variables.
62
+
63
+ In WebUI, enable Apps, add your public HTTPS event URL and the API key, approve the scopes, then connect. Invite the app into a channel. GET on the event URL provides the public manifest; POST receives authenticated events. The public key is intentionally public.
64
+
65
+ Any route is valid, including `/manifest`. Pass `manifest=False` to `create_router` for a POST-only endpoint and enter the app details manually in WebUI. `app.manifest` supplies those details.
66
+
67
+ For localhost development, pass the exact WebUI origin in `allowed_private_origins=("http://127.0.0.1:8080",)`. The example accepts the comma-separated `OPEN_WEBUI_ALLOWED_PRIVATE_ORIGINS` setting. Separately configure WebUI's `APP_ALLOWED_PRIVATE_ORIGINS` to allow your app origin. Production callbacks require HTTPS and public destinations by default; DNS results, IP literals, redirects, response size and timeouts are checked.
68
+
69
+ ## Events and replies
70
+
71
+ Register async handlers with `@app.on(...)` for:
72
+
73
+ - `mention`: explicit app mentions; text is in `event.data["text"]`.
74
+ - `action`: a saved message button; `event.data` contains `message_id`, `action_id`, and `context`.
75
+ - `operation_status`: a status request for deferred work.
76
+ - `message.created`, `message.updated`, `message.deleted`: ordinary channel events, including messages without mentions. Request `messages:read` for these.
77
+
78
+ All callbacks receive `(event, client)`. Events are verified against the originating WebUI **before** your handler runs. For interactions, the actor's admin status comes from current authorization, not the incoming payload. Unsigned source metadata is not exposed. Ordinary message events do not authorize commands on behalf of their authors.
79
+
80
+ Return `AppReply(content="...")` for an inline private reply, or set `visibility="channel"` to post publicly in the channel. `AppReply` also supports `update`, `download`, `error`, and `deferred`. `Action(action_id="...", label="...", context={...})` buttons are supported only on saved channel messages. Button handlers must enforce any operation-specific rules themselves.
81
+
82
+ Return `AppReply(type="deferred", operation_id=event.id)` for background work and implement an `operation_status` handler. Keep status replies private. Your app owns job storage and restart recovery; the SDK does not start a worker or persist jobs. Recheck `await client.authorize(event.execution_context)` before later sensitive work, and apply your app's own permissions.
83
+
84
+ Ordinary channel-event handlers return `None`; use the runtime client to send a message. A mention can produce both a channel event and a mention interaction. Check `event.message["author"]` to avoid responding to your own messages. Delivery is best effort, can arrive out of order, and has no replay guarantee.
85
+
86
+ ## Runtime client
87
+
88
+ ```python
89
+ from oix import AppChannelMessage
90
+
91
+ message = await client.create_message(
92
+ event.channel["id"], AppChannelMessage(content="Working on it...")
93
+ )
94
+ await client.update_message(
95
+ message["id"], AppChannelMessage(content="Done."), etag=message["etag"]
96
+ )
97
+ ```
98
+
99
+ Available methods: `get_me()`, `list_channels()`, `list_messages(channel_id, limit=50, before=None, parent_id=None)`, `create_message(...)`, `update_message(...)`, `authorize(execution_context)`, and `verify_event(event_context)`. Responses are WebUI JSON dictionaries/lists. `APIError.status` exposes HTTP failures, including `412` for stale edits. Transport failures use aiohttp exceptions or `TimeoutError`. Message creation is never automatically retried.
100
+
101
+ One `App` can serve multiple independent WebUI instances. Each event supplies its WebUI origin and local installation ID; its client is bound to that pair. There is no required installation registry or single `OPEN_WEBUI_URL`. For proactive messages, save the destination URL and app ID alongside your own subscription, then call `app.client(webui_url, app_id)` after restart. API-key holders are trusted to connect instances; a shared key does not provide tenant-specific business authorization.
102
+
103
+ For another web framework, call `app.authenticate(authorization)` before reading a body, enforce a 256 KiB body limit, parse JSON, then `await app.handle_event(payload, authorization=authorization)`. Return the resulting JSON. No SDK is required on the WebUI server.
104
+
105
+ ## Development and publishing
106
+
107
+ ```sh
108
+ uv sync --extra fastapi
109
+ uv run ruff check .
110
+ uv run ruff format --check .
111
+ uv run pytest
112
+ uv build
113
+ uv run twine check dist/*
114
+ ```
115
+
116
+ `uv.lock` locks development/CI dependencies. Wheels retain normal library dependency ranges. No sibling repository is needed to build or install the package.
117
+
118
+ The GitHub workflows are adapted from the sibling **oikb** repository:
119
+
120
+ - `ci.yml`: lint, tests and package builds on PRs and pushes to `main`/`release`.
121
+ - `release.yml`: a push to `release` creates `v<version>` at that commit, with notes from `CHANGELOG.md`.
122
+ - `pypi.yml`: a push to `release` (or manual run on that branch) tests/builds, publishes missing versions with PyPI trusted publishing, and attaches the wheel, source archive and checksums to the GitHub release. Existing PyPI versions are skipped.
123
+
124
+ Before the first release, configure a PyPI pending/trusted publisher for **oix**, your GitHub owner/repository, workflow **pypi.yml**, and environment **pypi**. Create that GitHub environment and restrict publishing to the `release` branch. No long-lived PyPI API token is required. The SDK does not need oikb's Docker builds or dependency wheelhouse.
125
+
126
+ To release, update the version in `pyproject.toml`, update the changelog and lockfile, then push the reviewed commit to your `release` branch. You can instead build locally and run `uv publish` with your own publishing credentials. This checkout has not been published.
@@ -0,0 +1,35 @@
1
+ """Run: uv run --extra fastapi uvicorn examples.hello:server --port 8001"""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from fastapi import FastAPI
7
+
8
+ from oix import App, AppReply, Client, Event
9
+ from oix.fastapi import create_router
10
+
11
+ app = App(
12
+ id="hello",
13
+ name="Hello",
14
+ api_key=os.environ["API_KEY"],
15
+ private_key=Path(os.environ["APP_PRIVATE_KEY_FILE"]).read_text(),
16
+ scopes=["mentions:read"],
17
+ allowed_private_origins=tuple(
18
+ filter(
19
+ None,
20
+ os.getenv(
21
+ "OPEN_WEBUI_ALLOWED_PRIVATE_ORIGINS",
22
+ "",
23
+ ).split(","),
24
+ )
25
+ ),
26
+ )
27
+
28
+
29
+ @app.on("mention")
30
+ async def hello(event: Event, client: Client) -> AppReply:
31
+ return AppReply(content="Hello! This reply is only visible to you.")
32
+
33
+
34
+ server = FastAPI()
35
+ server.include_router(create_router(app))