+
+ );
+}
diff --git a/app/src/features/routines/form.ts b/app/src/features/routines/form.ts
index 4e2e4097..3660817e 100644
--- a/app/src/features/routines/form.ts
+++ b/app/src/features/routines/form.ts
@@ -45,8 +45,14 @@ export function blankForm(): RoutineForm {
};
}
+/** What the form is filled from: a routine, or one a bot asks for. */
+export type RoutineFields = Pick<
+ Routine,
+ "name" | "prompt" | "schedule" | "timezone" | "overlap" | "missed"
+>;
+
/** The form as it opens for an existing routine. */
-export function formOf(routine: Routine): RoutineForm {
+export function formOf(routine: RoutineFields): RoutineForm {
const form: RoutineForm = {
...blankForm(),
name: routine.name,
diff --git a/app/src/features/routines/routineRequest.test.tsx b/app/src/features/routines/routineRequest.test.tsx
new file mode 100644
index 00000000..8b6906f1
--- /dev/null
+++ b/app/src/features/routines/routineRequest.test.tsx
@@ -0,0 +1,109 @@
+import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
+import { afterEach, describe, expect, test } from "vitest";
+import { FakeBotloft } from "../../lib/fake";
+import { ROUTINE_TOOL } from "../../lib/fakeChat";
+import { crewOpened, openBot, renderApp } from "../../test/app";
+
+afterEach(cleanup);
+
+const INBOX = {
+ name: "Morning inbox",
+ prompt: "Read the new mail and summarize what is urgent.",
+ schedule: { kind: "weekly", days: [1, 2, 3, 4, 5], time: "08:00" },
+ timezone: "America/Sao_Paulo",
+};
+
+/** A crew with its chief and a bot "Mail", open on the chief's chat. */
+async function openChief() {
+ const fake = new FakeBotloft();
+ const crew = await fake.call("crews.create", {
+ name: "Office",
+ lead: { name: "Chief", role: "Leads the crew", instructions: "Run the office" },
+ });
+ const chiefId = crew.leadBotId as string;
+ const mail = await fake.call("bots.create", {
+ crewId: crew.id,
+ name: "Mail",
+ role: "Reads the inbox",
+ instructions: "",
+ });
+ fake.setBotState(chiefId, "idle");
+ renderApp(fake);
+ await crewOpened("Office");
+ openBot("Chief");
+ await screen.findByRole("list", { name: "Messages" });
+ return { fake, chiefId, mail };
+}
+
+function ask(fake: FakeBotloft, botId: string, input: object, title: string) {
+ act(() => {
+ fake.chat.ask(botId, ROUTINE_TOOL, "Morning inbox", JSON.stringify(input));
+ });
+ return screen.getByRole("region", { name: title });
+}
+
+const answer = (fake: FakeBotloft) =>
+ fake.calls.find((call) => call.method === "approvals.answer")?.params;
+
+const routines = (fake: FakeBotloft) => [...fake.routines.routines.values()];
+
+describe("a bot asking for a routine", () => {
+ test("the owner creates it as asked", async () => {
+ const { fake, chiefId } = await openChief();
+ const card = ask(fake, chiefId, INBOX, "Chief wants to set up a routine");
+ expect((within(card).getByLabelText("Name") as HTMLInputElement).value).toBe("Morning inbox");
+ expect(within(card).getByRole("radio", { name: "Weekdays" })).toHaveProperty("checked", true);
+ fireEvent.click(within(card).getByRole("button", { name: "Create routine" }));
+
+ expect(await screen.findByText("You created the routine Morning inbox")).toBeDefined();
+ expect(answer(fake)).toEqual({ approvalId: expect.any(String), allow: true });
+ expect(routines(fake)).toMatchObject([
+ { botId: chiefId, name: "Morning inbox", timezone: "America/Sao_Paulo" },
+ ]);
+ });
+
+ test("the owner can change when it runs before creating it", async () => {
+ const { fake, chiefId } = await openChief();
+ const card = ask(fake, chiefId, INBOX, "Chief wants to set up a routine");
+ fireEvent.change(within(card).getByLabelText("Name"), { target: { value: "Inbox at nine" } });
+ fireEvent.click(within(card).getByRole("radio", { name: "Every day" }));
+ fireEvent.change(within(card).getByLabelText("At"), { target: { value: "09:00" } });
+ fireEvent.click(within(card).getByRole("button", { name: "Create routine" }));
+
+ expect(await screen.findByText("You created the routine Inbox at nine")).toBeDefined();
+ const sent = JSON.parse(String((answer(fake) as { input?: string } | undefined)?.input));
+ expect(sent).toMatchObject({
+ name: "Inbox at nine",
+ schedule: { kind: "weekly", days: [1, 2, 3, 4, 5, 6, 7], time: "09:00" },
+ });
+ expect(routines(fake)[0]?.schedule).toEqual(sent.schedule);
+ });
+
+ test("a routine for another bot of the crew goes to that bot", async () => {
+ const { fake, chiefId, mail } = await openChief();
+ const card = ask(
+ fake,
+ chiefId,
+ { ...INBOX, bot: mail.handle },
+ "Chief wants to set up a routine for Mail",
+ );
+ expect(within(card).getByLabelText("What should Mail do?")).toBeDefined();
+ fireEvent.click(within(card).getByRole("button", { name: "Create routine" }));
+
+ await screen.findByText("You created the routine Morning inbox");
+ expect(routines(fake)).toMatchObject([{ botId: mail.id }]);
+ });
+
+ test("a no goes back with the owner's note and creates nothing", async () => {
+ const { fake, chiefId } = await openChief();
+ const card = ask(fake, chiefId, INBOX, "Chief wants to set up a routine");
+ fireEvent.change(within(card).getByPlaceholderText(/If you say no, tell Chief why/), {
+ target: { value: "I check it myself" },
+ });
+ fireEvent.click(within(card).getByRole("button", { name: "Not now" }));
+
+ expect(await screen.findByText("You said no to the routine Morning inbox")).toBeDefined();
+ expect(answer(fake)).toMatchObject({ allow: false, note: "I check it myself" });
+ expect(routines(fake)).toEqual([]);
+ });
+});
diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts
index 479f4d05..b4327c2f 100644
--- a/app/src/i18n/en/chat.ts
+++ b/app/src/i18n/en/chat.ts
@@ -210,6 +210,21 @@ export const chat = {
declined: (name: string) => `You said no to ${name}`,
expired: (name: string) => `${name} was not answered in time`,
},
+ routineRequest: {
+ title: (bot: string) => `${bot} wants to set up a routine`,
+ titleFor: (bot: string, runner: string) => `${bot} wants to set up a routine for ${runner}`,
+ explain: (runner: string) =>
+ `A routine makes ${runner} work on its own at set times. It shows in the Routines tab, where you can pause or delete it.`,
+ noteLabel: (bot: string) => `What to tell ${bot} if you say no`,
+ notePlaceholder: (bot: string) => `If you say no, tell ${bot} why (optional)`,
+ create: "Create routine",
+ decline: "Not now",
+ createFailed: "Could not create the routine",
+ declineFailed: "Could not send the answer",
+ created: (name: string) => `You created the routine ${name}`,
+ declined: (name: string) => `You said no to the routine ${name}`,
+ expired: (name: string) => `The routine ${name} was not answered in time`,
+ },
plan: {
ready: (bot: string) => `${bot} made a plan and wants to go ahead`,
noteLabel: (bot: string) => `What ${bot} should change in the plan`,
diff --git a/app/src/i18n/en/tools.ts b/app/src/i18n/en/tools.ts
index 9077c0b3..71d9f3d6 100644
--- a/app/src/i18n/en/tools.ts
+++ b/app/src/i18n/en/tools.ts
@@ -32,6 +32,7 @@ export const tools = {
complete_task: "Finish a task",
my_tasks: "See its tasks",
suggest_bot: "Suggest a bot",
+ schedule_routine: "Set up a routine",
browser: "Use a site",
browser_help: "Ask for your hand",
browser_open: "Open a page",
diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts
index 5c49b32f..a85fe1ae 100644
--- a/app/src/i18n/es/chat.ts
+++ b/app/src/i18n/es/chat.ts
@@ -217,6 +217,21 @@ export const chat: Messages["chat"] = {
declined: (name: string) => `Rechazaste ${name}`,
expired: (name: string) => `${name} no se respondió a tiempo`,
},
+ routineRequest: {
+ title: (bot: string) => `${bot} quiere crear una rutina`,
+ titleFor: (bot: string, runner: string) => `${bot} quiere crear una rutina para ${runner}`,
+ explain: (runner: string) =>
+ `Una rutina hace que ${runner} trabaje solo a horas fijas. Aparece en la pestaña Rutinas, donde puedes pausarla o borrarla.`,
+ noteLabel: (bot: string) => `Qué decirle a ${bot} si dices que no`,
+ notePlaceholder: (bot: string) => `Si dices que no, dile a ${bot} por qué (opcional)`,
+ create: "Crear rutina",
+ decline: "Ahora no",
+ createFailed: "No se pudo crear la rutina",
+ declineFailed: "No se pudo enviar la respuesta",
+ created: (name: string) => `Creaste la rutina ${name}`,
+ declined: (name: string) => `Rechazaste la rutina ${name}`,
+ expired: (name: string) => `La rutina ${name} no se respondió a tiempo`,
+ },
plan: {
ready: (bot: string) => `${bot} hizo un plan y quiere seguir con él`,
noteLabel: (bot: string) => `Qué debe cambiar ${bot} en el plan`,
diff --git a/app/src/i18n/es/tools.ts b/app/src/i18n/es/tools.ts
index d8b9b081..24bef187 100644
--- a/app/src/i18n/es/tools.ts
+++ b/app/src/i18n/es/tools.ts
@@ -33,6 +33,7 @@ export const tools: Messages["tools"] = {
complete_task: "Terminar una tarea",
my_tasks: "Ver sus tareas",
suggest_bot: "Sugerir un bot",
+ schedule_routine: "Crear una rutina",
browser: "Usar un sitio",
browser_help: "Pedir tu ayuda",
browser_open: "Abrir una página",
diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts
index 47bc0c53..37667dba 100644
--- a/app/src/i18n/pt-BR/chat.ts
+++ b/app/src/i18n/pt-BR/chat.ts
@@ -215,6 +215,21 @@ export const chat: Messages["chat"] = {
declined: (name: string) => `Você recusou ${name}`,
expired: (name: string) => `${name} não foi respondido a tempo`,
},
+ routineRequest: {
+ title: (bot: string) => `${bot} quer criar uma rotina`,
+ titleFor: (bot: string, runner: string) => `${bot} quer criar uma rotina para ${runner}`,
+ explain: (runner: string) =>
+ `Uma rotina faz ${runner} trabalhar sozinho em horários marcados. Ela aparece na aba Rotinas, onde você pode pausar ou apagar.`,
+ noteLabel: (bot: string) => `O que dizer a ${bot} se você recusar`,
+ notePlaceholder: (bot: string) => `Se recusar, diga a ${bot} o porquê (opcional)`,
+ create: "Criar rotina",
+ decline: "Agora não",
+ createFailed: "Não foi possível criar a rotina",
+ declineFailed: "Não foi possível enviar a resposta",
+ created: (name: string) => `Você criou a rotina ${name}`,
+ declined: (name: string) => `Você recusou a rotina ${name}`,
+ expired: (name: string) => `A rotina ${name} não foi respondida a tempo`,
+ },
plan: {
ready: (bot: string) => `${bot} fez um plano e quer seguir com ele`,
noteLabel: (bot: string) => `O que ${bot} deve mudar no plano`,
diff --git a/app/src/i18n/pt-BR/tools.ts b/app/src/i18n/pt-BR/tools.ts
index 99e931d7..a52333ab 100644
--- a/app/src/i18n/pt-BR/tools.ts
+++ b/app/src/i18n/pt-BR/tools.ts
@@ -32,6 +32,7 @@ export const tools: Messages["tools"] = {
complete_task: "Concluir uma tarefa",
my_tasks: "Ver as próprias tarefas",
suggest_bot: "Sugerir um bot",
+ schedule_routine: "Criar uma rotina",
browser: "Usar um site",
browser_help: "Pedir sua ajuda",
browser_open: "Abrir uma página",
diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts
index ed35db71..f06fd2f6 100644
--- a/app/src/lib/fakeChat.ts
+++ b/app/src/lib/fakeChat.ts
@@ -12,11 +12,14 @@ import type {
BotModel,
ChatBody,
ChatItem,
+ RoutinesCreateParams,
ToolItem,
} from "./protocol.gen";
/** The chief's tool to suggest a bot (spec 10.2). */
export const SUGGEST_TOOL = "mcp__botloft__suggest_bot";
+/** Any bot's tool to ask for a routine (spec 20.12). */
+export const ROUTINE_TOOL = "mcp__botloft__schedule_routine";
/** A bot asking the owner for a hand in its browser (spec 21.10). */
const HELP_TOOL = "mcp__botloft__browser_help";
@@ -200,6 +203,28 @@ export class FakeChat {
...(model && { model }),
});
}
+ // An allowed routine request becomes a routine, as the owner left it,
+ // for the bot it names or the one that asked.
+ if (allow && approval.toolName === ROUTINE_TOOL && bot) {
+ if (input !== undefined) {
+ approval.input = JSON.stringify({
+ ...JSON.parse(approval.input),
+ ...JSON.parse(input),
+ });
+ }
+ const { bot: handle, ...routine } = JSON.parse(approval.input) as Omit<
+ RoutinesCreateParams,
+ "botId"
+ > & { bot?: string };
+ const runner = handle
+ ? [...this.fake.bots.values()].find(
+ (other) => other.crewId === bot.crewId && other.handle === handle,
+ )
+ : bot;
+ if (runner) {
+ this.fake.routines.handlers()["routines.create"]({ botId: runner.id, ...routine });
+ }
+ }
// Any answer to a request for help gives the browser back (spec 21.10).
if (approval.toolName === HELP_TOOL) {
this.fake.browser.helped(approval.botId);
diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts
index 67775665..83a23179 100644
--- a/app/src/lib/protocol.gen.ts
+++ b/app/src/lib/protocol.gen.ts
@@ -828,8 +828,9 @@ export type ApprovalsAnswerParams = { approvalId: ApprovalId, allow: boolean,
*/
note?: string,
/**
- * A bot suggestion as the owner changed it before allowing it, in the
- * JSON of the tool's input (spec 10.2). Ignored for other requests.
+ * A bot or routine suggestion as the owner changed it before allowing
+ * it, in the JSON of the tool's input (spec 10.2, 20.12). Ignored for
+ * other requests.
*/
input?: string, };
diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs
index 23e335f4..26ab3567 100644
--- a/crates/botloft-core/src/chat.rs
+++ b/crates/botloft-core/src/chat.rs
@@ -14,6 +14,8 @@ pub const PLAN_INPUT_MAX: usize = 32 * 1024;
pub const PLAN_TOOL: &str = "ExitPlanMode";
/// The chief's tool to suggest a new bot (spec 10.2).
pub const SUGGEST_TOOL: &str = "mcp__botloft__suggest_bot";
+/// A bot asking the owner for a routine (spec 20.12).
+pub const ROUTINE_TOOL: &str = "mcp__botloft__schedule_routine";
/// A bot asking to use its browser on a site (spec 21.5). Not a tool the
/// model calls: the daemon opens this request from inside the `browser_*`
/// tools.
@@ -21,7 +23,8 @@ pub const BROWSER_SITE_TOOL: &str = "mcp__botloft__browser";
/// A bot asking the owner to do something in its browser themselves (spec
/// 21.10), opened from inside `browser_ask_owner`.
pub const BROWSER_HELP_TOOL: &str = "mcp__botloft__browser_help";
-/// Longest suggestion kept, in bytes: the owner reads and edits all of it.
+/// Longest bot or routine suggestion kept, in bytes: the owner reads and
+/// edits all of it.
pub const SUGGESTION_INPUT_MAX: usize = 64 * 1024;
/// Longest tool output kept, in bytes.
pub const TOOL_OUTPUT_MAX: usize = 8 * 1024;
@@ -57,7 +60,7 @@ pub fn one_line(text: &str, max: usize) -> String {
pub fn tool_input_max(tool: &str) -> usize {
match tool {
PLAN_TOOL => PLAN_INPUT_MAX,
- SUGGEST_TOOL => SUGGESTION_INPUT_MAX,
+ SUGGEST_TOOL | ROUTINE_TOOL => SUGGESTION_INPUT_MAX,
tool if is_command(tool) => COMMAND_INPUT_MAX,
_ => TOOL_INPUT_MAX,
}
@@ -100,7 +103,7 @@ pub fn tool_summary(name: &str, input: &Value) -> String {
"mcp__botloft__send_message" => {
field(input, "to").map(|to| format!("@{}", to.trim_start_matches('@')))
}
- SUGGEST_TOOL => field(input, "name").map(str::to_owned),
+ SUGGEST_TOOL | ROUTINE_TOOL => field(input, "name").map(str::to_owned),
BROWSER_SITE_TOOL => field(input, "site").map(str::to_owned),
BROWSER_HELP_TOOL | "mcp__botloft__browser_ask_owner" => {
field(input, "task").map(str::to_owned)
diff --git a/crates/botloft-core/src/protocol/chat.rs b/crates/botloft-core/src/protocol/chat.rs
index 48abe9c0..364a1e84 100644
--- a/crates/botloft-core/src/protocol/chat.rs
+++ b/crates/botloft-core/src/protocol/chat.rs
@@ -241,8 +241,9 @@ pub struct ApprovalsAnswerParams {
#[serde(default)]
#[cfg_attr(test, ts(optional))]
pub note: Option,
- /// A bot suggestion as the owner changed it before allowing it, in the
- /// JSON of the tool's input (spec 10.2). Ignored for other requests.
+ /// A bot or routine suggestion as the owner changed it before allowing
+ /// it, in the JSON of the tool's input (spec 10.2, 20.12). Ignored for
+ /// other requests.
#[serde(default)]
#[cfg_attr(test, ts(optional))]
pub input: Option,
diff --git a/crates/botloftd/src/approvals/mod.rs b/crates/botloftd/src/approvals/mod.rs
index c777c755..20e19588 100644
--- a/crates/botloftd/src/approvals/mod.rs
+++ b/crates/botloftd/src/approvals/mod.rs
@@ -8,7 +8,7 @@ mod prompt;
use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard};
-use botloft_core::chat::{SUGGEST_TOOL, clip, tool_input_max, tool_summary};
+use botloft_core::chat::{ROUTINE_TOOL, SUGGEST_TOOL, clip, tool_input_max, tool_summary};
use botloft_core::command::tool_explanation;
use botloft_core::ids::{ApprovalId, BotId, ChatItemId};
use botloft_core::protocol::{
@@ -147,13 +147,17 @@ pub fn answer(daemon: &Daemon, params: ApprovalsAnswerParams) -> ApiResult {
let value: Value = serde_json::from_str(&input)
.map_err(|_| ApiError::validation("the changed suggestion is not valid JSON"))?;
Some(value.to_string())
}
+ Some(input) if params.allow && existing.approval.tool_name == ROUTINE_TOOL => Some(
+ crate::tools::check_changed_routine(daemon, &existing.approval.input, &input)?,
+ ),
_ => None,
};
let Some(record) = settle(daemon, &existing, status, note.as_deref(), input.as_deref()) else {
diff --git a/crates/botloftd/src/service/routines.rs b/crates/botloftd/src/service/routines.rs
index 7140b458..17ae4c8a 100644
--- a/crates/botloftd/src/service/routines.rs
+++ b/crates/botloftd/src/service/routines.rs
@@ -54,6 +54,21 @@ pub fn create(daemon: &Daemon, params: RoutinesCreateParams) -> ApiResult ApiResult<()> {
+ name(¶ms.name)?;
+ validate::message("prompt", ¶ms.prompt)?;
+ plan(¶ms.schedule, ¶ms.timezone, daemon.clock.now_ms())?;
+ Ok(())
+}
+
+/// When the routine runs next, as its zone shows it (`2026-10-01 09:00`).
+pub(crate) fn next_run_text(routine: &Routine) -> Option {
+ let plan = Plan::new(&routine.schedule, &routine.timezone).ok()?;
+ Some(plan.local_time(routine.next_run_at?))
+}
+
pub fn update(daemon: &Daemon, params: RoutinesUpdateParams) -> ApiResult {
let store = daemon.store();
let mut routine = active(&store, ¶ms.routine_id)?;
diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs
index f39b5d6d..84f89e1c 100644
--- a/crates/botloftd/src/supervisor/spawn.rs
+++ b/crates/botloftd/src/supervisor/spawn.rs
@@ -23,6 +23,9 @@ use crate::{approvals, context, courier, routines, workspace};
/// Tools the bot uses without asking: its crew tools (spec 7.4).
const ALLOWED_TOOLS: &str = "mcp__botloft";
+/// Claude Code's own schedulers: they die with the process and Botloft never
+/// sees them. Work at set times is a routine (spec 7.4, 20).
+const DISALLOWED_TOOLS: &str = "CronCreate,CronDelete,CronList,ScheduleWakeup,RemoteTrigger";
/// Makes Claude Code load `CLAUDE.md` from `--add-dir` folders (spec 5).
const ADDITIONAL_MEMORY: &str = "CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD";
/// Where permission requests go (spec 10.1).
@@ -220,6 +223,8 @@ fn launch_spec(
PERMISSION_TOOL,
"--allowedTools",
ALLOWED_TOOLS,
+ "--disallowedTools",
+ DISALLOWED_TOOLS,
"--mcp-config",
] {
args.push(arg.into());
diff --git a/crates/botloftd/src/tools/catalog.rs b/crates/botloftd/src/tools/catalog.rs
index f0e0da94..60c1079f 100644
--- a/crates/botloftd/src/tools/catalog.rs
+++ b/crates/botloftd/src/tools/catalog.rs
@@ -3,6 +3,7 @@
use serde_json::{Value, json};
+use super::routine::PROMPT_MAX;
use super::suggest::{INSTRUCTIONS_MAX, REASON_MAX};
use crate::service::tasks::MAX_DEADLINE_MINUTES;
use botloft_core::validate::{NAME_MAX_CHARS, ROLE_MAX_CHARS};
@@ -13,6 +14,8 @@ pub const COMPLETE_TASK: &str = "complete_task";
pub const MY_TASKS: &str = "my_tasks";
/// The chief's tool (spec 10.2); Claude Code names it `mcp__botloft__suggest_bot`.
pub const SUGGEST_BOT: &str = "suggest_bot";
+/// Any bot's tool to ask for a routine (spec 20.12).
+pub const SCHEDULE_ROUTINE: &str = "schedule_routine";
/// Claude Code's `--permission-prompt-tool` (spec 10.1).
pub const PERMISSION_PROMPT: &str = "permission_prompt";
@@ -159,6 +162,80 @@ fn crew_tools() -> Value {
"additionalProperties": false,
},
},
+ {
+ "name": SCHEDULE_ROUTINE,
+ "title": "Schedule a routine",
+ "description": "Asks the owner for a routine: work done at set times, such as every \
+ weekday at 08:00 or every 2 hours. Each time, the bot gets your prompt as a \
+ message, even after restarts. The owner sees the routine in your chat, may change \
+ it, and approves or declines it; the call waits for that. Approved, it shows among \
+ the bot's routines in the Botloft app. This is the only way to schedule work.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "maxLength": 80,
+ "description": "A short name the owner recognizes, like \"Morning inbox\".",
+ },
+ "prompt": {
+ "type": "string",
+ "maxLength": PROMPT_MAX,
+ "description": "What to do each time, written to the bot that runs it. \
+ Make it complete on its own: it arrives with no other context.",
+ },
+ "schedule": {
+ "description": "When it runs, in the owner's time zone. Runs must be at \
+ least 5 minutes apart.",
+ "oneOf": [
+ {
+ "type": "object",
+ "properties": {
+ "kind": { "const": "weekly" },
+ "days": {
+ "type": "array",
+ "items": { "type": "integer", "minimum": 1, "maximum": 7 },
+ "minItems": 1,
+ "description": "1 = Monday … 7 = Sunday.",
+ },
+ "time": { "type": "string", "description": "HH:MM, 24-hour." },
+ },
+ "required": ["kind", "days", "time"],
+ "additionalProperties": false,
+ },
+ {
+ "type": "object",
+ "properties": {
+ "kind": { "const": "interval" },
+ "minutes": { "type": "integer", "minimum": 5, "maximum": 10_080 },
+ },
+ "required": ["kind", "minutes"],
+ "additionalProperties": false,
+ },
+ {
+ "type": "object",
+ "properties": {
+ "kind": { "const": "cron" },
+ "expr": {
+ "type": "string",
+ "description": "5 fields: minute hour day month weekday.",
+ },
+ },
+ "required": ["kind", "expr"],
+ "additionalProperties": false,
+ },
+ ],
+ },
+ "bot": {
+ "type": "string",
+ "description": "Handle of another bot of your crew the routine is for. \
+ Leave it out for a routine of your own.",
+ },
+ },
+ "required": ["name", "prompt", "schedule"],
+ "additionalProperties": false,
+ },
+ },
{
"name": PERMISSION_PROMPT,
"title": "Permission prompt",
diff --git a/crates/botloftd/src/tools/mod.rs b/crates/botloftd/src/tools/mod.rs
index 3a97d296..4ab23ce2 100644
--- a/crates/botloftd/src/tools/mod.rs
+++ b/crates/botloftd/src/tools/mod.rs
@@ -15,6 +15,7 @@ mod browser_sites;
mod calls;
mod catalog;
mod era;
+mod routine;
mod suggest;
use std::sync::Arc;
@@ -29,6 +30,7 @@ use serde_json::{Value, json};
use tracing::debug;
use self::era::{Era, LEGACY, era, supported};
+pub(crate) use self::routine::check_changed as check_changed_routine;
use crate::approvals;
use crate::rpc::jsonrpc::{self, Request};
use crate::state::Daemon;
@@ -38,7 +40,7 @@ const CACHE_TTL_MS: u64 = 60 * 60 * 1000;
const INSTRUCTIONS: &str = "Tools to work with your Botloft crew: see who is in it, send notes \
or tasks to other bots, report the result of tasks assigned to you and, for the crew's chief, \
- suggest new bots. The browser_ tools drive your own web browser, which the owner can watch \
+ suggest new bots. schedule_routine asks the owner for work at set times. The browser_ tools drive your own web browser, which the owner can watch \
live and take over when you ask with browser_ask_owner. The owner writes to you directly; messages from other bots and from Botloft start with \
[botloft].";
@@ -116,7 +118,7 @@ pub async fn handle(
return StatusCode::ACCEPTED.into_response();
};
let answered = match era(&headers, &request) {
- // Both hold the request until the owner answers (spec 10.1, 10.2).
+ // They hold the request until the owner answers (spec 10.1, 10.2, 20.12).
Ok(era) if called(&request) == Some(catalog::PERMISSION_PROMPT) => {
permission(&daemon, &bot, generation, &request)
.await
@@ -126,6 +128,10 @@ pub async fn handle(
let result = suggest::suggest(&daemon, &bot, generation, arguments(&request)).await;
Ok(decorate(era, &request, result))
}
+ Ok(era) if called(&request) == Some(catalog::SCHEDULE_ROUTINE) => {
+ let result = routine::schedule(&daemon, &bot, generation, arguments(&request)).await;
+ Ok(decorate(era, &request, result))
+ }
// They act in a browser and may wait for the owner (spec 21.5).
Ok(era) if called(&request).is_some_and(|name| name.starts_with(browser_args::PREFIX)) => {
let name = called(&request).unwrap_or_default().to_owned();
diff --git a/crates/botloftd/src/tools/routine.rs b/crates/botloftd/src/tools/routine.rs
new file mode 100644
index 00000000..3dc63ebc
--- /dev/null
+++ b/crates/botloftd/src/tools/routine.rs
@@ -0,0 +1,194 @@
+//! `schedule_routine` (spec 20.12): a bot asks for a routine, for itself or,
+//! by handle, for another bot of its crew. The owner sees it in the bot's
+//! chat, may change it, and allows or declines it; the call waits for that,
+//! like `suggest_bot`. A bot that bypasses permissions creates it at once.
+
+use botloft_core::chat::ROUTINE_TOOL;
+use botloft_core::ids::BotId;
+use botloft_core::protocol::{
+ Missed, Overlap, PermissionMode, Routine, RoutinesCreateParams, Schedule,
+};
+use botloft_core::slug;
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+
+use super::calls::{explain, parse, tool_result};
+use crate::approvals::{self, Answer};
+use crate::service::{ApiError, ApiResult, bots, routines};
+use crate::state::Daemon;
+
+/// Longest prompt a bot may write for a routine, in characters: the owner
+/// reads all of it in the card.
+pub(super) const PROMPT_MAX: usize = 8_000;
+/// Used when the system's zone has no IANA name.
+const FALLBACK_ZONE: &str = "UTC";
+
+/// The tool's input, and what the owner sends back when they change it.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct Proposal {
+ name: String,
+ prompt: String,
+ schedule: Schedule,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ timezone: Option,
+ /// Handle of the bot the routine is for, when not the caller.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ bot: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ overlap: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ missed: Option,
+}
+
+impl Proposal {
+ fn params(&self, bot: BotId) -> RoutinesCreateParams {
+ RoutinesCreateParams {
+ bot_id: bot,
+ name: self.name.clone(),
+ prompt: self.prompt.clone(),
+ schedule: self.schedule.clone(),
+ timezone: self.timezone.clone().unwrap_or_else(system_zone),
+ overlap: self.overlap,
+ missed: self.missed,
+ }
+ }
+}
+
+pub(super) async fn schedule(
+ daemon: &Daemon,
+ bot: &BotId,
+ generation: u64,
+ arguments: Value,
+) -> Value {
+ tool_result(run(daemon, bot, generation, arguments).await)
+}
+
+async fn run(
+ daemon: &Daemon,
+ bot: &BotId,
+ generation: u64,
+ arguments: Value,
+) -> Result {
+ let mut proposal: Proposal = parse(arguments)?;
+ if proposal.prompt.chars().count() > PROMPT_MAX {
+ return Err(format!("prompt must be at most {PROMPT_MAX} characters"));
+ }
+ let (target, handle) = target(daemon, bot, proposal.bot.as_deref()).map_err(explain)?;
+ proposal.bot = handle;
+ proposal.timezone = Some(proposal.timezone.unwrap_or_else(system_zone));
+ // Nothing impossible goes to the owner.
+ let params = proposal.params(target.clone());
+ routines::check(daemon, ¶ms).map_err(explain)?;
+
+ let mode = bots::find(&daemon.store(), bot)
+ .map_err(explain)?
+ .permission_mode;
+ if mode == PermissionMode::BypassPermissions {
+ let routine = routines::create(daemon, params).map_err(explain)?;
+ return Ok(created_note(
+ &routine,
+ "You bypass permissions, so it was created without asking the owner.",
+ ));
+ }
+
+ let input = serde_json::to_value(&proposal).map_err(|err| err.to_string())?;
+ match approvals::ask(daemon, bot, generation, ROUTINE_TOOL, &input, "").await {
+ Some(Answer::Allowed { input: None }) => {
+ let routine = routines::create(daemon, params).map_err(explain)?;
+ Ok(created_note(&routine, "The owner approved it."))
+ }
+ Some(Answer::Allowed {
+ input: Some(changed),
+ }) => {
+ let changed: Proposal = serde_json::from_str(&changed)
+ .map_err(|err| format!("the owner's changes could not be read: {err}"))?;
+ let routine = routines::create(daemon, changed.params(target)).map_err(explain)?;
+ Ok(created_note(
+ &routine,
+ "The owner changed it before approving it: read the name, prompt and schedule \
+ above.",
+ ))
+ }
+ Some(Answer::Denied { note }) => {
+ let why = note.map_or_else(String::new, |note| format!(" They said: {note}"));
+ Ok(json!({
+ "created": false,
+ "note": format!(
+ "The owner declined this routine.{why} Nothing was scheduled: do not say it is."
+ ),
+ }))
+ }
+ Some(Answer::Expired) => Ok(json!({
+ "created": false,
+ "note": "The owner did not answer in time. Nothing was scheduled: tell the owner, and \
+ ask again later if it still helps.",
+ })),
+ None => Err("Botloft could not ask the owner.".to_owned()),
+ }
+}
+
+/// The owner's changes to a request (`approvals.answer`), checked before
+/// the request is settled so the card can say what is wrong. Returns them
+/// with the bot and zone of the request, which the owner does not change.
+pub(crate) fn check_changed(daemon: &Daemon, asked: &str, changed: &str) -> ApiResult {
+ let asked: Proposal = serde_json::from_str(asked)
+ .map_err(|_| ApiError::validation("the routine asked for could not be read"))?;
+ let mut changed: Proposal = serde_json::from_str(changed)
+ .map_err(|_| ApiError::validation("the changed routine is not valid"))?;
+ changed.bot = asked.bot;
+ if changed.timezone.is_none() {
+ changed.timezone = asked.timezone;
+ }
+ // The bot is checked again when the routine is created.
+ let params = changed.params(BotId::generate());
+ routines::check(daemon, ¶ms)?;
+ serde_json::to_string(&changed).map_err(|err| ApiError::validation(err.to_string()))
+}
+
+/// The bot the routine is for, and its handle when it is not the caller.
+fn target(
+ daemon: &Daemon,
+ caller: &BotId,
+ handle: Option<&str>,
+) -> ApiResult<(BotId, Option)> {
+ let store = daemon.store();
+ let (crew, me) = bots::active(&store, caller)?;
+ let handle = match handle.map(|h| slug::slugify(h.trim().trim_start_matches('@'), "")) {
+ None => return Ok((me.id, None)),
+ Some(handle) if handle.is_empty() || handle == me.handle => return Ok((me.id, None)),
+ Some(handle) => handle,
+ };
+ let Some(id) = store.active_bot_by_handle(&crew.id, &handle)? else {
+ return Err(ApiError::NotFound(format!(
+ "no bot in your crew answers to @{handle}; call crew_roster to see who does"
+ )));
+ };
+ Ok((id, Some(handle)))
+}
+
+/// The computer's time zone, as the app sends it when the owner creates a
+/// routine (spec 20.2).
+fn system_zone() -> String {
+ jiff::tz::TimeZone::system()
+ .iana_name()
+ .map_or_else(|| FALLBACK_ZONE.to_owned(), str::to_owned)
+}
+
+fn created_note(routine: &Routine, how: &str) -> Value {
+ let next = routines::next_run_text(routine).map_or_else(String::new, |at| {
+ format!(" It runs next at {at} ({}).", routine.timezone)
+ });
+ json!({
+ "created": true,
+ "routine_id": routine.id,
+ "name": routine.name,
+ "prompt": routine.prompt,
+ "schedule": routine.schedule,
+ "timezone": routine.timezone,
+ "note": format!(
+ "{how} The owner finds it in the Botloft app among the routines of the bot it is \
+ for, and can change, pause or delete it there.{next} Each time, that bot gets the prompt as a message."
+ ),
+ })
+}
diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs
index 7e77f149..d7319e41 100644
--- a/crates/botloftd/src/workspace/files.rs
+++ b/crates/botloftd/src/workspace/files.rs
@@ -128,6 +128,12 @@ app; the other bots of your crew send you messages too.
writes to you in, that says what the command does and why you need it,
in everyday words, with no code, file paths or jargon. They read it to
decide whether to allow the command.
+- For work at set times (\"every day at 8\", \"every 2 hours\"), call
+ `schedule_routine`: a name, what to do each time and when. The owner
+ approves it in your chat and may change it; then it shows among your
+ routines in the Botloft app. It is the only way to schedule: say it is scheduled only after the
+ tool says it was created. With `bot`, the routine is for another bot of
+ your crew.
## Working with your crew
@@ -264,6 +270,7 @@ mod tests {
assert!(rules.contains("attachments/"));
assert!(rules.contains("fill in its\n `description` for them"));
assert!(rules.contains("`browser_*` tools"));
+ assert!(rules.contains("`schedule_routine`"));
assert!(rules.contains(r#""#));
assert!(!rules.contains("You lead this crew"));
diff --git a/crates/botloftd/tests/mcp.rs b/crates/botloftd/tests/mcp.rs
index 13678ae1..26c7608d 100644
--- a/crates/botloftd/tests/mcp.rs
+++ b/crates/botloftd/tests/mcp.rs
@@ -72,6 +72,7 @@ async fn claude_code_discovers_the_server_and_lists_the_tools() {
"complete_task",
"my_tasks",
"suggest_bot",
+ "schedule_routine",
"browser_open",
"browser_look",
"browser_click",
@@ -127,7 +128,7 @@ async fn earlier_revisions_start_with_initialize() {
let listed = mcp::post(t.addr, &legacy, &list).await;
assert_eq!(
listed.body["result"]["tools"].as_array().map(Vec::len),
- Some(17)
+ Some(18)
);
let unknown = json!({ "jsonrpc": "2.0", "id": 2, "method": "prompts/list" }).to_string();
let unknown = mcp::post(t.addr, &legacy, &unknown).await;
diff --git a/crates/botloftd/tests/routine_requests.rs b/crates/botloftd/tests/routine_requests.rs
new file mode 100644
index 00000000..d27119bc
--- /dev/null
+++ b/crates/botloftd/tests/routine_requests.rs
@@ -0,0 +1,262 @@
+//! Bots asking for routines (spec 20.12): `schedule_routine` waits for the
+//! owner, who may change the routine before it is created, and a routine
+//! can be for another bot of the crew.
+
+mod common;
+
+use botloft_core::protocol::BotState;
+use common::bots::ready_bot;
+use common::mcp::Mcp;
+use common::{Client, TestDaemon};
+use serde_json::{Value, json};
+use tokio::task::JoinHandle;
+
+struct Crew {
+ t: TestDaemon,
+ app: Client,
+ crew: Value,
+ bot: Value,
+ mcp: Mcp,
+}
+
+async fn crew_with_bot() -> Crew {
+ let t = TestDaemon::start_supervised().await;
+ let mut app = t.session().await;
+ let crew = app
+ .call("crews.create", json!({ "name": "Office" }))
+ .await
+ .expect("crew");
+ let (bot, _, mcp) = ready_bot(&t, &mut app, &crew, "Mail").await;
+ Crew {
+ t,
+ app,
+ crew,
+ bot,
+ mcp,
+ }
+}
+
+fn inbox() -> Value {
+ json!({
+ "name": "Morning inbox",
+ "prompt": "Read the new mail and summarize what is urgent.",
+ "schedule": { "kind": "weekly", "days": [1, 2, 3, 4, 5], "time": "08:00" },
+ "timezone": "America/Sao_Paulo",
+ })
+}
+
+fn schedule(mcp: &Mcp, arguments: Value) -> JoinHandle> {
+ let mut mcp = Mcp::new(mcp.addr, mcp.token.clone());
+ tokio::spawn(async move { mcp.tool("schedule_routine", arguments).await })
+}
+
+/// Waits for the request to show up in the bot's chat.
+async fn pending_request(app: &mut Client) -> Value {
+ loop {
+ let changed = app.notification("chat.item").await;
+ let body = &changed["item"]["body"];
+ if body["kind"] == "approval" && body["status"] == "pending" {
+ assert_eq!(body["toolName"], "mcp__botloft__schedule_routine");
+ return body.clone();
+ }
+ }
+}
+
+async fn routines_of(app: &mut Client, bot: &Value) -> Vec {
+ app.call("routines.list", json!({ "botId": bot["id"] }))
+ .await
+ .expect("routines")
+ .as_array()
+ .expect("list")
+ .clone()
+}
+
+#[tokio::test]
+async fn the_owner_approves_a_routine_and_it_shows_in_the_list() {
+ let mut c = crew_with_bot().await;
+ let call = schedule(&c.mcp, inbox());
+ let asked = pending_request(&mut c.app).await;
+ assert_eq!(asked["summary"], "Morning inbox");
+ c.t.until_state(&c.bot, BotState::NeedsApproval).await;
+ assert!(routines_of(&mut c.app, &c.bot).await.is_empty());
+
+ c.app
+ .call(
+ "approvals.answer",
+ json!({ "approvalId": asked["approvalId"], "allow": true }),
+ )
+ .await
+ .expect("answer");
+ let created = call.await.expect("task").expect("created");
+ assert_eq!(created["created"], true);
+ assert!(
+ created["note"]
+ .as_str()
+ .expect("note")
+ .contains("(America/Sao_Paulo)")
+ );
+
+ let list = routines_of(&mut c.app, &c.bot).await;
+ assert_eq!(list.len(), 1);
+ assert_eq!(list[0]["id"], created["routine_id"]);
+ assert_eq!(list[0]["name"], "Morning inbox");
+ assert_eq!(list[0]["timezone"], "America/Sao_Paulo");
+ assert_eq!(list[0]["schedule"]["time"], "08:00");
+ assert!(list[0]["nextRunAt"].is_i64());
+}
+
+#[tokio::test]
+async fn the_owner_can_change_the_routine_before_approving_it() {
+ let mut c = crew_with_bot().await;
+ let call = schedule(&c.mcp, inbox());
+ let asked = pending_request(&mut c.app).await;
+
+ // Too often: refused, and the request stays open.
+ let mut changed = inbox();
+ changed["schedule"] = json!({ "kind": "interval", "minutes": 2 });
+ let refused = c
+ .app
+ .call(
+ "approvals.answer",
+ json!({ "approvalId": asked["approvalId"], "allow": true, "input": changed.to_string() }),
+ )
+ .await
+ .expect_err("too often");
+ assert!(refused.message.contains("5 minutes"), "{}", refused.message);
+
+ changed["name"] = json!("Inbox at nine");
+ changed["schedule"] = json!({ "kind": "weekly", "days": [1, 3], "time": "09:00" });
+ c.app
+ .call(
+ "approvals.answer",
+ json!({ "approvalId": asked["approvalId"], "allow": true, "input": changed.to_string() }),
+ )
+ .await
+ .expect("answer");
+ let created = call.await.expect("task").expect("created");
+ assert!(created["note"].as_str().expect("note").contains("changed"));
+ let list = routines_of(&mut c.app, &c.bot).await;
+ assert_eq!(list[0]["name"], "Inbox at nine");
+ assert_eq!(list[0]["schedule"]["days"], json!([1, 3]));
+}
+
+#[tokio::test]
+async fn a_declined_routine_is_not_created() {
+ let mut c = crew_with_bot().await;
+ let call = schedule(&c.mcp, inbox());
+ let asked = pending_request(&mut c.app).await;
+ c.app
+ .call(
+ "approvals.answer",
+ json!({ "approvalId": asked["approvalId"], "allow": false, "note": "I check it myself" }),
+ )
+ .await
+ .expect("answer");
+ let declined = call.await.expect("task").expect("answered");
+ assert_eq!(declined["created"], false);
+ let note = declined["note"].as_str().expect("note");
+ assert!(note.contains("I check it myself"));
+ assert!(note.contains("Nothing was scheduled"));
+ assert!(routines_of(&mut c.app, &c.bot).await.is_empty());
+}
+
+#[tokio::test]
+async fn a_routine_can_be_for_another_bot_of_the_crew() {
+ let mut c = crew_with_bot().await;
+ let (sales, _, _) = ready_bot(&c.t, &mut c.app, &c.crew, "Sales").await;
+ let mut arguments = inbox();
+ arguments["bot"] = json!("@sales");
+ let call = schedule(&c.mcp, arguments);
+ // The request waits in the chat of the bot that asked.
+ let asked = pending_request(&mut c.app).await;
+ assert_eq!(
+ asked["input"]
+ .as_str()
+ .map(|input| input.contains("\"bot\":\"sales\"")),
+ Some(true)
+ );
+ c.t.until_state(&c.bot, BotState::NeedsApproval).await;
+ c.app
+ .call(
+ "approvals.answer",
+ json!({ "approvalId": asked["approvalId"], "allow": true }),
+ )
+ .await
+ .expect("answer");
+ call.await.expect("task").expect("created");
+ assert!(routines_of(&mut c.app, &c.bot).await.is_empty());
+ assert_eq!(routines_of(&mut c.app, &sales).await.len(), 1);
+
+ let mut stranger = inbox();
+ stranger["bot"] = json!("nobody");
+ let refused = schedule(&c.mcp, stranger).await.expect("task");
+ assert!(refused.expect_err("unknown").contains("@nobody"));
+}
+
+#[tokio::test]
+async fn an_impossible_routine_never_reaches_the_owner() {
+ let c = crew_with_bot().await;
+ let mut often = inbox();
+ often["schedule"] = json!({ "kind": "cron", "expr": "* * * * *" });
+ let refused = schedule(&c.mcp, often).await.expect("task");
+ assert!(refused.expect_err("too often").contains("5 minutes"));
+
+ let mut zone = inbox();
+ zone["timezone"] = json!("Mars/Olympus");
+ let refused = schedule(&c.mcp, zone).await.expect("task");
+ assert!(refused.is_err());
+
+ let mut unknown = inbox();
+ unknown["when"] = json!("tomorrow");
+ let refused = schedule(&c.mcp, unknown).await.expect("task");
+ assert!(
+ refused
+ .expect_err("unknown field")
+ .contains("invalid arguments")
+ );
+}
+
+#[tokio::test]
+async fn a_bot_that_bypasses_permissions_creates_at_once() {
+ let mut c = crew_with_bot().await;
+ c.app
+ .call(
+ "bots.setPermissionMode",
+ json!({ "botId": c.bot["id"], "mode": "bypass_permissions" }),
+ )
+ .await
+ .expect("mode");
+ // The bot restarts into the new mode, with a new token.
+ let old = c.mcp.token.clone();
+ let mcp = loop {
+ let process = c.t.process_of(&c.bot).await;
+ c.t.until_state(&c.bot, BotState::Idle).await;
+ let token = process.env("BOTLOFT_BOT_TOKEN").expect("token");
+ if token != old {
+ break Mcp::new(c.t.addr, token);
+ }
+ tokio::time::sleep(std::time::Duration::from_millis(20)).await;
+ };
+ let mut arguments = inbox();
+ arguments
+ .as_object_mut()
+ .expect("object")
+ .remove("timezone");
+ let created = schedule(&mcp, arguments)
+ .await
+ .expect("task")
+ .expect("created");
+ assert!(
+ created["note"]
+ .as_str()
+ .expect("note")
+ .contains("without asking")
+ );
+ // The computer's zone when the bot names none.
+ assert!(
+ created["timezone"]
+ .as_str()
+ .is_some_and(|zone| !zone.is_empty())
+ );
+ assert_eq!(routines_of(&mut c.app, &c.bot).await.len(), 1);
+}
diff --git a/crates/botloftd/tests/supervisor.rs b/crates/botloftd/tests/supervisor.rs
index d3beedb5..07cc70be 100644
--- a/crates/botloftd/tests/supervisor.rs
+++ b/crates/botloftd/tests/supervisor.rs
@@ -63,6 +63,10 @@ async fn a_new_bot_runs_headless_in_a_new_session_with_a_clean_environment() {
arg_after(&process, "--allowedTools").as_deref(),
Some("mcp__botloft")
);
+ assert_eq!(
+ arg_after(&process, "--disallowedTools").as_deref(),
+ Some("CronCreate,CronDelete,CronList,ScheduleWakeup,RemoteTrigger")
+ );
assert!(arg_after(&process, "--mcp-config").is_some_and(|path| path.ends_with("mcp.json")));
assert!(process.spec.cwd.ends_with("scout"));
assert_eq!(
diff --git a/docs/spec.md b/docs/spec.md
index 3b97d90f..c6543bd4 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -131,7 +131,7 @@ O **handle** do bot (`@revisao`) é derivado do nome pela mesma regra, acompanha
attachments\\ arquivos que o dono mandou no chat (9.5)
.claude\
settings.json regra de negação para os segredos e o que o Claude Code não carrega no bot, 7.5 (gerado pelo daemon, sobrescrito a cada start)
- rules\botloft.md identidade, papel, crew, pasta de trabalho, guia de uso das tools, como explicar cada comando ao dono (10.1) e, no chefe, como liderar (gerado pelo daemon)
+ rules\botloft.md identidade, papel, crew, pasta de trabalho, guia de uso das tools, como explicar cada comando ao dono (10.1), que agendar é rotina (20) e, no chefe, como liderar (gerado pelo daemon)
.botloft\
mcp.json config MCP do bot (gerado pelo daemon)
```
@@ -244,10 +244,12 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s
[--effort ]
--permission-prompt-tool mcp__botloft__permission_prompt
--allowedTools mcp__botloft
+ --disallowedTools CronCreate,CronDelete,CronList,ScheduleWakeup,RemoteTrigger
```
- `--setting-sources project,local` e `--strict-mcp-config` deixam de fora hooks, skills, agents, modo de permissão e servidores MCP pessoais do dono: o bot vê o que o Botloft gera. O login da conta não é uma fonte de settings e continua valendo. As instruções pessoais do dono (`%USERPROFILE%\.claude\CLAUDE.md`) não saem por essa flag: o Claude Code as acha subindo as pastas a partir do cwd. Quem as deixa de fora, junto com a memória automática, é o `settings.json` gerado (7.5).
- `--allowedTools mcp__botloft` libera as tools da crew sem aprovação. Uma regra `allow` no `settings.json` do projeto não bastaria: em `-p`, numa pasta que nunca passou pelo diálogo de confiança, o Claude Code não aplica as regras `allow` do projeto (documentado em `permissions`).
+ - `--disallowedTools` tira do bot os agendamentos do próprio Claude Code. `CronCreate` e `ScheduleWakeup` só valem enquanto aquele processo vive: somem num reinício, expiram em 7 dias, e o Botloft não os vê nem os mostra. `RemoteTrigger` cria agendamentos na nuvem, fora do computador. Pedido para agendar, um bot usava `CronCreate` sem pedir permissão e dizia que tinha agendado (19). Trabalho em horário marcado é uma rotina (20).
- Qualquer outra ferramenta que peça permissão passa pela tool de aprovação (10.1) e vira um pedido no chat.
- `--add-dir` põe a pasta de trabalho da crew (5) entre as pastas do bot. Sem ela, gravar na `shared\`, que fica fora do workspace, pedia aprovação até em "Aceitar edições" (19).
- `--permission-mode` vem do modo do bot (`Bot.permissionMode`), que o dono escolhe no chat (15.1): `default` (Manual: pergunta antes de editar, rodar comandos e usar a rede), `accept_edits` (`acceptEdits`: edita arquivos sem perguntar), `plan` (planeja e pede para seguir, 10.1), `auto` (um classificador libera o que é seguro e o resto vira pedido) e `bypass_permissions` (`bypassPermissions`: faz tudo sem perguntar, ver 13). Bot novo começa em `default`. `dontAsk` não é oferecido: nega o que não estiver liberado, e o bot não teria como pedir. Regras `deny` valem em todos os modos.
@@ -511,6 +513,7 @@ Transporte: Streamable HTTP, só POST e resposta `application/json`, sem sessão
| `complete_task` | `task_id`, `result`, `status?` (`done` padrão, `failed`) | `task_id`, `status` e quem recebe o resultado |
| `my_tasks` | `role?` (`assigned`, `requested`) | tasks `open` e `expired`: id, de, para, status, prazo relativo, hops e o pedido original |
| `suggest_bot` | `name`, `role`, `instructions` (até 8 000 caracteres), `model?`, `reason` (até 1 000) | `created` e, criado, `handle`, `name`, `role`, `model` e um lembrete para mandar a primeira task; recusado ou sem resposta, o porquê (10.2). Só o chefe |
+| `schedule_routine` | `name`, `prompt` (até 8 000 caracteres), `schedule` (como em 20.2), `bot?` (handle de outro bot da crew) | `created` e, criada, `routine_id`, nome, pedido, horário, fuso e a próxima vez; recusada ou sem resposta, o aviso de que nada foi agendado (20.12) |
| `permission_prompt` | `tool_name`, `input`, `tool_use_id` | decisão do dono (10.1). Chamada pelo Claude Code, não pelo modelo |
| `browser_*` | seção 21.4 | o navegador do bot: abrir, ler, clicar, digitar, rolar, ver a tela, pedir a mão do dono |
@@ -539,7 +542,7 @@ Com `--permission-prompt-tool mcp__botloft__permission_prompt`, toda ferramenta
- **Quem escreve é o bot.** A explicação vem antes do comando, nunca no lugar dele. O cartão (15.1) a mostra como texto puro, sem markdown nem links, diz que foi o bot que escreveu e que o que roda é o comando, e deixa o comando a um clique, inteiro e com as quebras de linha. Para isso a entrada de um comando é guardada até 32 KB; um comando maior aparece cortado, com o aviso de que não coube. O que o daemon devolve ao Claude Code é a entrada original, a mesma que o dono pôde abrir.
- **Sem explicação**, o cartão diz que o bot não disse para que serve o comando, sugere negar e perguntar, e já vem com o comando aberto. O campo é opcional e o modelo às vezes o deixa de fora (19), então esse caso continua existindo.
- **Regras do bot** (5.1): todo comando leva uma `description` para o dono, que pode não ser técnico: uma frase curta, na língua em que o dono escreve para o bot, dizendo o que o comando faz e por quê, em palavras do dia a dia, sem código, caminhos nem jargão. Sem a regra a descrição vinha curta e técnica, e às vezes em inglês (19). O daemon não conhece o idioma do app; quem acerta a língua é o bot, pela conversa.
-- **Outras ferramentas** não têm explicação: a entrada delas não traz um texto escrito para quem lê. O pedido mostra a ação no idioma do dono (15.6) e o `summary` (o arquivo, a URL), com a entrada inteira em "Detalhes". Um pedido para escrever ou editar um arquivo mostra o nome do arquivo, e uma tela HTML aparece em rascunho na área de design antes do OK (22.3); plano, sugestão de bot e navegador têm cartões próprios. A `description` do `Agent` é o título do trabalho do ajudante e continua sendo o `summary`.
+- **Outras ferramentas** não têm explicação: a entrada delas não traz um texto escrito para quem lê. O pedido mostra a ação no idioma do dono (15.6) e o `summary` (o arquivo, a URL), com a entrada inteira em "Detalhes". Um pedido para escrever ou editar um arquivo mostra o nome do arquivo, e uma tela HTML aparece em rascunho na área de design antes do OK (22.3); plano, sugestão de bot, pedido de rotina (20.12) e navegador têm cartões próprios. A `description` do `Agent` é o título do trabalho do ajudante e continua sendo o `summary`.
**Plano.** No modo `plan`, o bot pede para seguir com a ferramenta `ExitPlanMode`, cuja entrada traz o plano em markdown (`{plan}`). O pedido passa pela mesma tool e vira no chat um cartão com o plano inteiro: a entrada dessa ferramenta é guardada até 32 KB (as outras, até 4 KB) e o resumo é a primeira linha do plano. "Aprovar plano" permite; "Pedir mudanças" nega com a nota do dono, e o bot continua planejando. Depois de aprovado, o Claude Code troca de modo sozinho (7.4). Que o pedido passa pela tool em `-p` e para qual modo o bot vai ainda precisam de teste real (19).
@@ -552,7 +555,7 @@ Toda crew nova nasce com um **chefe**: `crews.create` com `lead` cria a crew e e
- **Regras do chefe:** além das de todo bot, uma seção "You lead this crew": planejar, dividir o trabalho em partes que rodam em paralelo e distribuí-las com `send_message` (`kind: "task"`); conferir os resultados antes de responder ao dono; quando faltar um especialista, sugerir um bot com `suggest_bot`; escolher o modelo pelo trabalho (`haiku` para o simples e repetitivo, `sonnet` para quase tudo, `opus` ou `fable` só para o raciocínio mais difícil, lembrando que gastam mais do plano); manter a crew pequena; e contar ao dono o que cada bot faz, para ele acompanhar e falar com cada um no chat dele. Os outros bots leem que, se a crew precisar de outro bot, devem pedir ao chefe, que o `crew_roster` marca com `chief`.
- **`suggest_bot`:** aparece para todos os bots, mas só o chefe pode usar; os outros recebem um erro que manda pedir ao chefe. Antes de incomodar o dono, o daemon confere os campos (os de um bot, mais o limite de instruções e o porquê), que o nome está livre na crew e que a crew tem menos de `max_per_crew` bots ativos. Um pedido impossível volta como erro ao chefe.
- **Aprovação:** a sugestão vira um pedido no chat do chefe, pelo mesmo caminho das aprovações (10.1): `approval` com `toolName: "mcp__botloft__suggest_bot"`, entrada até 64 KB e resumo com o nome sugerido. O chefe fica `needs_approval`, e a chamada MCP espera a resposta até `approval_timeout_minutes`. A tool vem liberada por `--allowedTools mcp__botloft`, então o Claude Code não pede permissão antes: quem decide é o dono, pelo cartão.
-- **Resposta:** permitir cria o bot na crew do chefe, com modo Manual e o modelo sugerido. O dono pode mudar nome, papel, modelo e instruções antes: `approvals.answer` leva `input`, o JSON da sugestão como ele deixou, que substitui a entrada gravada (o cartão mostra o que foi criado); `input` só vale para sugestões. Negar volta ao chefe com a nota do dono, e nada é criado. Sem resposta no prazo, o chefe lê que pode sugerir de novo depois. O bot novo sobe na hora e aparece na barra lateral, com o próprio chat; o resultado da tool lembra o chefe de mandar a primeira task.
+- **Resposta:** permitir cria o bot na crew do chefe, com modo Manual e o modelo sugerido. O dono pode mudar nome, papel, modelo e instruções antes: `approvals.answer` leva `input`, o JSON da sugestão como ele deixou, que substitui a entrada gravada (o cartão mostra o que foi criado); `input` só vale para sugestões de bot e pedidos de rotina (20.12). Negar volta ao chefe com a nota do dono, e nada é criado. Sem resposta no prazo, o chefe lê que pode sugerir de novo depois. O bot novo sobe na hora e aparece na barra lateral, com o próprio chat; o resultado da tool lembra o chefe de mandar a primeira task.
- **Chefe em `bypass_permissions`:** a sugestão cria o bot na hora, sem cartão, como tudo o que esse modo faz sem perguntar (13). O resultado diz ao chefe que foi criado sem pedir.
- Bots criados assim são bots comuns: o dono conversa, muda, pausa e arquiva cada um como qualquer outro.
@@ -961,6 +964,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf
| Prévia de PDF no painel de arquivos | 15.1 | O PDF vai para um `