diff --git a/src/apps/officer-web/App.tsx b/src/apps/officer-web/App.tsx
index c52abe40..e3cbd8d7 100644
--- a/src/apps/officer-web/App.tsx
+++ b/src/apps/officer-web/App.tsx
@@ -108,6 +108,7 @@ export function App() {
} />
} />
} />
+ } />
} />
} />
diff --git a/src/apps/officer-web/Screens/Dashboard/Bots/BotsScreen.tsx b/src/apps/officer-web/Screens/Dashboard/Bots/BotsScreen.tsx
new file mode 100644
index 00000000..300986c6
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Bots/BotsScreen.tsx
@@ -0,0 +1,47 @@
+import type { LayoutNode } from 'officerdev';
+import { WorkspaceView, ChatPanelWrapper, ChatHeader } from 'officerdev';
+import { useAuthoredLayout } from 'state/useAuthoredLayout';
+import { defaultLayout } from './defaultLayout';
+
+/**
+ * `/bots` — one sentence arms a watch, and the conversation stays open to widen it.
+ *
+ * ── Why it is a chat and nothing else ──
+ *
+ * A bot has no settings worth a form. What it watches, how often, and who hears about it are all things
+ * the owner says in a sentence, and the agent's job is to turn that into an armed background job without
+ * an interview. The instructions live in `$DATA_PATH//bots/CLAUDE.md`, written by the server on
+ * every turn — see `api/bots/brief.ts`.
+ *
+ * ── Why the panel is supplied here rather than named in the registry ──
+ *
+ * Every bot runs in one shared directory, which the server resolves from `context: 'bots'`. The registry's
+ * `officerdev/chat` derives its context from the workspace and would land on none. `components` is exactly
+ * the prop for a screen that supplies its own chat panel — `ChatPanelWrapper` says so — and it carries the
+ * header with it, so the picker lists bots rather than every chat on the machine.
+ */
+
+const CHAT_CONTEXT = { context: 'bots' } as const;
+
+const BotsChat = ({ panelId }: { panelId: string }) => (
+
+
+
+);
+
+const BotsChatHeader = ({ panelId }: { panelId: string }) => (
+
+);
+
+export const BotsScreen = () => {
+ const workspace = useAuthoredLayout('screens/bots', defaultLayout as LayoutNode);
+
+ return (
+
+ );
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Bots/defaultLayout.ts b/src/apps/officer-web/Screens/Dashboard/Bots/defaultLayout.ts
new file mode 100644
index 00000000..4dbb76cc
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Bots/defaultLayout.ts
@@ -0,0 +1,12 @@
+import type { LayoutNode } from 'officerdev';
+
+// One panel: the conversation.
+//
+// A bot is armed, corrected and widened by talking to it — there is no form that would do it better, and
+// the header's own picker is the list of bots. A second pane listing them would repeat the header and take
+// width from the only thing on this screen anybody reads.
+export const defaultLayout: LayoutNode = {
+ type: 'panel',
+ id: 'bots-chat',
+ appType: 'bots-chat',
+};
diff --git a/src/apps/officer-web/Screens/Dashboard/Bots/index.tsx b/src/apps/officer-web/Screens/Dashboard/Bots/index.tsx
new file mode 100644
index 00000000..422420dd
--- /dev/null
+++ b/src/apps/officer-web/Screens/Dashboard/Bots/index.tsx
@@ -0,0 +1 @@
+export { BotsScreen } from './BotsScreen';
diff --git a/src/apps/officer-web/Screens/Dashboard/index.tsx b/src/apps/officer-web/Screens/Dashboard/index.tsx
index ac9eaac8..ac8e4fb9 100644
--- a/src/apps/officer-web/Screens/Dashboard/index.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/index.tsx
@@ -24,3 +24,4 @@ export * from './Jobs';
export * from './QrTransfer';
export * from './SendOff';
export * from './Todos';
+export * from './Bots';
diff --git a/src/servers/api/bots/brief.ts b/src/servers/api/bots/brief.ts
new file mode 100644
index 00000000..a0acecde
--- /dev/null
+++ b/src/servers/api/bots/brief.ts
@@ -0,0 +1,104 @@
+import { join } from 'node:path';
+import { mkdirSync } from 'node:fs';
+import { getBotsDir } from '@@/data-path';
+import { writeAtomic } from '@@/write-atomic';
+
+/**
+ * The instructions every bot runs under.
+ *
+ * Written into `$DATA_PATH//bots/CLAUDE.md`, which Claude Code reads from its working directory at
+ * session start. Rewritten on every bot turn, so a change here reaches every bot on the next message with
+ * nothing to migrate — the same arrangement as a plugin's chat brief.
+ *
+ * ── Why this file is short, and a commander's is not ──
+ *
+ * A commander is a job: it has procedures, a ledger, classification rules, a dispatch policy, and a setup
+ * conversation to settle all of it. A bot is one sentence and a watch. Everything a commander's template
+ * spends words on — proving the action on one item, arming only once the owner agrees, writing JOB.md —
+ * is ceremony a bot does not have. The whole point is that the first message arms it.
+ */
+const BRIEF = `# Bots
+
+Every conversation in this directory is a **bot**: one thing watched, one thing done when it changes.
+
+**This file is the platform's and is rewritten on every turn.** Anything you want to keep goes in a file
+you create beside it.
+
+## Arm on the first message. Do not interview.
+
+The owner says what they want in one sentence. Work out what it means and **arm it in that same turn**.
+
+> "Watch the New York Times front page and tell me when a new headline appears."
+> "Ping my site every few minutes and let the todo chat know if it goes down."
+
+Neither of those wants a conversation about it. Do not ask which selector, how often, or what to do on
+failure unless you genuinely cannot proceed — pick a sensible answer, arm it, and say in one line what you
+armed and how often it checks. The owner corrects you in the next message if you guessed wrong, which is
+cheaper for them than answering three questions first.
+
+**Never ask the owner to phrase it differently.** They will not say "notify" or "nudge" and should not have
+to. "Tell me", "let me know", "ping me", "message the todo guy" all mean the same two things below.
+
+## Arming is a background job, and nothing else
+
+Use the background job you already know, with the **hand-back-then-re-arm** behaviour. Its own instructions
+tell you the rest; the parts that matter here:
+
+- The watch is a **cheap shell check that exits when the condition is true.** Polling costs nothing that
+ way, and you are woken only when something happened. Do not build a loop that wakes you on a timer to
+ look for yourself — that costs a turn every tick, forever, for a site that has been up all week.
+- **Exit is the signal.** Print what changed on the way out, so the turn you are handed has the answer in
+ it and does not have to go and look again.
+- **Re-arm before you end the turn**, exactly as you armed it the first time. Same condition, same command.
+
+## What to do when it fires
+
+Two things, and the owner will not name either of them:
+
+- **Tell the owner.** A notification reaches their phone. Send one when what fired is worth an interruption
+ — a site down, a headline they asked to know about. Not "I checked and nothing happened".
+- **Tell another chat.** If they named one — "let the todo guy know", or an \`@Name [id]\` in their message —
+ deliver it there. The id in a mention is a prefix; resolve it rather than using it raw.
+
+Then re-arm, and stay quiet until the next fire.
+
+## Remembering things
+
+The interesting watches compare against last time — a headline, a hash, a status. Keep that in a file in
+this directory named after this bot, and say in your first reply what you called it. Do not keep it only in
+the conversation: you are woken into a turn that may be days later, and the file is what survives.
+
+## Adding to a bot
+
+The owner will come back and add more — another site to ping, another page to watch. That is the same bot:
+widen the check, re-arm it, and confirm in one line what it now covers. Do not start a second watch running
+beside the first unless the two genuinely need different intervals.
+
+## What a bot does not do
+
+It watches and it tells. It does not fix things, deploy things, or change anything it is watching. A
+request that wants work done is a request for a commander or an ordinary chat — say so plainly and stop
+rather than quietly becoming one.
+`;
+
+/**
+ * Put the brief where a bot will read it, and make sure the directory exists.
+ *
+ * Best effort by contract: a turn must still start if this fails. The bot then knows less, which is where
+ * every chat was before this existed — worse than being briefed, better than not answering.
+ */
+export async function ensureBotsBrief(email: string): Promise {
+ const dir = getBotsDir(email);
+ mkdirSync(dir, { recursive: true });
+
+ try {
+ const target = join(dir, 'CLAUDE.md');
+ // Skip the write when nothing changed, so the directory's mtimes do not churn on every message.
+ const file = Bun.file(target);
+ if (!(await file.exists()) || (await file.text()) !== BRIEF) await writeAtomic(target, BRIEF);
+ } catch (err) {
+ console.error('[bots] could not write the brief:', err instanceof Error ? err.message : err);
+ }
+
+ return dir;
+}
diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts
index ddd51d69..b5559053 100644
--- a/src/servers/api/chat/chat.ts
+++ b/src/servers/api/chat/chat.ts
@@ -1,4 +1,4 @@
-import { getPluginChatsDir } from '@@/data-path';
+import { getPluginChatsDir, getBotsDir } from '@@/data-path';
import type { Context } from 'hono';
import { emitToSession, sessionKeyForTranscript } from './websocket';
import { pauseBackgroundTask, restartBackgroundTask } from './task-restart';
@@ -125,6 +125,8 @@ const cwdOf = (ctx: Context, who: { home: string; email: string }): string => {
const context = ctx.req.query('context');
const contextId = ctx.req.query('contextId')?.trim();
if (context === 'plugin' && contextId) return getPluginChatsDir(who.email, contextId);
+ // Every bot shares one directory, so this needs no id — the context IS the address.
+ if (context === 'bots') return getBotsDir(who.email);
return ctx.req.query('cwd')?.trim() || who.home;
};
diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts
index 81522b19..f9a41973 100644
--- a/src/servers/api/chat/websocket.ts
+++ b/src/servers/api/chat/websocket.ts
@@ -43,6 +43,7 @@ import { claudeLoginState } from '@@/os-user-claude';
import { mkdirSync, existsSync } from 'node:fs';
import { snapshotPlugins } from '@@/plugins/mount';
import { writeAtomic } from '@@/write-atomic';
+import { ensureBotsBrief } from '../bots/brief';
import { logger } from './logger';
import { isSuperAdmin } from '@@/super-admin';
import { snapshotClaudeUsage } from './usage-store';
@@ -336,6 +337,18 @@ async function resolveChatCwd(
return dir;
}
+ /*
+ Every bot runs in ONE directory — `$DATA_PATH//bots` — rather than one each.
+
+ A commander gets its own because it is a job with procedures and a ledger. A bot is a sentence and a
+ watch, and a directory per bot would mean a creation step before anything can be said. Sharing it also
+ means the list is free: `/chat/sessions?cwd=` already answers "what bots do I have", because Claude
+ groups transcripts by the directory they ran in.
+
+ The brief is written here on every turn, so a change to it reaches every bot on the next message.
+ */
+ if (msg.context === 'bots') return ensureBotsBrief(email);
+
if (msg.context === 'email') return resolveEmailCwd(email, msg.contextId);
if (msg.context === 'chat') {
if (msg.cwd?.trim()) return resolveCwd(home, msg.cwd);
diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts
index 8f46ba1a..bdb8da0e 100644
--- a/src/servers/data-path.ts
+++ b/src/servers/data-path.ts
@@ -263,6 +263,26 @@ export const getCommandersDir = (email: string) => join(DATA_PATH, email, 'comma
*/
export const getPluginsDir = (email: string) => join(DATA_PATH, email, 'plugins');
+/**
+ * Where every bot runs — `$DATA_PATH//bots`.
+ *
+ * ── One directory, not one per bot ──
+ *
+ * A commander gets its own, because a commander is a job with procedures, a ledger and its own rules. A
+ * bot is one sentence and a watch, and giving each one a directory would mean a tree of near-empty
+ * folders and a creation step before anything can be said.
+ *
+ * Sharing it also gives the list for free: every bot is a chat whose cwd is this, so Claude groups their
+ * transcripts together and `/chat/sessions?cwd=` is already the answer to "what bots do I have".
+ *
+ * A bot that needs to remember something — the headline it saw last time — writes a file here named after
+ * itself. The `CLAUDE.md` in this directory says so; it is the platform's and is rewritten on every bot
+ * turn, the same arrangement as a plugin's chat brief.
+ *
+ * Confined like `commanders` and `plugins`: a member's bot runs as the member, so this must be theirs.
+ */
+export const getBotsDir = (email: string) => join(DATA_PATH, email, 'bots');
+
export const getPluginChatsDir = (email: string, appName: string): string => {
if (!appName || appName.includes('/') || appName.includes('\\') || appName === '.' || appName === '..') {
throw new Error(`refusing to resolve a plugin directory for ${JSON.stringify(appName)}`);
diff --git a/src/servers/os-user.ts b/src/servers/os-user.ts
index f865eb66..1ff9067c 100644
--- a/src/servers/os-user.ts
+++ b/src/servers/os-user.ts
@@ -2,7 +2,15 @@ import { chmod, mkdir, readdir, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { secretStorePath } from 'officerdb/secret-store';
-import { DATA_PATH, USER_DIRS, getOwnerHomeDir, getCommandersDir, getPluginsDir, toShellUsername } from './data-path';
+import {
+ DATA_PATH,
+ USER_DIRS,
+ getOwnerHomeDir,
+ getCommandersDir,
+ getPluginsDir,
+ getBotsDir,
+ toShellUsername,
+} from './data-path';
// Real Linux accounts for members, so the surfaces that execute code can run as them.
//
@@ -347,7 +355,7 @@ export async function confineUserTree(params: {
// dies on its own cwd before running anything, and it reads as the agent being broken.
const entries = await readdir(accountDir, { withFileTypes: true });
for (const entry of entries) {
- if (entry.name === 'home' || entry.name === 'commanders' || entry.name === 'plugins') continue;
+ if (['home', 'commanders', 'plugins', 'bots'].includes(entry.name)) continue;
if (!entry.isDirectory()) continue;
await chmod(join(accountDir, entry.name), 0o700);
}
@@ -450,6 +458,14 @@ export async function confineUserTree(params: {
const closePlugins = await run(['sudo', '-n', 'chmod', '700', pluginsDir]);
if (!closePlugins.ok) return { ok: false, error: `chmod of ${pluginsDir} failed: ${closePlugins.out}` };
+ // And `bots`, for the same reason a third time: a member's bot runs as the member, in this directory.
+ const botsDir = getBotsDir(params.email);
+ if (!existsSync(botsDir)) await mkdir(botsDir, { recursive: true });
+ const giveBots = await run(['sudo', '-n', 'chown', '-R', `${params.uid}:${params.gid}`, botsDir]);
+ if (!giveBots.ok) return { ok: false, error: `chown of ${botsDir} failed: ${giveBots.out}` };
+ const closeBots = await run(['sudo', '-n', 'chmod', '700', botsDir]);
+ if (!closeBots.ok) return { ok: false, error: `chmod of ${botsDir} failed: ${closeBots.out}` };
+
// ── One directory where container bind mounts can live ──
//
// Created for every account, not only the ones that get a daemon: it is two `install` calls, and
diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts
index 356569d1..e85f98e9 100644
--- a/src/servers/permissions/registry.ts
+++ b/src/servers/permissions/registry.ts
@@ -319,7 +319,10 @@ const CORE_REGISTRY: Permission[] = [
// input. Riding this permission rather than a new one, because it is meaningless without `chat`
// already granted.
ws: ['chat', 'chat-notify'],
- routes: ['/chat'],
+ // `/bots` is a chat with a context, not a second feature: a bot IS a conversation whose working
+ // directory is `bots/`, armed through the background job any chat can arm. It earns no permission of
+ // its own because it adds no surface — the same routes, the same socket, the same confinement.
+ routes: ['/chat', '/bots'],
},
// Confined rather than execution since 2026-08-11. Every path under `/file-browser` resolves its root
// through `resolveHomeDir(userId)` in a middleware that refuses the request outright when the account has