/bots — one sentence arms a watch

a bot is a chat whose working directory is bots/, armed through the background
job any chat can already arm. no scheduler was written and none was needed:
handoff already means "poll cheaply, wake the agent only when it fires", which
is the expensive half of a commander without any of its ceremony.

ONE directory, not one per bot. a commander earns its own because it is a job
with procedures and a ledger; a bot is a sentence and a watch, and a directory
each would mean a creation step before anything can be said. sharing it makes
the list free — every bot is a chat in that cwd, so the header's picker is the
list of bots.

it earns NO permission of its own. /bots rides `chat`, because a bot adds no
surface: same routes, same socket, same confinement. the registry says so
rather than leaving the next reader to wonder.

the brief is the feature. it tells the agent to arm on the FIRST message and
not interview — pick a sensible interval, arm, say what you armed in one line,
and let the owner correct it, which is cheaper for them than three questions.
it says the owner will never say "notify" or "nudge" and must not have to. it
forbids the loop that wakes on a timer to look for itself, which is the
expensive mistake available here. and it draws the line: a bot watches and
tells, it does not fix — a request for work is a commander, say so and stop.

bots joins commanders and plugins in confineUserTree, third time: a member's
bot runs as the member and a 700 service-user directory kills the turn on its
own cwd.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-26 07:13:57 +00:00
co-authored by Claude Opus 5
parent 2faf1fa4b2
commit de538cfc1b
11 changed files with 224 additions and 4 deletions
+1
View File
@@ -108,6 +108,7 @@ export function App() {
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
<Route path="/sendoff" element={<Dashboard.SendOffScreen />} />
<Route path="/todos" element={<Dashboard.TodosScreen />} />
<Route path="/bots" element={<Dashboard.BotsScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
@@ -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/<email>/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 }) => (
<div className="flex h-full min-h-0 flex-col">
<ChatPanelWrapper panelId={panelId} chatContext={CHAT_CONTEXT} />
</div>
);
const BotsChatHeader = ({ panelId }: { panelId: string }) => (
<ChatHeader panelId={panelId} chatContext={CHAT_CONTEXT} />
);
export const BotsScreen = () => {
const workspace = useAuthoredLayout('screens/bots', defaultLayout as LayoutNode);
return (
<WorkspaceView
workspace={workspace}
locked
appTypes={{ allowed: ['bots-chat'], fallback: 'bots-chat' }}
components={{ 'bots-chat': { component: BotsChat, header: BotsChatHeader } }}
/>
);
};
@@ -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',
};
@@ -0,0 +1 @@
export { BotsScreen } from './BotsScreen';
@@ -24,3 +24,4 @@ export * from './Jobs';
export * from './QrTransfer';
export * from './SendOff';
export * from './Todos';
export * from './Bots';