From c1f455ccc5a9d6d9ee10445c2132edcb8eda6a28 Mon Sep 17 00:00:00 2001
From: Matt Rubens <2600+mrubens@users.noreply.github.com>
Date: Sun, 2 Aug 2026 01:04:03 -0400
Subject: [PATCH 1/4] feat: allow choosing a model for custom automations
---
.../src/handlers/custom-automations/index.ts | 6 +
apps/docs/automations.mdx | 2 +
...AutomationsSettings.render.client.test.tsx | 27 +
.../automations/CustomAutomationsSection.tsx | 22 +
apps/web/src/components/tasks/ModelSelect.tsx | 19 +-
.../automations/custom-automations.ts | 6 +
apps/web/src/trpc/routers/_app.ts | 2 +
.../roomote-mcp-server/custom-automations.ts | 2 +
.../src/mcp/roomote-mcp-server/index.ts | 7 +
packages/db/drizzle/0026_sour_master_mold.sql | 1 +
packages/db/drizzle/meta/0026_snapshot.json | 9921 +++++++++++++++++
packages/db/drizzle/meta/_journal.json | 7 +
.../lib/__tests__/custom-automations.test.ts | 46 +
packages/db/src/lib/custom-automations.ts | 24 +-
packages/db/src/schema.ts | 5 +
.../__tests__/custom-automations.test.ts | 36 +
.../server/automations/custom-automations.ts | 18 +
packages/types/src/background-agents.ts | 1 +
18 files changed, 10147 insertions(+), 5 deletions(-)
create mode 100644 packages/db/drizzle/0026_sour_master_mold.sql
create mode 100644 packages/db/drizzle/meta/0026_snapshot.json
diff --git a/apps/api/src/handlers/custom-automations/index.ts b/apps/api/src/handlers/custom-automations/index.ts
index 4eb15bf18..f767df0d4 100644
--- a/apps/api/src/handlers/custom-automations/index.ts
+++ b/apps/api/src/handlers/custom-automations/index.ts
@@ -39,6 +39,7 @@ const writeSchema = z.object({
prompt: z.string().trim().min(1).max(8_000),
enabled: z.boolean().default(true),
schedule: z.string().trim().min(1).max(500),
+ model: z.string().trim().min(1).max(200).optional(),
environmentId: z.string().uuid(),
targetProvider: z.enum(['slack', 'discord', 'teams', 'telegram']).optional(),
targetChannelId: z.string().trim().min(1).max(160).optional(),
@@ -50,6 +51,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: z.string().trim().min(1).max(200).nullable().optional(),
environmentId: z.string().uuid().optional(),
targetProvider: z
.enum(['slack', 'discord', 'teams', 'telegram'])
@@ -197,6 +199,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),
@@ -251,6 +254,9 @@ 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..441ae6b4b 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'
? {
@@ -509,6 +516,20 @@ export function CustomAutomationsSection() {
) : null}
+
+
+
+ setForm((current) => ({ ...current, model: value }))
+ }
+ />
+
+
@@ -746,6 +767,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..5558cd47d 100644
--- a/apps/web/src/components/tasks/ModelSelect.tsx
+++ b/apps/web/src/components/tasks/ModelSelect.tsx
@@ -20,8 +20,17 @@ 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;
};
+// 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 +44,7 @@ export function ModelSelect({
disabled = false,
className,
ariaLabel = 'Model',
+ emptyOptionLabel,
}: ModelSelectProps) {
const { data, isPending } = useLaunchTaskModels();
const modelGroups = useMemo(() => {
@@ -59,14 +69,19 @@ export function ModelSelect({
return (
+ {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}
-
-
-
-
- setForm((current) => ({ ...current, model: value }))
- }
- />
diff --git a/apps/web/src/components/tasks/ModelSelect.tsx b/apps/web/src/components/tasks/ModelSelect.tsx
index 5558cd47d..54f4f55d1 100644
--- a/apps/web/src/components/tasks/ModelSelect.tsx
+++ b/apps/web/src/components/tasks/ModelSelect.tsx
@@ -25,6 +25,8 @@ type ModelSelectProps = {
* 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
@@ -45,6 +47,7 @@ export function ModelSelect({
className,
ariaLabel = 'Model',
emptyOptionLabel,
+ size = 'sm',
}: ModelSelectProps) {
const { data, isPending } = useLaunchTaskModels();
const modelGroups = useMemo(() => {
@@ -75,7 +78,7 @@ export function ModelSelect({
}
disabled={disabled || isPending || !data}
>
-
+
From d49e5700dbb262cab6505b928f5ed390ca9f732f Mon Sep 17 00:00:00 2001
From: Matt Rubens <2600+mrubens@users.noreply.github.com>
Date: Sun, 2 Aug 2026 01:12:38 -0400
Subject: [PATCH 3/4] improve: rename model fallback option to default coding
model
---
.../settings/automations/CustomAutomationsSection.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx
index 3cd887502..a9cfde43f 100644
--- a/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx
+++ b/apps/web/src/components/settings/automations/CustomAutomationsSection.tsx
@@ -523,7 +523,7 @@ export function CustomAutomationsSection() {
size="default"
ariaLabel="Automation model"
value={form.model}
- emptyOptionLabel="Deployment default"
+ emptyOptionLabel="Default coding model"
disabled={busy}
onValueChange={(value) =>
setForm((current) => ({ ...current, model: value }))
From 81e57c38f4d27d1f54c24c06836cdf45938b8cf8 Mon Sep 17 00:00:00 2001
From: Matt Rubens <2600+mrubens@users.noreply.github.com>
Date: Sun, 2 Aug 2026 01:40:58 -0400
Subject: [PATCH 4/4] fix: validate model format at the custom automation
schema boundary
---
.../src/handlers/custom-automations/index.ts | 11 +++++++++--
apps/web/src/trpc/routers/_app.ts | 18 ++++++++++++++++--
2 files changed, 25 insertions(+), 4 deletions(-)
diff --git a/apps/api/src/handlers/custom-automations/index.ts b/apps/api/src/handlers/custom-automations/index.ts
index f767df0d4..d711af567 100644
--- a/apps/api/src/handlers/custom-automations/index.ts
+++ b/apps/api/src/handlers/custom-automations/index.ts
@@ -34,12 +34,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: z.string().trim().min(1).max(200).optional(),
+ 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,7 +58,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: z.string().trim().min(1).max(200).nullable().optional(),
+ model: modelSchema.nullable().optional(),
environmentId: z.string().uuid().optional(),
targetProvider: z
.enum(['slack', 'discord', 'teams', 'telegram'])
diff --git a/apps/web/src/trpc/routers/_app.ts b/apps/web/src/trpc/routers/_app.ts
index a7a6c2221..eb62728c3 100644
--- a/apps/web/src/trpc/routers/_app.ts
+++ b/apps/web/src/trpc/routers/_app.ts
@@ -665,7 +665,14 @@ const automationsRouter = createRouter({
'cron',
]),
cronExpression: z.string().trim().max(200).nullable().optional(),
- model: z.string().trim().min(1).max(200).nullable().optional(),
+ model: z
+ .string()
+ .trim()
+ .min(1)
+ .max(200)
+ .regex(/^[^/\s]+\/.+$/u, 'Model must use provider/model format.')
+ .nullable()
+ .optional(),
environmentId: z.string().uuid(),
targetProvider: z
.enum(['slack', 'discord', 'teams', 'telegram'])
@@ -700,7 +707,14 @@ const automationsRouter = createRouter({
'cron',
]),
cronExpression: z.string().trim().max(200).nullable().optional(),
- model: z.string().trim().min(1).max(200).nullable().optional(),
+ model: z
+ .string()
+ .trim()
+ .min(1)
+ .max(200)
+ .regex(/^[^/\s]+\/.+$/u, 'Model must use provider/model format.')
+ .nullable()
+ .optional(),
environmentId: z.string().uuid(),
targetProvider: z
.enum(['slack', 'discord', 'teams', 'telegram'])