diff --git a/docs/cli/hooks.md b/docs/cli/hooks.md index 69136bfe1..825550b05 100644 --- a/docs/cli/hooks.md +++ b/docs/cli/hooks.md @@ -7,7 +7,7 @@ read_when: # `clawdbot hooks` -Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.). +Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, and gateway startup). Related: - Hooks: [Hooks](/hooks) @@ -29,9 +29,10 @@ List all discovered hooks from workspace, managed, and bundled directories. **Example output:** ``` -Hooks (3/3 ready) +Hooks (4/4 ready) Ready: + 🚀 boot-md ✓ - Run BOOT.md on gateway startup 📝 command-logger ✓ - Log all command events to a centralized audit file 💾 session-memory ✓ - Save session context to memory when /new command is issued 😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance @@ -107,8 +108,8 @@ Show summary of hook eligibility status (how many are ready vs. not ready). ``` Hooks Status -Total hooks: 2 -Ready: 2 +Total hooks: 4 +Ready: 4 Not ready: 0 ``` @@ -273,3 +274,17 @@ clawdbot hooks enable soul-evil ``` **See:** [SOUL Evil Hook](/hooks/soul-evil) + +### boot-md + +Runs `BOOT.md` when the gateway starts (after channels start). + +**Events**: `gateway:startup` + +**Enable**: + +```bash +clawdbot hooks enable boot-md +``` + +**See:** [boot-md documentation](/hooks#boot-md) diff --git a/docs/concepts/agent-workspace.md b/docs/concepts/agent-workspace.md index f20320038..33b0e174a 100644 --- a/docs/concepts/agent-workspace.md +++ b/docs/concepts/agent-workspace.md @@ -86,6 +86,10 @@ These are the standard files Clawdbot expects inside the workspace: - Optional tiny checklist for heartbeat runs. - Keep it short to avoid token burn. +- `BOOT.md` + - Optional startup checklist executed on gateway restart when internal hooks are enabled. + - Keep it short; use the message tool for outbound sends. + - `BOOTSTRAP.md` - One-time first-run ritual. - Only created for a brand-new workspace. diff --git a/docs/docs.json b/docs/docs.json index b7ac1375b..c6b30202a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -657,6 +657,10 @@ "source": "/templates/AGENTS", "destination": "/reference/templates/AGENTS" }, + { + "source": "/templates/BOOT", + "destination": "/reference/templates/BOOT" + }, { "source": "/templates/BOOTSTRAP", "destination": "/reference/templates/BOOTSTRAP" @@ -1051,6 +1055,7 @@ "reference/RELEASING", "reference/AGENTS.default", "reference/templates/AGENTS", + "reference/templates/BOOT", "reference/templates/BOOTSTRAP", "reference/templates/HEARTBEAT", "reference/templates/IDENTITY", diff --git a/docs/hooks.md b/docs/hooks.md index 030f8fedf..fa1780ec0 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -37,10 +37,11 @@ The hooks system allows you to: ### Bundled Hooks -Clawdbot ships with three bundled hooks that are automatically discovered: +Clawdbot ships with four bundled hooks that are automatically discovered: - **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new` - **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log` +- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled) - **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance List available hooks: @@ -195,7 +196,7 @@ Each event includes: ```typescript { - type: 'command' | 'session' | 'agent', + type: 'command' | 'session' | 'agent' | 'gateway', action: string, // e.g., 'new', 'reset', 'stop' sessionKey: string, // Session identifier timestamp: Date, // When the event occurred @@ -228,6 +229,12 @@ Triggered when agent commands are issued: - **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`) +### Gateway Events + +Triggered when the gateway starts: + +- **`gateway:startup`**: After channels start and hooks are loaded + ### Future Events Planned event types: @@ -542,6 +549,26 @@ clawdbot hooks enable soul-evil } ``` +### boot-md + +Runs `BOOT.md` when the gateway starts (after channels start). +Internal hooks must be enabled for this to run. + +**Events**: `gateway:startup` + +**Requirements**: `workspace.dir` must be configured + +**What it does**: +1. Reads `BOOT.md` from your workspace +2. Runs the instructions via the agent runner +3. Sends any requested outbound messages via the message tool + +**Enable**: + +```bash +clawdbot hooks enable boot-md +``` + ## Best Practices ### Keep Handlers Fast @@ -614,6 +641,7 @@ The gateway logs hook loading at startup: ``` Registered hook: session-memory -> command:new Registered hook: command-logger -> command +Registered hook: boot-md -> gateway:startup ``` ### Check Discovery diff --git a/docs/reference/templates/BOOT.md b/docs/reference/templates/BOOT.md new file mode 100644 index 000000000..952224476 --- /dev/null +++ b/docs/reference/templates/BOOT.md @@ -0,0 +1,9 @@ +--- +summary: "Workspace template for BOOT.md" +read_when: + - Adding a BOOT.md checklist +--- +# BOOT.md + +Add short, explicit instructions for what Clawdbot should do on startup (enable `hooks.internal.enabled`). +If the task sends a message, use the message tool and then reply with NO_REPLY. diff --git a/src/gateway/boot.test.ts b/src/gateway/boot.test.ts new file mode 100644 index 000000000..b4a9073a0 --- /dev/null +++ b/src/gateway/boot.test.ts @@ -0,0 +1,71 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const agentCommand = vi.fn(); + +vi.mock("../commands/agent.js", () => ({ agentCommand })); + +const { runBootOnce } = await import("./boot.js"); +const { resolveMainSessionKey } = await import("../config/sessions/main-session.js"); + +describe("runBootOnce", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const makeDeps = () => ({ + sendMessageWhatsApp: vi.fn(), + sendMessageTelegram: vi.fn(), + sendMessageDiscord: vi.fn(), + sendMessageSlack: vi.fn(), + sendMessageSignal: vi.fn(), + sendMessageIMessage: vi.fn(), + }); + + it("skips when BOOT.md is missing", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-")); + await expect( + runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }), + ).resolves.toEqual({ status: "skipped", reason: "missing" }); + expect(agentCommand).not.toHaveBeenCalled(); + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("skips when BOOT.md is empty", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-")); + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), " \n", "utf-8"); + await expect( + runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }), + ).resolves.toEqual({ status: "skipped", reason: "empty" }); + expect(agentCommand).not.toHaveBeenCalled(); + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); + + it("runs agent command when BOOT.md exists", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-boot-")); + const content = "Say hello when you wake up."; + await fs.writeFile(path.join(workspaceDir, "BOOT.md"), content, "utf-8"); + + agentCommand.mockResolvedValue(undefined); + await expect( + runBootOnce({ cfg: {}, deps: makeDeps(), workspaceDir }), + ).resolves.toEqual({ status: "ran" }); + + expect(agentCommand).toHaveBeenCalledTimes(1); + const call = agentCommand.mock.calls[0]?.[0]; + expect(call).toEqual( + expect.objectContaining({ + deliver: false, + sessionKey: resolveMainSessionKey({}), + }), + ); + expect(call?.message).toContain("BOOT.md:"); + expect(call?.message).toContain(content); + expect(call?.message).toContain("NO_REPLY"); + + await fs.rm(workspaceDir, { recursive: true, force: true }); + }); +}); diff --git a/src/gateway/boot.ts b/src/gateway/boot.ts new file mode 100644 index 000000000..aa2a13d8f --- /dev/null +++ b/src/gateway/boot.ts @@ -0,0 +1,92 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js"; +import type { CliDeps } from "../cli/deps.js"; +import type { ClawdbotConfig } from "../config/config.js"; +import { resolveMainSessionKey } from "../config/sessions/main-session.js"; +import { agentCommand } from "../commands/agent.js"; +import { createSubsystemLogger } from "../logging.js"; +import { type RuntimeEnv, defaultRuntime } from "../runtime.js"; + +const log = createSubsystemLogger("gateway/boot"); +const BOOT_FILENAME = "BOOT.md"; + +export type BootRunResult = + | { status: "skipped"; reason: "missing" | "empty" } + | { status: "ran" } + | { status: "failed"; reason: string }; + +function buildBootPrompt(content: string) { + return [ + "You are running a boot check. Follow BOOT.md instructions exactly.", + "", + "BOOT.md:", + content, + "", + "If BOOT.md asks you to send a message, use the message tool (action=send with channel + target).", + "Use the `target` field (not `to`) for message tool destinations.", + `After sending with the message tool, reply with ONLY: ${SILENT_REPLY_TOKEN}.`, + `If nothing needs attention, reply with ONLY: ${SILENT_REPLY_TOKEN}.`, + ].join("\n"); +} + +async function loadBootFile( + workspaceDir: string, +): Promise<{ content?: string; status: "ok" | "missing" | "empty" }> { + const bootPath = path.join(workspaceDir, BOOT_FILENAME); + try { + const content = await fs.readFile(bootPath, "utf-8"); + const trimmed = content.trim(); + if (!trimmed) return { status: "empty" }; + return { status: "ok", content: trimmed }; + } catch (err) { + const anyErr = err as { code?: string }; + if (anyErr.code === "ENOENT") return { status: "missing" }; + throw err; + } +} + +export async function runBootOnce(params: { + cfg: ClawdbotConfig; + deps: CliDeps; + workspaceDir: string; +}): Promise { + const bootRuntime: RuntimeEnv = { + log: () => {}, + error: (message) => log.error(String(message)), + exit: defaultRuntime.exit, + }; + let result: Awaited>; + try { + result = await loadBootFile(params.workspaceDir); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error(`boot: failed to read ${BOOT_FILENAME}: ${message}`); + return { status: "failed", reason: message }; + } + + if (result.status === "missing" || result.status === "empty") { + return { status: "skipped", reason: result.status }; + } + + const sessionKey = resolveMainSessionKey(params.cfg); + const message = buildBootPrompt(result.content ?? ""); + + try { + await agentCommand( + { + message, + sessionKey, + deliver: false, + }, + bootRuntime, + params.deps, + ); + return { status: "ran" }; + } catch (err) { + const messageText = err instanceof Error ? err.message : String(err); + log.error(`boot: agent run failed: ${messageText}`); + return { status: "failed", reason: messageText }; + } +} diff --git a/src/gateway/server-startup.ts b/src/gateway/server-startup.ts index d11813329..b2196d7dd 100644 --- a/src/gateway/server-startup.ts +++ b/src/gateway/server-startup.ts @@ -8,7 +8,11 @@ import { import type { CliDeps } from "../cli/deps.js"; import type { loadConfig } from "../config/config.js"; import { startGmailWatcher } from "../hooks/gmail-watcher.js"; -import { clearInternalHooks } from "../hooks/internal-hooks.js"; +import { + clearInternalHooks, + createInternalHookEvent, + triggerInternalHook, +} from "../hooks/internal-hooks.js"; import { loadInternalHooks } from "../hooks/loader.js"; import type { loadClawdbotPlugins } from "../plugins/loader.js"; import { type PluginServicesHandle, startPluginServices } from "../plugins/services.js"; @@ -122,6 +126,17 @@ export async function startGatewaySidecars(params: { ); } + if (params.cfg.hooks?.internal?.enabled) { + setTimeout(() => { + const hookEvent = createInternalHookEvent("gateway", "startup", "gateway:startup", { + cfg: params.cfg, + deps: params.deps, + workspaceDir: params.defaultWorkspaceDir, + }); + void triggerInternalHook(hookEvent); + }, 250); + } + let pluginServices: PluginServicesHandle | null = null; try { pluginServices = await startPluginServices({ diff --git a/src/hooks/bundled/README.md b/src/hooks/bundled/README.md index 281cfd1bc..48ad5ea95 100644 --- a/src/hooks/bundled/README.md +++ b/src/hooks/bundled/README.md @@ -47,6 +47,20 @@ Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by clawdbot hooks enable soul-evil ``` +### 🚀 boot-md + +Runs `BOOT.md` whenever the gateway starts (after channels start). + +**Events**: `gateway:startup` +**What it does**: Executes BOOT.md instructions via the agent runner. +**Output**: Whatever the instructions request (for example, outbound messages). + +**Enable**: + +```bash +clawdbot hooks enable boot-md +``` + ## Hook Structure Each hook is a directory containing: @@ -156,6 +170,7 @@ Currently supported events: - **command:reset**: `/reset` command - **command:stop**: `/stop` command - **agent:bootstrap**: Before workspace bootstrap files are injected +- **gateway:startup**: Gateway startup (after channels start) More event types coming soon (session lifecycle, agent errors, etc.). @@ -165,7 +180,7 @@ Hook handlers receive an `InternalHookEvent` object: ```typescript interface InternalHookEvent { - type: "command" | "session" | "agent"; + type: "command" | "session" | "agent" | "gateway"; action: string; // e.g., 'new', 'reset', 'stop' sessionKey: string; context: Record; diff --git a/src/hooks/bundled/boot-md/HOOK.md b/src/hooks/bundled/boot-md/HOOK.md new file mode 100644 index 000000000..dac210b62 --- /dev/null +++ b/src/hooks/bundled/boot-md/HOOK.md @@ -0,0 +1,19 @@ +--- +name: boot-md +description: "Run BOOT.md on gateway startup" +homepage: https://docs.clawd.bot/hooks#boot-md +metadata: + { + "clawdbot": + { + "emoji": "🚀", + "events": ["gateway:startup"], + "requires": { "config": ["workspace.dir"] }, + "install": [{ "id": "bundled", "kind": "bundled", "label": "Bundled with Clawdbot" }], + }, + } +--- + +# Boot Checklist Hook + +Runs `BOOT.md` every time the gateway starts, if the file exists in the workspace. diff --git a/src/hooks/bundled/boot-md/handler.ts b/src/hooks/bundled/boot-md/handler.ts new file mode 100644 index 000000000..89d9b5c5c --- /dev/null +++ b/src/hooks/bundled/boot-md/handler.ts @@ -0,0 +1,27 @@ +import type { CliDeps } from "../../../cli/deps.js"; +import { createDefaultDeps } from "../../../cli/deps.js"; +import type { ClawdbotConfig } from "../../../config/config.js"; +import { runBootOnce } from "../../../gateway/boot.js"; +import type { HookHandler } from "../../hooks.js"; + +type BootHookContext = { + cfg?: ClawdbotConfig; + workspaceDir?: string; + deps?: CliDeps; +}; + +const runBootChecklist: HookHandler = async (event) => { + if (event.type !== "gateway" || event.action !== "startup") { + return; + } + + const context = (event.context ?? {}) as BootHookContext; + if (!context.cfg || !context.workspaceDir) { + return; + } + + const deps = context.deps ?? createDefaultDeps(); + await runBootOnce({ cfg: context.cfg, deps, workspaceDir: context.workspaceDir }); +}; + +export default runBootChecklist; diff --git a/src/hooks/internal-hooks.ts b/src/hooks/internal-hooks.ts index 2de74c6a3..6c8cc676f 100644 --- a/src/hooks/internal-hooks.ts +++ b/src/hooks/internal-hooks.ts @@ -8,7 +8,7 @@ import type { WorkspaceBootstrapFile } from "../agents/workspace.js"; import type { ClawdbotConfig } from "../config/config.js"; -export type InternalHookEventType = "command" | "session" | "agent"; +export type InternalHookEventType = "command" | "session" | "agent" | "gateway"; export type AgentBootstrapHookContext = { workspaceDir: string; @@ -26,7 +26,7 @@ export type AgentBootstrapHookEvent = InternalHookEvent & { }; export interface InternalHookEvent { - /** The type of event (command, session, agent, etc.) */ + /** The type of event (command, session, agent, gateway, etc.) */ type: InternalHookEventType; /** The specific action within the type (e.g., 'new', 'reset', 'stop') */ action: string;