Let outside services and computers' browser notifications give bots work

A bot hook (bot_hooks) is a secret URL, POST /api/hooks/<token>, whose
calls queue the hook's brief plus the raw body as a task, per user and
bot like a routine. Only the token's hash is kept (shown once, rotatable);
an optional HMAC-SHA256 signing secret is stored encrypted. Calls are
rate limited per address and per hook, recorded in bot_hook_events
(pruned daily), deduplicated by delivery id, and a call while the last
run still waits joins its brief instead of queueing another. The payload
is fenced data and HookRunNote tells the bot never to take instructions
from it; failed runs retry like routines.

A hook can instead watch a computer's browser notifications: the app
installs a small watcher that owns org.freedesktop.Notifications on the
sandbox's session bus, so Chrome hands it every notification (it only
looks for that service at start-up, so Chrome is restarted once, never
mid-task), and it posts each one to its hooks, optionally filtered.
Computers reach the app at COMPUTER_APP_URL or the app container's name.

Users make hooks in the conversation's Hooks modal and manage them all
on /admin/routines; bots never create or change hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 14:06:59 +01:00
co-authored by Claude Opus 5.5
parent a596722e59
commit bd9744043e
69 changed files with 3777 additions and 18 deletions
@@ -0,0 +1,53 @@
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { useCallback, useEffect, useState } from "react";
import type { BotHookWithNames } from "@/src/types";
type Params = {
bot_id: number;
workspace_id?: number | null;
/**
* Load only once the modal opens, so the conversation's page props stay
* small.
*/
enabled?: boolean;
};
/**
* A conversation's hooks of the user: a bot's own in its chat or the ones
* that run in the workspace. Loaded on open, never part of the page props.
*/
export default function useBotHooks({ bot_id, workspace_id, enabled = true }: Params) {
const [hooks, setHooks] = useState<BotHookWithNames[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string>();
const load = useCallback(async () => {
setLoading(true);
setError(undefined);
const query = new URLSearchParams({ bot_id: String(bot_id) });
if (workspace_id) query.set("workspace_id", String(workspace_id));
const res = await fetchApi<{}, APIResponseObject<BotHookWithNames>>(
`/api/admin/hooks/list-hooks?${query.toString()}`,
{ method: "GET" },
).catch(() => null);
setLoading(false);
if (!res?.success) {
setError(res?.msg || `Couldn't load the hooks.`);
return;
}
setHooks(res.payload || []);
}, [bot_id, workspace_id]);
useEffect(() => {
if (enabled) load().catch(() => {});
}, [enabled, load]);
return { hooks, setHooks, loading, error, reload: load };
}
@@ -0,0 +1,255 @@
import { useContext, useEffect, useState } from "react";
import Modal from "@moduletrace/twui/elements/Modal";
import H2 from "@moduletrace/twui/layout/H2";
import Row from "@moduletrace/twui/layout/Row";
import Span from "@moduletrace/twui/layout/Span";
import Stack from "@moduletrace/twui/layout/Stack";
import Button from "@moduletrace/twui/layout/Button";
import { Plus, Webhook } from "lucide-react";
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotHookWithNames, BotHookWithToken, BotWithLastMessage } from "@/src/types";
import HookCalls from "@/src/components/general/hook-calls";
import HookForm from "@/src/components/general/hook-form";
import HookRow from "@/src/components/general/hook-row";
import HookUrlNotice from "@/src/components/general/hook-url-notice";
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
import { AppContext } from "@/src/pages/__root";
import useBotHooks from "../(hooks)/use-bot-hooks";
import HeaderMenuItem from "./header-menu-item";
type Status = { error?: boolean; success?: boolean; msg?: string };
/**
* What the modal shows besides the list: a form, a URL shown once, or a
* hook's recent calls.
*/
type View =
| { kind: "create" }
| { kind: "edit"; hook: BotHookWithNames }
| { kind: "url"; hook: BotHookWithNames; token: string }
| { kind: "calls"; hook: BotHookWithNames };
type Props = {
bot: BotWithLastMessage;
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
on_open?: () => void;
};
/**
* The user's hooks in this conversation: secret URLs an outside service
* (a webhook, a script) calls to give the bot a task, whose result lands
* here. Open it from the header menu; the list loads then, so the page's
* props stay small. The user makes, pauses, edits, re-keys and deletes
* their own; bots never make hooks.
*/
export default function BotHooksModal({ bot, workspace, on_open }: Props) {
const { pageProps } = useContext(AppContext);
const [open, set_open] = useState(false);
const [view, set_view] = useState<View>();
const [busy, set_busy] = useState(false);
const [status, set_status] = useState<Status>();
const { hooks, setHooks, loading, error } = useBotHooks({
bot_id: Number(bot.id),
workspace_id: workspace?.id ? Number(workspace.id) : null,
enabled: open,
});
const name = bot.name || "This bot";
const place = workspace?.name || "this workspace";
const title = workspace?.id ? `${place} hooks` : `${name}'s hooks`;
useEffect(() => {
if (open) on_open?.();
}, [open]);
const replace = ({ hook }: { hook: BotHookWithNames }) =>
setHooks((prev) => (prev.some((h) => h.id == hook.id) ? prev.map((h) => (h.id == hook.id ? hook : h)) : [hook, ...prev]));
const back = () => {
set_view(undefined);
set_status(undefined);
};
const toggle = async ({ hook }: { hook: BotHookWithNames }) => {
if (busy) return;
set_busy(true);
set_status(undefined);
const res = await postAdminApi<BotHookWithNames>({
path: "hooks/update-hook",
body: { hook_id: hook.id, enabled: !hook.enabled },
});
set_busy(false);
if (!res.success || !res.singleRes) {
set_status({ error: true, msg: res.msg || "That didn't work." });
return;
}
replace({ hook: res.singleRes });
set_status({ success: true, msg: hook.enabled ? "Paused." : "Resumed." });
};
const rotate = async ({ hook }: { hook: BotHookWithNames }) => {
if (busy) return;
if (!window.confirm(`Give "${hook.title}" a new URL? The current one stops working at once.`)) return;
set_busy(true);
set_status(undefined);
const res = await postAdminApi<BotHookWithToken>({ path: "hooks/rotate-hook", body: { hook_id: hook.id } });
set_busy(false);
if (!res.success || !res.singleRes) {
set_status({ error: true, msg: res.msg || "That didn't work." });
return;
}
replace({ hook: res.singleRes.hook });
if (res.singleRes.token) {
set_view({ kind: "url", hook: res.singleRes.hook, token: res.singleRes.token });
} else {
set_status({ success: true, msg: res.msg || "Done." });
}
};
const remove = async ({ hook }: { hook: BotHookWithNames }) => {
if (busy) return;
if (!window.confirm(`Delete the hook "${hook.title}"? Its URL stops working; a run already started carries on.`)) {
return;
}
set_busy(true);
set_status(undefined);
const res = await postAdminApi({ path: "hooks/delete-hook", body: { hook_id: hook.id } });
set_busy(false);
if (!res.success) {
set_status({ error: true, msg: res.msg || "That didn't work." });
return;
}
setHooks((prev) => prev.filter((h) => h.id != hook.id));
set_status({ success: true, msg: "Deleted." });
};
const heading =
view?.kind == "create"
? "New hook"
: view?.kind == "calls"
? `Calls to ${view.hook.title || "the hook"}`
: view
? view.hook.title || "Hook"
: title;
const intro =
view?.kind == "create" || view?.kind == "edit"
? `Every call to the hook's URL gives ${name} this brief as a task, with what the caller sent, and the result lands here.`
: view?.kind == "url"
? `The hook's URL is its key.`
: view?.kind == "calls"
? `The latest calls and what became of each.`
: `Ways for the outside to give ${name} work${workspace?.id ? ` in ${place}` : ""}: a URL a service (a webhook, a script) calls, or the browser notifications on one of its computers. Each call is a task, and its result lands here.`;
return (
<Modal
target={<HeaderMenuItem icon={Webhook} title="Hooks" description={title} loading={loading} />}
targetWrapperProps={{ className: "w-full" }}
open={open}
setOpen={(next) => {
set_open(next);
if (!next) back();
}}
className="max-w-xl bg-paper p-6"
>
<Stack className="w-full gap-1! items-stretch">
<Row className="w-full gap-2! flex-nowrap items-center">
<H2 className="grow text-lg! leading-6! tracking-normal! font-bold text-ink">{heading}</H2>
{view && view.kind != "url" ? (
<Button
title="Back to the list"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={back}
className="px-2! text-[12px]"
>
Back
</Button>
) : !view ? (
<Button
title="Make a hook"
type="button"
variant="ghost"
color="gray"
size="small"
onClick={() => set_view({ kind: "create" })}
className="gap-1! px-2! text-[12px]"
>
<Plus size={14} aria-hidden />
New hook
</Button>
) : null}
</Row>
<Span className="text-[13px] leading-[18px] text-ink-secondary">{intro}</Span>
</Stack>
<Stack className="mt-4 max-h-[55vh] w-full gap-0! items-stretch overflow-y-auto">
{view?.kind == "create" || view?.kind == "edit" ? (
<HookForm
key={view.kind == "edit" ? view.hook.id : "new"}
hook={view.kind == "edit" ? view.hook : undefined}
bot_id={Number(bot.id)}
workspace_id={workspace?.id ? Number(workspace.id) : null}
computers={pageProps?.bot_computers || []}
onCancel={back}
onSaved={({ hook, token, watch, msg }) => {
replace({ hook });
if (token) {
set_view({ kind: "url", hook, token });
return;
}
back();
set_status({ success: !watch || watch.ok, error: Boolean(watch && !watch.ok), msg: msg || "Saved." });
}}
/>
) : view?.kind == "url" ? (
<HookUrlNotice token={view.token} signed={Boolean(view.hook.has_signing_secret)} onDone={back} />
) : view?.kind == "calls" ? (
<HookCalls hook_id={Number(view.hook.id)} />
) : (
<SettingsList is_empty={!hooks.length} empty={loading ? "Loading hooks..." : error || "No hooks here yet."}>
{hooks.map((hook) => (
<HookRow
key={hook.id}
hook={hook}
compact
busy={busy}
onToggle={() => toggle({ hook }).catch(() => {})}
onEdit={() => set_view({ kind: "edit", hook })}
onCalls={() => set_view({ kind: "calls", hook })}
onRotate={() => rotate({ hook }).catch(() => {})}
onDelete={() => remove({ hook }).catch(() => {})}
/>
))}
</SettingsList>
)}
</Stack>
<Stack className="mt-3 gap-1! items-stretch">
<SettingsStatus status={status} />
</Stack>
</Modal>
);
}
@@ -9,10 +9,12 @@ import BotSessionsModal from "@/src/components/general/bot-sessions-modal";
import { AppContext } from "@/src/pages/__root";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
import BotComputersModal from "./bot-computers-modal";
import BotFilesModal from "./bot-files-modal";
import BotTasksModal from "./bot-tasks-modal";
import BotRoutinesModal from "./bot-routines-modal";
import BotHooksModal from "./bot-hooks-modal";
import BotModelsModal from "./bot-models-modal";
import BotAdminMenuItem from "./bot-admin-menu-item";
import BotWorkspaceInstructionsModal from "./bot-workspace-instructions-modal";
@@ -57,7 +59,7 @@ type Props = {
/**
* The conversation header's menu: the sessions, a new session, copying
* the session, the bot's computer, its models, in a workspace its
* the session, its files, tasks, routines and hooks, the bot's computer, its models, in a workspace its
* instructions there and making it the admin there, its
* settings and, while it works, stopping it. A
* row that opens a modal closes the menu first, so the modal never sits
@@ -79,6 +81,10 @@ export default function ConversationHeaderMenu({
bot.kind == "coordinator"
? isPassThroughUser({ user_types: pageProps?.user_types })
: canManageCrew({ user_types: pageProps?.user_types });
/**
* Hooks start work, so a read-only user doesn't get them.
*/
const can_make_hooks = !isReadOnlyUser({ user_types: pageProps?.user_types });
/**
* A bot's instructions for one workspace, only there, for crew
* managers (the coordinator's too: only the super admin sees it).
@@ -150,6 +156,9 @@ export default function ConversationHeaderMenu({
workspace={workspace}
on_open={on_open}
/>
{can_make_hooks ? (
<BotHooksModal bot={bot} workspace={workspace} on_open={on_open} />
) : null}
<BotComputersModal
menu
bot={bot}