diff --git a/backend/open_webui/models/automations.py b/backend/open_webui/models/automations.py
index d3c0649455..1eaaca7175 100644
--- a/backend/open_webui/models/automations.py
+++ b/backend/open_webui/models/automations.py
@@ -1,6 +1,6 @@
import logging
import time
-from typing import Optional
+from typing import Literal, Optional
from uuid import uuid4
from open_webui.internal.db import Base, get_async_db_context
@@ -64,11 +64,17 @@ class AutomationTerminalConfig(BaseModel):
cwd: Optional[str] = None
+class AutomationTarget(BaseModel):
+ type: Literal['chat', 'channel'] = 'chat'
+ channel_id: Optional[str] = None
+
+
class AutomationData(BaseModel):
prompt: str
model_id: str
rrule: str
terminal: Optional[AutomationTerminalConfig] = None
+ target: Optional[AutomationTarget] = None
class AutomationModel(BaseModel):
diff --git a/backend/open_webui/routers/automations.py b/backend/open_webui/routers/automations.py
index e01c5c255b..8039a61e24 100644
--- a/backend/open_webui/routers/automations.py
+++ b/backend/open_webui/routers/automations.py
@@ -15,6 +15,8 @@ from open_webui.models.automations import (
AutomationRuns,
Automations,
)
+from open_webui.models.access_grants import AccessGrants
+from open_webui.models.channels import Channels
from open_webui.models.config import Config
from open_webui.models.folders import Folders
from open_webui.utils.access_control import has_permission
@@ -104,6 +106,48 @@ async def check_automation_folder_access(folder_id: Optional[str], user, db: Asy
)
+async def check_automation_channel_access(form_data: AutomationForm, user, db: AsyncSession):
+ target = form_data.data.target
+ if not target or target.type != 'channel':
+ return
+
+ if not target.channel_id or not await Config.get('channels.enable'):
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=ERROR_MESSAGES.NOT_FOUND,
+ )
+
+ channel = await Channels.get_channel_by_id(target.channel_id, db=db)
+ if not channel:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail=ERROR_MESSAGES.NOT_FOUND,
+ )
+
+ if user.role == 'admin':
+ return
+ if not await has_permission(user.id, 'features.channels', await Config.get('user.permissions')):
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=ERROR_MESSAGES.DEFAULT(),
+ )
+ if channel.type in ['group', 'dm']:
+ allowed = await Channels.is_user_channel_member(channel.id, user.id, db=db)
+ else:
+ allowed = await AccessGrants.has_access(
+ user_id=user.id,
+ resource_type='channel',
+ resource_id=channel.id,
+ permission='write',
+ db=db,
+ )
+ if not allowed:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail=ERROR_MESSAGES.DEFAULT(),
+ )
+
+
async def enrich_automation(automation: AutomationModel, db: AsyncSession, tz: str = None) -> AutomationResponse:
"""Full enrichment for single-item views (includes next_runs computation)."""
last_run = await AutomationRuns.get_latest(automation.id, db=db)
@@ -174,6 +218,7 @@ async def create_new_automation(
):
await check_automations_permission(request, user)
await check_automation_folder_access(form_data.folder_id, user, db)
+ await check_automation_channel_access(form_data, user, db)
try:
validate_rrule(form_data.data.rrule, tz=user.timezone)
except ValueError as e:
@@ -232,6 +277,7 @@ async def update_automation_by_id(
automation = await Automations.get_by_id(id, db=db)
check_automation_access(automation, user)
await check_automation_folder_access(form_data.folder_id, user, db)
+ await check_automation_channel_access(form_data, user, db)
try:
validate_rrule(form_data.data.rrule, tz=user.timezone)
diff --git a/backend/open_webui/routers/channels.py b/backend/open_webui/routers/channels.py
index 652cbdf07f..1e474b4468 100644
--- a/backend/open_webui/routers/channels.py
+++ b/backend/open_webui/routers/channels.py
@@ -1075,16 +1075,10 @@ async def model_response_handler(request, channel, message, user, db=None):
],
]
- # Resolve model config (same helpers automations use)
- from open_webui.utils.automations import (
- _resolve_model_features,
- _resolve_model_filter_ids,
- _resolve_model_tool_ids,
- )
+ # Resolve model config (same path automations use)
+ from open_webui.utils.automations import _resolve_model_defaults
- tool_ids = _resolve_model_tool_ids(request.app, model_id)
- features = await _resolve_model_features(request.app, model_id)
- filter_ids = _resolve_model_filter_ids(request.app, model_id)
+ tool_ids, features, filter_ids, _ = await _resolve_model_defaults(request.app, model_id)
# Build full form_data — same shape as frontend POST.
# The channel: prefix routes pipeline events to the
diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py
index c338e0471f..ac9a36066d 100644
--- a/backend/open_webui/tools/builtin.py
+++ b/backend/open_webui/tools/builtin.py
@@ -3602,7 +3602,7 @@ async def create_automation(
return JSONCodec.dumps({'error': 'User context not available'})
try:
- from open_webui.models.automations import AutomationData, AutomationForm, Automations
+ from open_webui.models.automations import AutomationData, AutomationForm, AutomationTarget, Automations
from open_webui.models.users import Users
from open_webui.routers.automations import check_automation_limits
from open_webui.utils.automations import next_n_runs_ns, next_run_ns, validate_rrule
@@ -3644,6 +3644,11 @@ async def create_automation(
prompt=prompt,
model_id=model_id,
rrule=rrule,
+ target=(
+ AutomationTarget(type='channel', channel_id=metadata.get('chat_id', '').removeprefix('channel:'))
+ if metadata.get('chat_id', '').startswith('channel:')
+ else None
+ ),
),
is_active=True,
)
@@ -3657,6 +3662,7 @@ async def create_automation(
'name': automation.name,
'folder_id': automation.folder_id,
'model_id': model_id,
+ 'target': automation.data.get('target'),
'is_active': automation.is_active,
'next_runs': next_n_runs_ns(rrule, tz=tz),
},
@@ -3695,7 +3701,7 @@ async def update_automation(
return JSONCodec.dumps({'error': 'User context not available'})
try:
- from open_webui.models.automations import AutomationData, AutomationForm, Automations
+ from open_webui.models.automations import AutomationData, AutomationForm, AutomationTarget, Automations
from open_webui.models.users import Users
from open_webui.routers.automations import check_automation_limits
from open_webui.utils.automations import next_n_runs_ns, next_run_ns, validate_rrule
@@ -3746,6 +3752,7 @@ async def update_automation(
prompt=new_prompt,
model_id=new_model_id,
rrule=new_rrule,
+ target=AutomationTarget(**automation.data['target']) if automation.data.get('target') else None,
),
is_active=automation.is_active,
)
@@ -3759,6 +3766,7 @@ async def update_automation(
'name': updated.name,
'folder_id': updated.folder_id,
'model_id': new_model_id,
+ 'target': updated.data.get('target'),
'is_active': updated.is_active,
'next_runs': next_n_runs_ns(new_rrule, tz=tz),
},
@@ -3824,6 +3832,7 @@ async def list_automations(
'folder_id': item.folder_id,
'prompt_snippet': snippet,
'model_id': item.data.get('model_id', ''),
+ 'target': item.data.get('target'),
'rrule': rrule,
'is_active': item.is_active,
'last_run_at': item.last_run_at,
diff --git a/backend/open_webui/utils/automations.py b/backend/open_webui/utils/automations.py
index 18da05f985..1d4be9b20b 100644
--- a/backend/open_webui/utils/automations.py
+++ b/backend/open_webui/utils/automations.py
@@ -36,6 +36,7 @@ from open_webui.models.automations import AutomationModel, AutomationRuns, Autom
from open_webui.models.chats import ChatForm, Chats
from open_webui.models.config import Config
from open_webui.models.folders import Folders
+from open_webui.models.messages import MessageForm
from open_webui.models.users import Users
from open_webui.utils.auth import create_token
from open_webui.utils.misc import parse_duration
@@ -300,33 +301,17 @@ def _build_request(
return request
-def _resolve_model_tool_ids(app, model_id: str) -> list[str]:
- """Read model-attached tool_ids from model config.
-
- The frontend does this in Chat.svelte (model.info.meta.toolIds).
- The backend never auto-resolves them, so we must do it explicitly.
- """
- models = getattr(app.state, 'MODELS', {})
- model = models.get(model_id, {})
- tool_ids = model.get('info', {}).get('meta', {}).get('toolIds', [])
- return list(tool_ids) if tool_ids else []
-
-
-async def _resolve_model_features(app, model_id: str) -> dict:
- """Read model default features from model config.
-
- The frontend does this in Chat.svelte (model.info.meta.defaultFeatureIds
- + model.info.meta.capabilities). Enables features like web_search,
- code_interpreter, image_generation when the model has them as defaults
- AND the capability is enabled AND the admin has enabled the feature.
- """
+async def _resolve_model_defaults(app, model_id: str) -> tuple[list[str], dict, list[str], Optional[str]]:
models = getattr(app.state, 'MODELS', {})
model = models.get(model_id, {})
meta = model.get('info', {}).get('meta', {})
+ tool_ids = list(meta.get('toolIds') or [])
+ filter_ids = list(meta.get('defaultFilterIds') or [])
+ terminal_id = meta.get('terminalId') or None
default_feature_ids = meta.get('defaultFeatureIds', [])
if not default_feature_ids:
- return {}
+ return tool_ids, {}, filter_ids, terminal_id
capabilities = meta.get('capabilities') or {}
features = {}
@@ -344,25 +329,7 @@ async def _resolve_model_features(app, model_id: str) -> dict:
if capabilities.get(feature_id) and feature_checks[feature_id]:
features[feature_id] = True
- return features
-
-
-def _resolve_model_filter_ids(app, model_id: str) -> list[str]:
- """Read model default filter_ids from model config."""
- models = getattr(app.state, 'MODELS', {})
- model = models.get(model_id, {})
- filter_ids = model.get('info', {}).get('meta', {}).get('defaultFilterIds', [])
- return list(filter_ids) if filter_ids else []
-
-
-def _resolve_model_terminal_id(app, model_id: str) -> Optional[str]:
- """Read model default terminal_id from model config.
-
- The frontend does this in Chat.svelte (model.info.meta.terminalId).
- """
- models = getattr(app.state, 'MODELS', {})
- model = models.get(model_id, {})
- return model.get('info', {}).get('meta', {}).get('terminalId') or None
+ return tool_ids, features, filter_ids, terminal_id
async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -> None:
@@ -413,10 +380,113 @@ async def _set_terminal_cwd(app, server_id: str, user, cwd: str, chat_id: str) -
log.warning(f'Failed to set terminal CWD: {e}')
+async def _execute_channel_automation(
+ app,
+ automation: AutomationModel,
+ user,
+ prompt: str,
+ model_id: str,
+ token: str,
+) -> None:
+ target = automation.data.get('target') or {}
+ channel_id = target.get('channel_id')
+ if not channel_id or not await Config.get('channels.enable'):
+ raise ValueError('Channel not found')
+
+ model = getattr(app.state, 'MODELS', {}).get(model_id, {})
+ request = _build_request(app, token=token)
+
+ from open_webui.routers.channels import new_message_handler
+
+ async with get_async_db() as db:
+ user_message, channel = await new_message_handler(
+ request,
+ channel_id,
+ MessageForm(
+ content=prompt,
+ data={},
+ meta={'automation_id': automation.id},
+ ),
+ user,
+ db,
+ )
+ response_parent_id = (
+ user_message.parent_id
+ if user_message.parent_id
+ else (user_message.id if await Config.get('channels.model_response_mode', 'thread') == 'thread' else None)
+ )
+ assistant_message, channel = await new_message_handler(
+ request,
+ channel.id,
+ MessageForm(
+ parent_id=response_parent_id,
+ content='',
+ data={},
+ meta={
+ 'automation_id': automation.id,
+ 'model_id': model_id,
+ 'model_name': model.get('name', model_id),
+ },
+ ),
+ user,
+ db,
+ )
+
+ tool_ids, features, filter_ids, _ = await _resolve_model_defaults(app, model_id)
+
+ form_data = {
+ 'model': model_id,
+ 'messages': [
+ {
+ 'role': 'system',
+ 'content': f'You are {model.get("name", model_id)}, participating in a channel conversation. Be concise and conversational.',
+ },
+ {'role': 'user', 'content': f'{user.name if user else "User"}: {prompt}'},
+ ],
+ 'stream': True,
+ 'chat_id': f'channel:{channel.id}',
+ 'id': assistant_message.id,
+ 'session_id': f'channel:{channel.id}',
+ 'automation_id': automation.id,
+ 'background_tasks': {},
+ }
+ if tool_ids:
+ form_data['tool_ids'] = tool_ids
+ if features:
+ form_data['features'] = features
+ if filter_ids:
+ form_data['filter_ids'] = filter_ids
+
+ await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)
+
+ from open_webui.socket.main import sio
+
+ await sio.emit(
+ 'automation:result',
+ {
+ 'automation_id': automation.id,
+ 'name': automation.name,
+ 'chat_id': f'channel:{channel.id}',
+ 'message_id': assistant_message.id,
+ 'status': 'success',
+ },
+ room=f'user:{automation.user_id}',
+ )
+
+ await _record_run(automation.id, 'success', chat_id=f'channel:{channel.id}')
+ await publish_event(
+ app,
+ EVENTS.AUTOMATION_RUN_COMPLETED,
+ actor=user,
+ subject_id=automation.id,
+ data={'name': automation.name, 'channel_id': channel.id, 'message_id': assistant_message.id},
+ )
+
+
async def execute_automation(app, automation: AutomationModel) -> None:
"""Execute an automation through the full chat completion pipeline.
- Creates a real chat, then calls chat_completion exactly like the frontend:
+ Creates a real chat or channel message, then calls chat_completion exactly like the frontend:
session_id + chat_id + message_id → async task → pipeline handles everything
(filters, model params, knowledge/RAG, tools, DB saves, webhooks).
"""
@@ -452,6 +522,20 @@ async def execute_automation(app, automation: AutomationModel) -> None:
prompt = await prompt_template(automation.data['prompt'], user)
model_id = automation.data['model_id']
+ try:
+ expires_delta = parse_duration(str(await Config.get('automations.auth_token_expires_in', '1h')))
+ except ValueError:
+ expires_delta = None
+ token = create_token(
+ data={'id': user.id, 'typ': 'automation'},
+ expires_delta=expires_delta or timedelta(hours=1),
+ )
+
+ target = automation.data.get('target') or {}
+ if target.get('type') == 'channel':
+ await _execute_channel_automation(app, automation, user, prompt, model_id, token)
+ return
+
folder_id = automation.folder_id
if folder_id and not await Folders.get_folder_by_id_and_user_id(folder_id, automation.user_id):
await Automations.clear_folder_ids(automation.user_id, [folder_id])
@@ -528,12 +612,7 @@ async def execute_automation(app, automation: AutomationModel) -> None:
)
# Resolve model defaults (frontend does this, backend doesn't)
- tool_ids = _resolve_model_tool_ids(app, model_id)
- features = await _resolve_model_features(app, model_id)
- filter_ids = _resolve_model_filter_ids(app, model_id)
-
- # Resolve terminal from model config
- terminal_id = _resolve_model_terminal_id(app, model_id)
+ tool_ids, features, filter_ids, terminal_id = await _resolve_model_defaults(app, model_id)
# Build the same payload the frontend sends to /api/chat/completions
form_data = {
@@ -564,14 +643,6 @@ async def execute_automation(app, automation: AutomationModel) -> None:
# Call the full chat completion pipeline (same as POST /api/chat/completions).
# The handler reference is stored on app.state to avoid circular imports.
- try:
- expires_delta = parse_duration(str(await Config.get('automations.auth_token_expires_in', '1h')))
- except ValueError:
- expires_delta = None
- token = create_token(
- data={'id': user.id, 'typ': 'automation'},
- expires_delta=expires_delta or timedelta(hours=1),
- )
request = _build_request(app, token=token)
await app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)
diff --git a/src/lib/apis/automations/index.ts b/src/lib/apis/automations/index.ts
index 593a56cab1..1a427e81d3 100644
--- a/src/lib/apis/automations/index.ts
+++ b/src/lib/apis/automations/index.ts
@@ -5,11 +5,17 @@ export type AutomationTerminalConfig = {
cwd?: string;
};
+export type AutomationTarget = {
+ type: 'chat' | 'channel';
+ channel_id?: string | null;
+};
+
export type AutomationData = {
prompt: string;
model_id: string;
rrule: string;
terminal?: AutomationTerminalConfig;
+ target?: AutomationTarget | null;
};
export type AutomationForm = {
diff --git a/src/lib/components/AutomationModal.svelte b/src/lib/components/AutomationModal.svelte
index 23028b3bc8..6a709386df 100644
--- a/src/lib/components/AutomationModal.svelte
+++ b/src/lib/components/AutomationModal.svelte
@@ -8,9 +8,10 @@
import ScheduleDropdown from '$lib/components/automations/ScheduleDropdown.svelte';
import ModelDropdown from '$lib/components/automations/ModelDropdown.svelte';
- import FolderDropdown from '$lib/components/automations/FolderDropdown.svelte';
+ import DestinationDropdown from '$lib/components/automations/DestinationDropdown.svelte';
import { getFolders } from '$lib/apis/folders';
- import { folders } from '$lib/stores';
+ import { getChannels } from '$lib/apis/channels';
+ import { channels, folders } from '$lib/stores';
import {
createAutomation,
@@ -30,10 +31,13 @@
let prompt = '';
let model_id = '';
let folder_id = '';
+ let target_type: 'chat' | 'channel' = 'chat';
+ let channel_id = '';
let is_active = true;
let loading = false;
let foldersLoaded = false;
+ let channelsLoaded = false;
// Schedule dropdown ref
let scheduleDropdown: ScheduleDropdown;
@@ -43,6 +47,10 @@
toast.error($i18n.t('Name, prompt, and model are required'));
return;
}
+ if (target_type === 'channel' && !channel_id) {
+ toast.error($i18n.t('Channel is required'));
+ return;
+ }
if (scheduleDropdown?.frequency === 'ONCE') {
const scheduled = new Date(`${scheduleDropdown.onceDate}T${scheduleDropdown.onceTime}`);
if (scheduled <= new Date()) {
@@ -54,11 +62,12 @@
try {
const form: AutomationForm = {
name: name.trim(),
- folder_id: folder_id || null,
+ folder_id: target_type === 'channel' ? null : folder_id || null,
data: {
prompt: prompt.trim(),
model_id: model_id.trim(),
- rrule: scheduleDropdown.buildRrule()
+ rrule: scheduleDropdown.buildRrule(),
+ target: target_type === 'channel' ? { type: 'channel', channel_id } : { type: 'chat' }
},
is_active
};
@@ -88,12 +97,19 @@
if (res) folders.set(res);
foldersLoaded = true;
}
+ if (!channelsLoaded && ($channels ?? []).length === 0) {
+ const res = await getChannels(localStorage.token).catch(() => null);
+ if (res) channels.set(res);
+ channelsLoaded = true;
+ }
if (automation) {
name = automation.name;
prompt = automation.data.prompt;
model_id = automation.data.model_id;
folder_id = automation.folder_id ?? '';
+ target_type = automation.data.target?.type === 'channel' ? 'channel' : 'chat';
+ channel_id = automation.data.target?.channel_id ?? '';
is_active = automation.is_active;
if (scheduleDropdown) {
scheduleDropdown.parseRrule(automation.data.rrule);
@@ -105,6 +121,12 @@
folder_id = ($folders ?? []).some((folder) => folder.id === cloneFrom.folder_id)
? (cloneFrom.folder_id ?? '')
: '';
+ target_type = cloneFrom.data.target?.type === 'channel' ? 'channel' : 'chat';
+ channel_id = ($channels ?? []).some(
+ (channel) => channel.id === cloneFrom.data.target?.channel_id
+ )
+ ? (cloneFrom.data.target?.channel_id ?? '')
+ : '';
is_active = true;
if (scheduleDropdown) {
scheduleDropdown.parseRrule(cloneFrom.data.rrule);
@@ -114,6 +136,8 @@
prompt = '';
model_id = '';
folder_id = '';
+ target_type = 'chat';
+ channel_id = '';
is_active = true;
}
};
@@ -162,7 +186,15 @@