Building Apps
An app is a server that Open WebUI connects to. Admins install it; admins and the channel's creator add it to channels, as described in Apps.
Quick start with oix
The quickest way to build an app is oix, the Python SDK (Python 3.11 or later). It implements the protocol on this page, so your code only handles events:
python -m pip install oixEmbeds and link buttons need oix 0.7.0 or newer. Mentions need oix 0.8.0 or newer.
from oix import App
app = App(
manifest_id="hello",
name="Hello",
scopes=["channels:messages:read", "channels:messages:write"],
)
@app.on("channel.message.created")
async def hello(event):
if event.content == "hello":
await event.reply("Hello!")
if __name__ == "__main__":
app.run()Save it as app.py. The app reads its API key from API_KEY and its private key from the file named in APP_PRIVATE_KEY_FILE; the oix README has a script that writes both to .env once. Start the app with . ./.env && python app.py. It listens on port 8000 at /events. Put it behind HTTPS with WebSocket upgrades enabled, and its App URL is for example https://hello.example.com/events. Install it with that URL and the API key, add it to a channel and send hello.
To test against a local Open WebUI, follow the oix local development steps together with Private and plain HTTP app servers.
An app in another language implements the protocol below directly.
What an app provides
- An optional manifest at the App URL.
- A Socket.IO server under the App URL's path, with the
/appsnamespace. - An Ed25519 key pair, for example
openssl genpkey -algorithm ED25519 > app-private-key.pem, with the public key fromopenssl pkey -in app-private-key.pem -pubout. The public key goes in the manifest; the private key signs the connection proof. - An API key: a random secret you create, for example with
openssl rand -hex 32, and give to the admin. Open WebUI sends it asAuthorization: Bearer <API key>on the manifest request and on the Socket.IO handshake. Reject requests without it.
Manifest
Open WebUI sends GET <App URL> with the API key. Answer with HTTP 200 and a JSON object of at most 256 KiB, within 10 seconds:
{
"id": "hello",
"name": "Hello",
"description": "Says hello.",
"profile_image_url": "https://example.com/hello.png",
"public_key": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n",
"requested_scopes": ["channels:mentions:read", "channels:messages:write"]
}| Field | Rules |
|---|---|
id | Required, 1 to 100 characters. Identifies the app and must stay the same for an installed app. |
name | Required, 1 to 100 characters. |
description | Optional, up to 2,000 characters. |
profile_image_url | Optional. An http(s) URL of up to 2,048 characters without credentials, or a base64 data:image/png, jpeg, gif or webp URL. Up to 192 KiB in total. Users' browsers load it directly. |
public_key | Required. An Ed25519 public key in PEM format (-----BEGIN PUBLIC KEY-----). Open WebUI pins it at install. |
requested_scopes | Required. A list of the scopes. |
Unknown fields make the manifest invalid.
Connection
For an App URL of https://app.example.com/events, Open WebUI connects as a Socket.IO client to https://app.example.com with the Socket.IO path /events/socket.io, WebSocket transport only, namespace /apps and the Authorization: Bearer <API key> header. If the app sits behind a reverse proxy, the proxy must pass WebSocket upgrades on the Socket.IO path. The handshake then runs over Socket.IO events with acknowledgements:
-
Open WebUI emits
app:connectwith:{ "app_id": "<id Open WebUI assigned>", "challenge": "<random UUID>", "connection_token": null }connection_tokenisnullwhen Open WebUI holds no token: on the first connect, after the admin enters a new API key, after the app's public key changes and after an uninstall. Otherwise it is the token the app returned at the last Connect an admin clicked. -
The app acknowledges with:
{ "status": 200, "data": { "installation_id": "<UUID>", "connection_token": "<string>", "proof": "<JWT>" } }The app chooses
installation_id(a UUID that stays the same for this installation) andconnection_token(up to 8,192 characters). Open WebUI stores the token encrypted when an admin clicks Connect and sends it on every reconnect. On automatic reconnects it keeps the stored token and ignores the one the app returns, so the app must keep accepting the token it issued.The app signs
proofwith its Ed25519 private key (JWT algorithmEdDSA, headertypoix-connect+jwt). Open WebUI verifies it with the pinned public key from the manifest. Required claims:Claim Value audoix:connectsubthe installation_idapp_idthe app_idfromapp:connectmanifest_idthe manifest idchallengethe challengefromapp:connecttoken_hashthe SHA-256 hex digest of the connection_tokenin this acknowledgementiat,expintegers, expat most 60 seconds afteriatWhile Open WebUI holds a token, the
installation_idmust stay the same. When it sendsnull, the app may answer with a newinstallation_id. -
Open WebUI emits
app:readywith{}. The app must acknowledge with exactly{"status": 200, "data": {"received": true}}.
The connection is then live. Open WebUI authenticates itself with the API key; the app authenticates itself with the signed proof. Each Open WebUI worker opens its own connection, so the app must accept several connections for the same installation.
The whole connection must complete within 20 seconds, and each acknowledgement within 10. Until Open WebUI has verified the proof, every app:request fails with App connection is not ready. From then until the app acknowledges app:ready, only ping works.
Events
Open WebUI emits app:event and waits for the acknowledgement. Every event carries type, app_id (Open WebUI's id for the app) and installation_id.
Interactions go to channel.mention and channel.action:
{
"id": "<operation id>",
"type": "channel.mention",
"actor": { "id": "<user id>", "name": "Ada", "is_admin": false },
"channel": { "id": "<channel id>" },
"data": { "text": "<@A:<app_id>|Hello> hi", "message_id": "<id>", "parent_id": null },
"execution_token": "<token>",
"source": null,
"app_id": "<app id>",
"installation_id": "<installation id>"
}channel.mention: a user mentioned the app.data.textis the raw message, where the mention appears as<@A:<app_id>|<name>>.data.parent_idis the thread root when the mention was posted in a thread. Requireschannels:mentions:readorchannels:messages:read. Acknowledge within 10 seconds with exactly{"status": 200, "data": {"received": true}}. This only confirms delivery. Post the answer afterwards with thecreate_messageoperation, which needschannels:messages:write; passdata.message_idasreply_to_idanddata.parent_idasparent_idto reply to the mention in the same place.channel.action: a user clicked a button.dataholdsmessage_id,etag,parent_id,action_idand the button'scontext;sourceholds the message'sidandupdated_at. Answer within 3 seconds with a reply.
Message events go to apps with channels:messages:read, for every message in the channels they joined, including the app's own messages: channel.message.created, channel.message.updated and channel.message.deleted. Acknowledge with a 2xx status within 10 seconds.
{
"id": "<event id>",
"type": "channel.message.created",
"channel": { "id": "<channel id>" },
"message": {
"id": "<id>", "channel_id": "<id>", "author": { "type": "user", "id": "<id>" },
"content": "...", "parent_id": null, "reply_to_id": null, "files": [], "actions": [], "embeds": [],
"created_at": "<ns>", "updated_at": "<ns>", "etag": "<ns>"
},
"event_token": "<token>",
"app_id": "<app id>",
"installation_id": "<installation id>"
}author.type is user, model, webhook or app. model and webhook follow the label stored on the message, which any poster can set, so treat them as presentation. For a message with a model label, channel.message.updated is sent only once the message is marked done, which for a model's reply means once the reply is complete. Deleted-message events carry only id, channel_id, parent_id, author and updated_at. Message events are best effort: each is sent once, and events that fail or occur while the app is disconnected are dropped.
A mention reaches an app with channels:messages:read twice: as channel.mention and as channel.message.created. Answer it in one of the two handlers.
execution_token and event_token are signed by Open WebUI. The app checks them by sending them back with the authorize or verify_event operation below. An execution_token is valid for 24 hours and an event_token for 5 minutes. Both stop working when the app's API key or public key changes.
Replying to interactions
Acknowledge a button click (channel.action) with {"status": 200, "data": <reply>}, at most 256 KiB:
{
"status": 200,
"data": {
"type": "message",
"visibility": "channel",
"content": "Ready to publish?",
"actions": [{ "action_id": "publish", "label": "Publish", "context": { "draft": 42 } }]
}
}| Field | Meaning |
|---|---|
type | message (default), update, ack, download or error. |
visibility | private (default, only the user who clicked) or channel. |
content | Markdown, up to 32,000 characters. |
title | Optional heading for private replies, up to 200 characters. |
actions | Up to 30 buttons. Each has a label (up to 200 characters) and either an action_id (up to 100 characters) with an optional context object that comes back in channel.action, or a url: an http(s) link of up to 2,048 characters, without credentials, that opens in a new tab. Only allowed on message or update with visibility channel. |
embeds | Up to 10 cards, on message or update. Each takes title (up to 256 characters), description (Markdown, up to 4,096), url (links the title), color (an integer from 0 to 0xFFFFFF for the accent bar), author ({"name": ...}, up to 256), footer ({"text": ...}, up to 2,048), thumbnail ({"url": ...}) and up to 25 fields (name up to 256, value in Markdown up to 1,024, optional inline). All cards in one message hold at most 6,000 characters of text. URLs are http(s) without credentials, up to 2,048 characters. Users' browsers load thumbnails directly. |
parent_id, reply_to_id | Post a channel reply into a thread or as a reply to a message. A channel reply without parent_id lands at the top of the channel. |
filename | For download: letters, digits, _, . and -, ending in .md. Default report.md. |
messagewithvisibilitychannelposts a channel message and needschannels:messages:write.updatereplaces the content, buttons and cards of the message whose button was clicked. It needschannels:messages:write. Setvisibilitytochannel: with the defaultprivate, buttons are refused and the user also gets a private copy of the content.downloadgives the user thecontentas a Markdown file.errorshows thecontentto the user, orThe app could not complete this operation.whencontentis empty.- Send
downloadwithvisibilityprivate, the default; withchannelit is shown nowhere. Anerroris shown only to the user who clicked, with either visibility. ackshows nothing. Send it as{"type": "ack"}.
Unknown fields make the reply invalid.
To refuse a mention or a button click, acknowledge with an error status and an error string, for example {"status": 403, "error": "Only the on-call team can run this."}. Open WebUI shows up to 2,000 characters of that string; without it, a message for the status code. After a mention, Open WebUI posts the message in the channel under the app's name, as a reply that starts with App error:, so everyone in the channel sees it. If the app cannot post there, only the user who mentioned it sees the message. After a button click, only the user who clicked sees it. Troubleshooting lists the messages admins and users see.
Calling Open WebUI
The app emits app:request on the same connection with {"operation": "<name>", "arguments": {...}}. The acknowledgement is {"status": 200, "data": ...} or {"status": <code>, "error": "<message>"}. Each request must finish within 10 seconds. Requests act only in channels the app is a member of.
| Operation | Arguments | Scope | Returns |
|---|---|---|---|
ping | {} | none | {"received": true} |
get_me | none | none | The app's record |
list_channels | none | channels:read | Joined channels: id, name, type, is_private |
list_messages | channel_id, optional parent_id, before, limit (1 to 100, default 50) | channels:messages:read | messages newest first and next_cursor |
create_message | channel_id, optional content, actions, embeds, parent_id, reply_to_id | channels:messages:write | The new message, including its etag |
update_message | message_id, etag, optional content, actions, embeds | channels:messages:write | The updated message |
authorize | token: an execution_token | the interaction's scope | authorized, operation_id, actor, channel_id, type, data |
verify_event | token: an event_token | channels:messages:read | The verified event |
update_message edits only the app's own messages. content, actions and embeds replace the old values, so omitted ones are cleared. If the message changed since the etag was read, the request fails with status 412 (Message changed).
Long-running work
A mention only needs its delivery receipt, so the app does the work afterwards and posts the result with create_message. A button click must be answered within 3 seconds. For anything slower:
- Reply
{"type": "ack"}right away. The user sees nothing. - Do the work.
- Optionally call
authorizewith the event'sexecution_token(valid for 24 hours) to confirm the user can still use the app and read the channel. - Post the result with the
create_messageoperation, usingdata.parent_idfrom the event to stay in the thread. This needschannels:messages:write, and the result is a channel message that everyone in the channel sees. A private reply is only possible as the direct answer to a button click.
Limits
| Limit | Value |
|---|---|
| Socket.IO packet | 16 MiB, JSON text only. Binary packets are rejected. |
| Event sent to the app | 1 MiB |
| Reply to an event | 256 KiB |
| Manifest response | 256 KiB, within 10 seconds |
| Message content | 32,000 characters, up to 30 buttons and 10 cards with 6,000 characters of card text |
| Connection and handshake | 20 seconds in total, 10 seconds per acknowledgement |
| Mention receipt time | 10 seconds |
| Button click answer time | 3 seconds |
| Message event answer time | 10 seconds |
app:request processing | 10 seconds |
execution_token lifetime | 24 hours |
event_token lifetime | 5 minutes |