Let bots manage their hooks, with the user confirming what widens them

Bots get list_hooks, pause_hook and delete_hook, which act directly since
they only take away. A new hook, or a new name, brief or filter, or
switching a paused one back on, goes through propose_hook: it stores a
hook_proposals row and puts a [hook-proposal:ID] card in the reply, which
the user it was proposed to confirms or rejects. The card is read from
what Confirm saves, with the bot's reason labelled as its own; confirming
checks everything again and saves through the Hooks modal's helpers. A
new hook's URL is shown on the card once, to the user only, never in the
outcome the bot is told. What calls a hook, its computers and its signing
are never proposed. Only chat replies propose, and a hook call's own run
gets no hook tools, so a caller's payload can't reach them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-25 15:18:54 +01:00
co-authored by Claude Opus 5.5
parent 045167715d
commit 380d3ebb2f
54 changed files with 1983 additions and 55 deletions
@@ -0,0 +1,33 @@
import { z } from "zod";
import deleteBotHook from "@/src/functions/backend/db/bot-hooks/delete-bot-hook";
import type { HarnessTool } from "../../../types";
import grabOwnHook from "./grab-own-hook";
type Params = {
user_id: number | string;
bot_id: number | string;
};
type Args = {
hook_id: number;
};
/**
* Lets a bot delete one of its hooks for good.
*/
export default function deleteHookTool({ user_id, bot_id }: Params): HarnessTool<Args> {
return {
name: "delete_hook",
description: `Delete one of your hooks for good (ids from list_hooks): its URL stops working and can't be brought back, and whatever calls it gets refused. Only when the user asks for it; to stop it for a while, pause_hook instead.`,
schema: z.object({
hook_id: z.number().int().positive(),
}),
run: async ({ args }) => {
const hook = await grabOwnHook({ user_id, bot_id, hook_id: args.hook_id });
await deleteBotHook({ user_id, hook_id: hook.id! });
return `Hook #${hook.id} "${hook.title}" deleted.`;
},
};
}
@@ -0,0 +1,30 @@
import type { HookProposalView } from "@/src/types";
import hookProposalRef from "@/src/utils/hook-proposal-ref";
type Params = {
view: HookProposalView;
};
/**
* What a bot is told once it proposed a hook: the ref to put in its
* reply, and what the card shows (worked out by the app), so it tells the
* user the same thing the card does.
*/
export default function describeHookProposalForBot({ view }: Params) {
const ref = hookProposalRef({ id: view.id });
const shows = view.details
.filter((detail) => !detail.block)
.map((detail) => `${detail.label}: ${detail.value}`)
.join("; ");
return [
`Proposed as ${ref}. Nothing is saved yet: the user confirms or rejects it from the card ${ref} becomes in your reply, so put ${ref} in your reply.`,
`The card shows ${shows}${view.details.some((detail) => detail.block) ? ", and the whole brief" : ""}.`,
view.makes_url
? `Once confirmed, the card shows the user its URL, once. You never see it: never ask for it. Tell them to give it to the service that will call it, and that they can require signed calls from the Hooks menu.`
: "",
`Don't say it's done: the user's next message says whether they confirmed it and what happened.`,
]
.filter(Boolean)
.join("\n");
}
@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test";
import grabBotHookTools from "./grab-bot-hook-tools";
const names = (params: Partial<Parameters<typeof grabBotHookTools>[0]>) =>
grabBotHookTools({ user_id: 1, bot_id: 2, computers: [], ...params }).map((tool) => tool.name);
describe("grabBotHookTools", () => {
test("a hook call's own run gets no hook tools", () => {
expect(names({ hooked: true, session_id: 3, onPropose: () => {} })).toEqual([]);
});
test("a run without a chat to confirm in can't propose", () => {
expect(names({ session_id: 3 })).toEqual(["list_hooks", "pause_hook", "delete_hook"]);
});
test("a chat reply can propose", () => {
expect(names({ session_id: 3, onPropose: () => {} })).toContain("propose_hook");
});
test("a proposal never takes a signing secret", () => {
const propose = grabBotHookTools({
user_id: 1,
bot_id: 2,
computers: [],
session_id: 3,
onPropose: () => {},
}).find((tool) => tool.name == "propose_hook")!;
const parsed = propose.schema!.parse({
title: "GitHub pushes",
instructions: "Summarise each push.",
signing_secret: "abcdefgh",
reason: "The user asked for push summaries.",
});
expect(parsed).not.toHaveProperty("signing_secret");
});
});
@@ -0,0 +1,52 @@
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import type { HarnessTool } from "../../../types";
import deleteHookTool from "./delete-hook-tool";
import listHooksTool from "./list-hooks-tool";
import pauseHookTool from "./pause-hook-tool";
import proposeHookTool from "./propose-hook-tool";
type Params = {
user_id: number | string;
bot_id: number | string;
session_id?: number | string;
workspace_id?: number | string | null;
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
* The run is a hook call: what started it came from outside, so it
* gets no hook tools at all.
*/
hooked?: boolean;
/**
* Called with each proposal's ref, so the reply carries its card. Only
* a chat reply proposes: someone is there to confirm.
*/
onPropose?: (params: { ref: string }) => void;
};
/**
* A bot's tools for the user's hooks it runs: read them, pause or delete
* one (that only takes away), and in a chat reply propose a new one or a
* change (the user confirms it from a card; a new one's URL goes to them
* alone). A hook call's own run gets none, so a caller's payload can never
* reach them.
*/
export default function grabBotHookTools({
user_id,
bot_id,
session_id,
workspace_id,
computers,
hooked,
onPropose,
}: Params): HarnessTool[] {
if (hooked) return [];
return [
listHooksTool({ user_id, bot_id }),
pauseHookTool({ user_id, bot_id }),
deleteHookTool({ user_id, bot_id }),
...(session_id && onPropose
? [proposeHookTool({ user_id, bot_id, session_id, workspace_id, computers, onPropose })]
: []),
];
}
@@ -0,0 +1,22 @@
import grabUserBotHook from "@/src/functions/backend/db/bot-hooks/grab-user-bot-hook";
import AppError from "@/src/utils/app-error";
type Params = {
user_id: number | string;
bot_id: number | string;
hook_id: number | string;
};
/**
* One of the user's hooks that this bot runs, or an `AppError`: a bot
* never touches another bot's hooks.
*/
export default async function grabOwnHook({ user_id, bot_id, hook_id }: Params) {
const hook = await grabUserBotHook({ user_id, hook_id });
if (String(hook.bot_id) != String(bot_id)) {
throw new AppError(`Hook #${hook_id} isn't one of yours.`);
}
return hook;
}
@@ -0,0 +1,28 @@
import { z } from "zod";
import grabBotHooks from "@/src/functions/backend/db/bot-hooks/grab-bot-hooks";
import describeBotHook from "@/src/utils/describe-bot-hook";
import type { HarnessTool } from "../../../types";
type Params = {
user_id: number | string;
bot_id: number | string;
};
/**
* Lets a bot see the user's hooks it runs, everywhere it works, never
* their URLs.
*/
export default function listHooksTool({ user_id, bot_id }: Params): HarnessTool<Record<string, never>> {
return {
name: "list_hooks",
description: `List the user's hooks that give you work: id, name, what calls it (an outside service at its secret URL, or browser notifications on a computer), where it runs, whether it's paused, its runs, last call and last error. You never see a hook's URL: only the user does.`,
schema: z.object({}),
run: async () => {
const hooks = await grabBotHooks({ user_id, bot_id });
if (!hooks.length) return `You have no hooks.`;
return hooks.map((hook) => `- ${describeBotHook({ hook })}`).join("\n");
},
};
}
@@ -0,0 +1,40 @@
import { z } from "zod";
import updateBotHook from "@/src/functions/backend/db/bot-hooks/update-bot-hook";
import describeBotHook from "@/src/utils/describe-bot-hook";
import type { HarnessTool } from "../../../types";
import grabOwnHook from "./grab-own-hook";
type Params = {
user_id: number | string;
bot_id: number | string;
};
type Args = {
hook_id: number;
};
/**
* Lets a bot pause one of its hooks: calls are refused until it's on
* again. Only taking away needs no confirming; switching it back on is a
* proposal (`propose_hook`).
*/
export default function pauseHookTool({ user_id, bot_id }: Params): HarnessTool<Args> {
return {
name: "pause_hook",
description: `Pause one of your hooks (ids from list_hooks): calls to it are refused until it's switched back on. Use it when the user asks, or when a hook keeps sending work that fails or that the user doesn't want. Switching it back on needs the user's confirmation: propose it with propose_hook (hook_id, enabled true).`,
schema: z.object({
hook_id: z.number().int().positive(),
}),
run: async ({ args }) => {
const hook = await grabOwnHook({ user_id, bot_id, hook_id: args.hook_id });
if (!hook.enabled) return `Hook #${hook.id} "${hook.title}" is already paused.`;
const paused = await updateBotHook({ user_id, hook_id: hook.id!, data: { enabled: false } });
if (paused.enabled) throw new Error(`Hook #${hook.id} is still on: pausing it didn't take.`);
return `Hook paused: ${describeBotHook({ hook: paused })}.`;
},
};
}
@@ -0,0 +1,101 @@
import { z } from "zod";
import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
import { BotHookSources, type BotHookSource } from "@/src/dict/bot-hook-dict";
import createHookProposal from "@/src/functions/backend/db/hook-proposals/create-hook-proposal";
import grabHookProposalView from "@/src/functions/backend/db/hook-proposals/grab-hook-proposal-view";
import hookProposalRef from "@/src/utils/hook-proposal-ref";
import type { HarnessTool } from "../../../types";
import describeHookProposalForBot from "./describe-hook-proposal-for-bot";
type Params = {
user_id: number | string;
bot_id: number | string;
/**
* The conversation it's proposed in, where its card shows.
*/
session_id: number | string;
/**
* Where the run is: a new hook runs there too.
*/
workspace_id?: number | string | null;
/**
* The bot's computers where it runs, the current one first.
*/
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
/**
* Called with the proposal's ref, so the reply always carries its card.
*/
onPropose: (params: { ref: string }) => void;
};
type Args = {
hook_id?: number;
title?: string;
instructions?: string;
source?: BotHookSource;
computer_id?: number;
watch_computer_id?: number;
watch_filter?: string;
enabled?: true;
reason: string;
};
/**
* Lets a bot propose a hook, or a change to one of its own (name, brief,
* filter, switching it back on), for the user to confirm from a card in
* the chat. A hook runs its brief on its own whenever something outside
* calls it, so the user writes it off, never the bot: nothing is saved
* until they confirm, and a new hook's URL is shown to them alone.
*/
export default function proposeHookTool({
user_id,
bot_id,
session_id,
workspace_id,
computers,
onPropose,
}: Params): HarnessTool<Args> {
const listed = computers.map((computer) => `${computer.id} (${computer.name || computer.kind})`).join(", ");
return {
name: "propose_hook",
description: `Propose a hook for the user to confirm: a secret URL an outside service (a webhook from GitHub, Stripe, a form, a script) calls to give you a task, or a watcher that hands you each browser notification on one of your computers (source browser_notifications, with watch_computer_id and an optional watch_filter, text the notification's site, title or body must contain). Every call queues your instructions (the brief) as a task for you, ${
workspace_id ? "in this workspace" : "in your own chat"
}, with what the caller sent attached as data, and the result is posted here. Runs carry the brief out without asking the user, so write it as a full brief for a future you with no memory of this chat, saying exactly what to do with each call. ${
computers.length ? `Your computers here: ${listed}.` : `You have no computer here, so it can't watch one.`
} To change one of your hooks (ids from list_hooks), give hook_id and only what changes: title, instructions, watch_filter, or enabled true to switch a paused one back on; what calls it and where it runs can't change. Nothing is saved until the user presses Confirm on the card. A new hook's URL is shown to the user on the card, once: you never see it, never ask for it, and never set a signing secret (the user adds one from the Hooks menu). Check list_hooks first: if a hook already does the job, propose a change to it instead.`,
schema: z.object({
hook_id: z.number().int().positive().optional().describe("The hook to change; leave out for a new one"),
title: z.string().trim().min(1).max(80).optional().describe(`A short name, e.g. "GitHub pushes"`),
instructions: z.string().trim().min(1).max(8000).optional().describe("The brief every call runs"),
source: z.enum(BotHookSources.map((s) => s.value) as [BotHookSource, ...BotHookSource[]]).optional(),
computer_id: z.number().int().positive().optional().describe("The computer its runs use; leave out for your current one"),
watch_computer_id: z.number().int().positive().optional(),
watch_filter: z.string().trim().max(300).optional(),
enabled: z.literal(true).optional(),
reason: z
.string()
.trim()
.min(10, "Say why you propose it")
.max(2000)
.describe("Why you propose it and what the user asked for, shown on the card as your words"),
}),
run: async ({ args }) => {
const { hook_id, reason, ...data } = args;
const id = await createHookProposal({
user_id,
bot_id,
session_id,
workspace_id,
hook_id,
data,
reason,
});
onPropose({ ref: hookProposalRef({ id }) });
return describeHookProposalForBot({ view: await grabHookProposalView({ proposal_id: id, user_id }) });
},
};
}