Link existing bots from the workspace rail's "+"

The "+" beside "Workspace bots" now opens a modal listing the crew's
workers not in the workspace yet (api/admin/workspaces/linkable-bots),
each added in one click, with "New bot" stacking the bot form over it.
Adding a bot to a workspace now pushes server:bot-created so other
rails pick it up live.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-19 20:37:25 +01:00
co-authored by Claude Opus 5.5
parent 139b3e443d
commit bf7109952b
8 changed files with 282 additions and 15 deletions
+1 -1
View File
@@ -160,7 +160,7 @@ Live routes under `/admin` (see `AdminSections`): `/admin/bots`, `/admin/workspa
- `/admin/settings/backups` - super admin only (listed in `SettingsPages`): create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/` - `/admin/settings/backups` - super admin only (listed in `SettingsPages`): create/list/download database snapshots in `db/.backups`, plus `restore/` (restore a listed backup, or upload a SQLite database file which is saved to `.backups` then restored). Shells out to the `bun-sqlite` CLI (`backup`, and `import <file>` since `restore` is interactive) via `src/functions/backend/backups/`; API routes in `api/admin/backups/`
- `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/` and `tools/` (see MCPs and custom tools: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`)), each row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry - `/admin/settings` (live) - redirects to `SettingsDefaultPath` (`/admin/settings/general`). Sub pages: `general/` (theme: System/Light/Dark per device via `useThemePreference`, stored in `localStorage[AppData.ThemeStorageKey]`, the same key `ThemeInitScript` and twui's `ColorSchemeSelector` read; applies instantly, no save bar), `profile/` (current user profile), `notifications/` (this device's Web Push switch, a test send, and every device the user subscribed), `change-password/` (set/change own password) `models/` (super admin only, `super_admin_only` in the dict hides it from the rail: model providers and which model each harness uses) and `environment/` (super admin only: app settings and the envs bots and computers use, per scope), each wrapped in `SettingsShell` (`(partials)/settings-shell.tsx`: 64px pane header like the conversation header, then one centered column). Content is built from `SettingsGroup` blocks (label + description left, controls right, hairline-separated, no cards) and ends with the sticky `SettingsSaveBar` (Save enabled only when dirty, Discard, one-line status). Saves don't reload the page. Keep save notices in component state: `useFormInit` clears `status` whenever the form changes. `bots/` (the crew: chat, edit with `BotForm`, models, delete a worker via `api/admin/bots/delete-bot`), `workspaces/` (open, create through the Add flow, delete), `computers/` (state and resources, live; super admins create, edit with `AddComputerForm`, retry, delete), `mcps/` and `tools/` (see MCPs and custom tools: super admins add, edit, refresh or try, delete; admins only set Access, `extension-access-modal.tsx`; add, edit, access and try open in modals (`AddModalShell`)), each row with a status line under it (`extension-status-line.tsx`: connected or not, last run worked or failed, when, and why), those five for `canManageCrew` only (`grab-crew-settings-server-props.ts`, `can_view` in the dict) and `users/` (for `canListUsers`: super admins and admins; the dict's `can_view` gates it in the rail; add with a password (`canCreateUser`), edit (`canUpdateUser`), both with the one `UserForm` (`admin/settings/users/(partials)/user-form/`, `useFormInit`, photo included, saved in one request), revoke (adds `revoked`, `canRevokeUser`), delete (`canRevokeUser`'s rules, never yourself), pick their user types from `UserTypes` (`user-types-select.tsx`: only the ones `filterAssignableUserTypes` allows, never `UneditableUserTypes`; at least one, since login needs a type; `saveUser` checks each grant and removal with `userTypeAssignment`; the super admin's types are never shown or changed there, and they can't be revoked; the password is entered twice, `SaveUserRequestSchema` checks they match), delete with everything theirs; `api/admin/users/save-user`, `delete-user`). The lists share `SettingsList`, `SettingsListRow`, `SettingsRowAction` and `use-settings-list`. Sub pages are declared once in `src/dict/settings-pages-dict.ts`; on every `/admin/settings/*` page the rail swaps the crew for a back link plus that list. Add a sub page by adding its folder and a dict entry
Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`). Every `/admin/*` page renders in the bots layout (`src/layouts/bots/`); there is no separate admin layout or aside. The rail header's Add icon (super admins and admins only, like the rail's "+" buttons) opens the Add flow (`(partials)/add-flow.tsx`, `AddFlowContext`): a bot, a workspace or (super admins) a computer, each form in a modal stacked over the choice, so closing it goes back there (the Bots and Workspaces labels have a "+" that opens the form directly; in a workspace's rail, "Workspace bots" has a "+" that opens `link-bots-modal.tsx` instead: the crew's workers not in it yet (`api/admin/workspaces/linkable-bots`), each added with `add-bot`, which pushes `server:bot-created`, and "New bot" stacking the bot form over it); on a workspace's pages new bots and computers join it. The rail shows the crew (`(partials)/rail-crew-list.tsx`, needs `bots` and `workspaces` in page props): the coordinator, the latest `AppData.RailRecentBots` workers (plus any open, unread or needing you, `pick-rail-bots`), "See all bots" with a few stones opening every bot in a modal, then Workspaces with a "+". On `/admin/workspaces/*` it shows the workspace's crew instead (`(partials)/rail-workspace-list.tsx`: back link, the workspace, then only the bots attached to it, "Workspace bots"; the coordinator stays in the main crew rail, and on a workspace bot's page the back link returns to the workspace page), except on `/admin/settings/*` where it shows "Back to bots" plus the settings sub pages (`(partials)/rail-settings-list.tsx`). Page heroes use the shared `AdminHero` component (`src/components/general/admin-hero.tsx`).
## Page Directory Rules ## Page Directory Rules
@@ -11,6 +11,11 @@ export type AddFlow = {
* a workspace page's "New computer"). * a workspace page's "New computer").
*/ */
openForm: (params: { kind: AddKind }) => void; openForm: (params: { kind: AddKind }) => void;
/**
* Opens the link-bots modal of the workspace being viewed (the
* workspace rail's "+"), whose "New bot" opens the bot form over it.
*/
openLinkBots: () => void;
}; };
/** /**
@@ -19,4 +24,5 @@ export type AddFlow = {
export const AddFlowContext = React.createContext<AddFlow>({ export const AddFlowContext = React.createContext<AddFlow>({
openAdd: () => {}, openAdd: () => {},
openForm: () => {}, openForm: () => {},
openLinkBots: () => {},
}); });
+30 -11
View File
@@ -11,6 +11,7 @@ import AddComputerForm from "./add-computer-form";
import AddModalShell from "./add-modal-shell"; import AddModalShell from "./add-modal-shell";
import AddNotice from "./add-notice"; import AddNotice from "./add-notice";
import AddWorkspaceForm from "./add-workspace-form"; import AddWorkspaceForm from "./add-workspace-form";
import LinkBotsModal from "./link-bots-modal";
type Props = { type Props = {
children: ReactNode; children: ReactNode;
@@ -25,12 +26,13 @@ type Props = {
export default function AddFlow({ children }: Props) { export default function AddFlow({ children }: Props) {
const { pageProps } = useContext(AppContext); const { pageProps } = useContext(AppContext);
const [choosing, setChoosing] = useState(false); const [choosing, setChoosing] = useState(false);
const [linking, setLinking] = useState(false);
const [form, setForm] = useState<AddKind | null>(null); const [form, setForm] = useState<AddKind | null>(null);
const [notice, setNotice] = useState<string>(); const [notice, setNotice] = useState<string>();
/** /**
* twui closes every open modal on Escape. While a form is open over the * twui closes every open modal on Escape. While a form is open over the
* Add modal, the Add modal is remounted open instead, so only the form * Add modal (or the link-bots modal), that modal is remounted open
* closes. * instead, so only the form closes.
*/ */
const form_ref = useRef<AddKind | null>(null); const form_ref = useRef<AddKind | null>(null);
const [chooser_key, setChooserKey] = useState(0); const [chooser_key, setChooserKey] = useState(0);
@@ -49,25 +51,32 @@ export default function AddFlow({ children }: Props) {
setForm(kind); setForm(kind);
}; };
/**
* Closes a modal the forms open over, unless a form is open on top.
*/
const underForm = (setter: (open: boolean) => void) => (open: boolean) => {
if (!open && form_ref.current) {
setChooserKey((prev) => prev + 1);
return;
}
setter(open);
};
const close = () => { const close = () => {
form_ref.current = null; form_ref.current = null;
setForm(null); setForm(null);
}; };
return ( return (
<AddFlowContext.Provider value={{ openAdd: () => setChoosing(true), openForm }}> <AddFlowContext.Provider
value={{ openAdd: () => setChoosing(true), openForm, openLinkBots: () => setLinking(true) }}
>
{children} {children}
<AddModalShell <AddModalShell
key={chooser_key} key={chooser_key}
open={choosing} open={choosing}
setOpen={(open) => { setOpen={underForm(setChoosing)}
if (!open && form_ref.current) {
setChooserKey((prev) => prev + 1);
return;
}
setChoosing(open);
}}
title="Add" title="Add"
description={ description={
workspace?.id workspace?.id
@@ -85,6 +94,16 @@ export default function AddFlow({ children }: Props) {
))} ))}
</Stack> </Stack>
</AddModalShell> </AddModalShell>
{workspace?.id ? (
<LinkBotsModal
key={`link-${chooser_key}`}
open={linking}
setOpen={underForm(setLinking)}
workspace_id={Number(workspace.id)}
workspace_name={workspace.name}
onNewBot={() => openForm({ kind: "bot" })}
/>
) : null}
<AddModalShell <AddModalShell
open={form == "bot"} open={form == "bot"}
setOpen={(open) => !open && close()} setOpen={(open) => !open && close()}
@@ -0,0 +1,46 @@
import Button from "@moduletrace/twui/layout/Button";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import BotAvatar from "@/src/components/general/bot-avatar";
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
import type { BotWithLastMessage } from "@/src/types";
type Props = {
bot: BotWithLastMessage;
linking?: boolean;
disabled?: boolean;
onLink: () => void;
};
/**
* One bot in the link-bots modal: stone, name, job, and Add.
*/
export default function LinkBotRow({ bot, linking, disabled, onLink }: Props) {
return (
<Row className="h-14 items-center gap-3 rounded-default px-3 flex-nowrap hover:bg-sunken transition-colors duration-[var(--m-fast)]">
<BotAvatar
image_media_id={bot.image_media_id}
hue={(bot.hue || null) as BotHue | null}
face={(bot.face || "idle") as BotFace}
/>
<Stack className="grow min-w-0 gap-0!">
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">{bot.name}</Span>
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">{bot.role || ""}</Span>
</Stack>
<Button
title={`Add ${bot.name || "this bot"}`}
type="button"
variant="ghost"
color="gray"
size="small"
loading={linking}
disabled={disabled}
onClick={onLink}
className="shrink-0"
>
Add
</Button>
</Row>
);
}
@@ -0,0 +1,125 @@
import { useEffect, useState } from "react";
import Input from "@moduletrace/twui/form/Input";
import Button from "@moduletrace/twui/layout/Button";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import { Plus } from "lucide-react";
import type { BotWithLastMessage, WorkspaceBot } from "@/src/types";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
import postAdminApi from "../(functions)/post-admin-api";
import AddModalShell from "./add-modal-shell";
import LinkBotRow from "./link-bot-row";
type Props = {
open: boolean;
setOpen: (open: boolean) => void;
workspace_id: number;
workspace_name?: string | null;
/**
* Opens the Add-a-bot form over this modal.
*/
onNewBot: () => void;
};
/**
* The workspace rail's "+": the crew's workers not in the workspace yet,
* each added with one click, and "New bot" for one that doesn't exist.
* Adding reloads the page, so the rail and the dashboard show the new crew.
*/
export default function LinkBotsModal({ open, setOpen, workspace_id, workspace_name, onNewBot }: Props) {
const [bots, setBots] = useState<BotWithLastMessage[]>();
const [query, setQuery] = useState("");
const [linking_id, setLinkingId] = useState<number>();
const [error, setError] = useState<string>();
useEffect(() => {
if (!open) return;
setError(undefined);
postAdminApi<BotWithLastMessage>({ path: "workspaces/linkable-bots", body: { workspace_id } }).then((res) => {
if (res.success) {
setBots(res.payload || []);
return;
}
setBots([]);
setError(res.msg || "Couldn't load the bots.");
});
}, [open, workspace_id]);
const link = async ({ bot }: { bot: BotWithLastMessage }) => {
if (!window.confirm(`Add ${bot.name || "this bot"} to ${workspace_name || "this workspace"}?`)) return;
setLinkingId(Number(bot.id));
setError(undefined);
const res = await postAdminApi<WorkspaceBot>({
path: "workspaces/add-bot",
body: { workspace_id, bot_id: bot.id },
});
if (res.success) {
window.location.reload();
return;
}
setLinkingId(undefined);
setError(res.msg || "That didn't work.");
};
const search = query.trim().toLowerCase();
const found = (bots || []).filter(
(bot) => !search || `${bot.name} ${bot.role || ""}`.toLowerCase().includes(search),
);
return (
<AddModalShell
open={open}
setOpen={setOpen}
title={`Add bots to ${workspace_name || "this workspace"}`}
description="Bots here work with the workspace's context, computers and models."
>
{bots?.length ? (
<Input
id="link-bots-search"
title="Search bots"
placeholder="Search by name or job"
autoComplete="off"
defaultValue={query}
onChange={(e) => setQuery(e.target.value)}
wrapperWrapperProps={{ className: "mt-4" }}
/>
) : null}
<Stack className="w-full mt-2 max-h-[50vh] overflow-y-auto gap-0! items-stretch">
{found.map((bot) => (
<LinkBotRow
key={bot.id}
bot={bot}
linking={linking_id == bot.id}
disabled={Boolean(linking_id)}
onLink={() => link({ bot })}
/>
))}
{!bots || found.length ? null : (
<Span className="px-3 py-6 text-[13px] leading-[18px] text-ink-secondary">
{search ? `No bot matches "${query.trim()}".` : "Every bot is already here."}
</Span>
)}
</Stack>
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
<Button
title="Create a new bot for this workspace"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={onNewBot}
className="self-start gap-1! mt-3"
>
<Plus size={14} aria-hidden />
New bot
</Button>
</AddModalShell>
);
}
@@ -27,7 +27,7 @@ type Props = {
* workspace's context. The coordinator is reached from the main rail. * workspace's context. The coordinator is reached from the main rail.
*/ */
export default function RailWorkspaceList({ workspace, bots, selected_id, on_workspace_page }: Props) { export default function RailWorkspaceList({ workspace, bots, selected_id, on_workspace_page }: Props) {
const { openForm } = useContext(AddFlowContext); const { openLinkBots } = useContext(AddFlowContext);
const workers = bots.filter((bot) => bot.kind !== "coordinator"); const workers = bots.filter((bot) => bot.kind !== "coordinator");
const base = `/admin/workspaces/${workspace.id}`; const base = `/admin/workspaces/${workspace.id}`;
@@ -40,7 +40,7 @@ export default function RailWorkspaceList({ workspace, bots, selected_id, on_wor
<Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" /> <Divider className="shrink-0 mx-1 @rail:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
<WorkspaceRow workspace={{ ...workspace, unread: 0 }} selected={on_workspace_page} /> <WorkspaceRow workspace={{ ...workspace, unread: 0 }} selected={on_workspace_page} />
<RailBotsSection <RailBotsSection
action={<RailSectionAddButton title="Add a bot to this workspace" onClick={() => openForm({ kind: "bot" })} />} action={<RailSectionAddButton title="Add bots to this workspace" onClick={openLinkBots} />}
title="Workspace bots" title="Workspace bots"
workers={workers} workers={workers}
selected_id={on_workspace_page ? undefined : selected_id} selected_id={on_workspace_page ? undefined : selected_id}
+14 -1
View File
@@ -1,3 +1,6 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
import pushBotCreated from "@/src/bots-queue/push-bot-created";
import userAuth from "@/src/functions/backend/auth/user-auth"; import userAuth from "@/src/functions/backend/auth/user-auth";
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace"; import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace";
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots"; import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
@@ -14,7 +17,8 @@ import type {
/** /**
* Puts one of the session user's bots in one of their workspaces. Returns * Puts one of the session user's bots in one of their workspaces. Returns
* the workspace's crew. * the workspace's crew, and pushes the bot to everyone who now sees it
* there.
*/ */
export const handler: BunextAPIRouteHandler<APIResponseObject<WorkspaceBot>> = async ({ req, body }) => { export const handler: BunextAPIRouteHandler<APIResponseObject<WorkspaceBot>> = async ({ req, body }) => {
try { try {
@@ -36,6 +40,15 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<WorkspaceBot>> = a
await addBotToWorkspace({ user_id: user.id, workspace_id, bot_id }); await addBotToWorkspace({ user_id: user.id, workspace_id, bot_id });
const bot = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE id = ? LIMIT 1`,
values: [bot_id],
})
).payload?.[0];
if (bot) await pushBotCreated({ bot });
return { return {
success: true, success: true,
payload: await grabWorkspaceBots({ user_id: user.id, workspace_id }), payload: await grabWorkspaceBots({ user_id: user.id, workspace_id }),
@@ -0,0 +1,58 @@
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
import type { BotWithLastMessage } from "@/src/types";
import { WorkspaceIdRequestSchema } from "@/src/schemas/workspace-schema";
import AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import safeErrorMessage from "@/src/utils/safe-error-message";
import type {
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/**
* The crew's workers that aren't in a workspace yet, for the workspace
* rail's "+". Super admins and admins only.
*/
export const handler: BunextAPIRouteHandler<APIResponseObject<BotWithLastMessage>> = async ({ req, body }) => {
try {
if (req.method != "POST") {
throw new AppError(`Method not allowed`);
}
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { success: false, msg: `Unauthorized`, logoutUser: true };
}
if (!canManageCrew({ user_types })) {
throw new AppError(`Only admins add bots to workspaces.`);
}
const { workspace_id } = parseWithSchema({ schema: WorkspaceIdRequestSchema, data: body });
const workspace = await grabUserWorkspace({ workspace_id, user_id: user.id });
if (!workspace?.id) {
throw new AppError(`Workspace not found.`);
}
const [bots, members] = await Promise.all([
grabBots({ user_id: user.id }),
grabWorkspaceBots({ user_id: user.id, workspace_id }),
]);
return {
success: true,
payload: bots.filter(
(bot) => bot.kind != "coordinator" && !members.some((member) => member.id == bot.id),
),
};
} catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) };
}
};