diff --git a/apps/api/src/handlers/custom-automations/index.ts b/apps/api/src/handlers/custom-automations/index.ts
index b2bf59970..b1eea1085 100644
--- a/apps/api/src/handlers/custom-automations/index.ts
+++ b/apps/api/src/handlers/custom-automations/index.ts
@@ -35,11 +35,19 @@ type CustomAutomationVariables = Variables & {
customAutomationAdminId: string;
};
+const modelSchema = z
+ .string()
+ .trim()
+ .min(1)
+ .max(200)
+ .regex(/^[^/\s]+\/.+$/u, 'Model must use provider/model format.');
+
const writeSchema = z.object({
name: z.string().trim().min(1).max(100),
prompt: z.string().trim().min(1).max(8_000),
enabled: z.boolean().default(true),
schedule: z.string().trim().min(1).max(500),
+ model: modelSchema.optional(),
environmentId: z.string().uuid(),
targetProvider: z.enum(['slack', 'discord', 'teams', 'telegram']).optional(),
targetChannelId: z.string().trim().min(1).max(160).optional(),
@@ -51,6 +59,7 @@ const updateSchema = z.object({
prompt: z.string().trim().min(1).max(8_000).optional(),
enabled: z.boolean().optional(),
schedule: z.string().trim().min(1).max(500).optional(),
+ model: modelSchema.nullable().optional(),
environmentId: z.string().uuid().optional(),
targetProvider: z
.enum(['slack', 'discord', 'teams', 'telegram'])
@@ -132,6 +141,8 @@ const VALIDATION_ERROR_PATTERNS: RegExp[] = [
/^Cron expression must be at most \d+ characters\.$/,
/^Cron expression must be between 1 and \d+ characters\.$/,
/^Use a standard five-field cron expression\.$/,
+ /^Model must be at most \d+ characters\.$/,
+ /^Model must use provider\/model format\.$/,
/^Environment is required\.$/,
/^Selected environment was not found\.$/,
/^Custom automation was not found\.$/,
@@ -313,6 +324,7 @@ customAutomationsRouter.post('/', async (c) => {
enabled: parsed.data.enabled,
scheduleMode: schedule.scheduleMode,
cronExpression: schedule.cronExpression,
+ model: parsed.data.model ?? null,
environmentId: parsed.data.environmentId,
target: buildTarget(parsed.data),
createdByUserId: adminId(c),
@@ -373,6 +385,11 @@ customAutomationsRouter.patch('/:id', async (c) => {
enabled: parsed.data.enabled ?? existing.enabled,
scheduleMode: schedule.scheduleMode,
cronExpression: schedule.cronExpression,
+ // Explicit null clears the override; omitted keeps the existing value.
+ model:
+ parsed.data.model === null
+ ? null
+ : (parsed.data.model ?? existing.model),
environmentId: parsed.data.environmentId ?? existing.environmentId ?? '',
target: clearTarget
? {}
diff --git a/apps/docs/automations.mdx b/apps/docs/automations.mdx
index 9a0071d57..946a99a0a 100644
--- a/apps/docs/automations.mdx
+++ b/apps/docs/automations.mdx
@@ -70,6 +70,8 @@ Create arbitrary scheduled agent runs with:
- the **prompt** Roomote should run
- a **cadence** (`every hour`, `every 6 hours`, `daily`, or `weekly`)
- one required **environment**
+- an optional **model** override for the runs; the default follows the
+ deployment task model
- an optional **report destination** channel (Slack, Discord, Teams, or
Telegram)
diff --git a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx
index 75bd2baef..8f88901c5 100644
--- a/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx
+++ b/apps/web/src/components/settings/automations/AutomationsSettings.render.client.test.tsx
@@ -274,6 +274,26 @@ vi.mock('@tanstack/react-query', () => ({
return { isPending: false, data: [] };
}
+ if (queryOptions.queryKey?.[0] === 'taskModels') {
+ return {
+ isPending: false,
+ data: {
+ defaultModelId: 'anthropic/claude-sonnet-5',
+ chatgptConnected: false,
+ openaiConnected: false,
+ xaiSubscriptionConnected: false,
+ xaiConnected: false,
+ models: [
+ {
+ id: 'anthropic/claude-sonnet-5',
+ displayName: 'Claude Sonnet 5',
+ isDefault: true,
+ },
+ ],
+ },
+ };
+ }
+
if (queryOptions.queryKey?.[0] === 'miscSettings') {
return {
isPending: false,
@@ -429,6 +449,13 @@ vi.mock('@/trpc/client', () => ({
queryKey: () => ['miscSettings', 'get'],
},
},
+ taskModels: {
+ launchOptions: {
+ queryOptions: () => ({
+ queryKey: ['taskModels', 'launchOptions'],
+ }),
+ },
+ },
}),
}));
diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx
index 62018dcb2..a9cfde43f 100644
--- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx
+++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx
@@ -33,6 +33,8 @@ import {
Trash2,
} from '@/components/system';
+import { ModelSelect } from '@/components/tasks/ModelSelect';
+
import { SlackChannelSelect } from './SlackChannelSelect';
type CustomAutomationFormState = {
@@ -42,6 +44,8 @@ type CustomAutomationFormState = {
scheduleMode: CustomAutomationScheduleMode;
environmentId: string;
cronExpression: string;
+ /** Provider/model launch override; empty string means deployment default. */
+ model: string;
targetProvider: 'none' | 'slack' | 'discord' | 'teams' | 'telegram';
targetChannelId: string;
targetServiceUrl: string;
@@ -54,6 +58,7 @@ const EMPTY_FORM: CustomAutomationFormState = {
scheduleMode: 'daily',
environmentId: '',
cronExpression: '',
+ model: '',
targetProvider: 'slack',
targetChannelId: '',
targetServiceUrl: '',
@@ -112,6 +117,7 @@ function formFromRow(row: CustomAutomationListItem): CustomAutomationFormState {
scheduleMode: row.scheduleMode,
environmentId: row.environmentId ?? '',
cronExpression: row.cronExpression ?? '',
+ model: row.model ?? '',
targetProvider: target.provider,
targetChannelId: target.channelId,
targetServiceUrl: target.serviceUrl,
@@ -349,6 +355,7 @@ export function CustomAutomationsSection() {
scheduleMode: form.scheduleMode,
cronExpression:
form.scheduleMode === 'cron' ? effectiveResolvedCron : null,
+ model: form.model || null,
environmentId: form.environmentId,
...(form.targetProvider !== 'none'
? {
@@ -437,6 +444,53 @@ export function CustomAutomationsSection() {
+ {form.scheduleMode === 'cron' ? (
+
+
+
{
+ setResolvedCron(null);
+ setScheduleSummary(null);
+ setForm((current) => ({
+ ...current,
+ cronExpression: event.target.value,
+ }));
+ }}
+ onBlur={() => {
+ const alreadyResolvingThisInput =
+ resolveScheduleMutation.isPending &&
+ resolveScheduleMutation.variables?.schedule ===
+ form.cronExpression;
+ if (
+ !clientParsedCron &&
+ !resolvedCron &&
+ form.cronExpression.trim() &&
+ !alreadyResolvingThisInput
+ ) {
+ resolveScheduleMutation.mutate({
+ schedule: form.cronExpression,
+ });
+ }
+ }}
+ />
+ {resolveScheduleMutation.isPending ? (
+
+ Interpreting schedule...
+
+ ) : effectiveScheduleSummary ? (
+
+ {effectiveScheduleSummary}
+
+ ) : null}
+
+ ) : null}
+
+
+
- {form.scheduleMode === 'cron' ? (
-
-
Model
+
{
- setResolvedCron(null);
- setScheduleSummary(null);
- setForm((current) => ({
- ...current,
- cronExpression: event.target.value,
- }));
- }}
- onBlur={() => {
- const alreadyResolvingThisInput =
- resolveScheduleMutation.isPending &&
- resolveScheduleMutation.variables?.schedule ===
- form.cronExpression;
- if (
- !clientParsedCron &&
- !resolvedCron &&
- form.cronExpression.trim() &&
- !alreadyResolvingThisInput
- ) {
- resolveScheduleMutation.mutate({
- schedule: form.cronExpression,
- });
- }
- }}
+ onValueChange={(value) =>
+ setForm((current) => ({ ...current, model: value }))
+ }
/>
- {resolveScheduleMutation.isPending ? (
-
- Interpreting schedule...
-
- ) : effectiveScheduleSummary ? (
-
- {effectiveScheduleSummary}
-
- ) : null}
- ) : null}
+
@@ -746,6 +769,7 @@ export function CustomAutomationsSection() {
? destinationLabel
: `${target.provider}:${destinationLabel}`}{' '}
· {statusLine(row)}
+ {row.model ? ` · ${row.model}` : null}
{row.createdByName ? ` · by ${row.createdByName}` : null}
diff --git a/apps/web/src/components/tasks/ModelSelect.tsx b/apps/web/src/components/tasks/ModelSelect.tsx
index 067d64b58..54f4f55d1 100644
--- a/apps/web/src/components/tasks/ModelSelect.tsx
+++ b/apps/web/src/components/tasks/ModelSelect.tsx
@@ -20,8 +20,19 @@ type ModelSelectProps = {
disabled?: boolean;
className?: string;
ariaLabel?: string;
+ /**
+ * When set, renders this label as a leading option that maps to the empty
+ * string value, for pickers where "no override" is a valid choice.
+ */
+ emptyOptionLabel?: string;
+ /** Trigger size; use 'default' to line up with default-size form controls. */
+ size?: 'sm' | 'default';
};
+// Radix Select items cannot use an empty-string value, so the empty option
+// round-trips through this sentinel.
+const EMPTY_OPTION_VALUE = '__model-select-empty__';
+
function modelOptionLabel(model: {
displayName: string;
isDefault?: boolean;
@@ -35,6 +46,8 @@ export function ModelSelect({
disabled = false,
className,
ariaLabel = 'Model',
+ emptyOptionLabel,
+ size = 'sm',
}: ModelSelectProps) {
const { data, isPending } = useLaunchTaskModels();
const modelGroups = useMemo(() => {
@@ -59,14 +72,19 @@ export function ModelSelect({
return (