Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Botloft

Workspace desktop Windows-first, com código público sob a FSL (vira Apache 2.0 após dois anos), para rodar bots Claude Code sempre ligados que colaboram entre si. Daemon Rust (`botloftd`) + app Tauri v2 + React.
Workspace desktop Windows-first, com código público sob a FSL (vira Apache 2.0 após dois anos), para rodar bots de agentes de código sempre ligados que colaboram entre si: Claude Code, Antigravity (experimental) e, em seguida, Codex. A seção 30 da spec e o ADR 0003 dizem como cada agente entra. Daemon Rust (`botloftd`) + app Tauri v2 + React.

## Fonte de verdade

Expand Down
54 changes: 36 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<h1 align="center">Botloft</h1>

<p align="center">
<b>A crew of Claude Code bots that stays on and works together on your computer.</b>
<b>A crew of AI coding bots that stays on and works together on your computer, on the agent you prefer.</b>
</p>

<p align="center">
Expand All @@ -32,8 +32,9 @@
<img src="docs/assets/hero-light.png" alt="Botloft with the Research crew open: seven bots, each with its own color, state and job">
</picture>

Botloft keeps a crew of [Claude Code](https://code.claude.com) bots running on your computer. Each bot has
its own folder, memory and conversation. They pass work to each other, keep going after you close the
Botloft keeps a crew of bots running on your computer, each on the AI coding agent you choose:
[Claude Code](https://code.claude.com), or [Antigravity](https://antigravity.google) as an experimental
option, with Codex next. Each bot has its own folder, memory and conversation. They pass work to each other, keep going after you close the
window and pick up where they left off after a restart. You talk to each one like in a chat app, and
you decide what they may do.

Expand All @@ -55,8 +56,8 @@ you decide what they may do.
- **Leave them running.** Bots keep working after you close Botloft. They start when you sign in,
come back soon after a crash and keep the computer awake while they work. Botloft
waits near the clock and tells you when a bot needs you.
- **Choose per bot.** How much it asks before acting, and which Claude model it uses. See how much
of your plan's usage is left.
- **Choose per bot.** Which agent it runs on, which model it uses, and how much it asks before
acting. With Claude Code, see how much of your plan's usage is left.
- **Back up and move.** Save a sealed backup file, or sign in with your e-mail (no password) to keep
a light copy in the cloud and restore it on another computer. The server cannot read it. Turn on
automatic copies, every day or week and only when something changed, if you want them to go by
Expand All @@ -76,10 +77,26 @@ you decide what they may do.
Botloft speaks English, Portuguese (Brazil) and Spanish, follows your light or dark theme and updates
itself.

## Agents

| | Claude Code | Antigravity (experimental) |
|---|---|---|
| Chat, crew messages, tasks, routines, the live browser | yes | chat, crew messages and tasks; the rest is not checked yet |
| Asks you before acting | yes: each tool, with "always allow" | no: it cannot ask. It works with files in its own folders; commands you list per bot (or "full access") |
| Images | yes | yes, by file |
| Model | your plan's default, or one you pick | its own list; the effort is part of the name |
| Usage meter and "Compact" | yes | an estimate of the conversation size; no compaction |
| Isolation between crews | yes | folders are fenced for its file tools, not for commands you allow |

Claude Code is the full experience. Antigravity is off until you add `experimental_agents = ["agy"]`
to Botloft's `config.toml`; then a new bot can be made on it, and Settings has "Agent for new
bots". Codex is the next agent. See [spec section 30](docs/spec.md) for what was checked.

## Get started

You need [Claude Code](https://code.claude.com/docs/en/setup), installed and signed in once with
your Claude account. Bots run on your own Claude plan.
You need at least one agent installed and signed in once, with your own account:
[Claude Code](https://code.claude.com/docs/en/setup), or Antigravity (experimental, see
[Agents](#agents)). Bots run on your own plan with that agent.

1. Download Botloft for your computer from the
[latest release](https://github.com/httpsphl/botloft/releases/latest):
Expand Down Expand Up @@ -112,15 +129,16 @@ app. Your bots and their files stay either way (see
flowchart LR
app["Botloft app<br/>Tauri + React"] -- "JSON-RPC over WebSocket<br/>127.0.0.1 only" --> daemon["botloftd<br/>Rust daemon"]
daemon --> db[("SQLite")]
daemon -- "stdin and stdout<br/>stream-json" --> bots["One claude -p<br/>per bot"]
daemon -- "stdin and stdout<br/>stream-json" --> bots["One agent process<br/>per bot"]
bots -- "Botloft's MCP tools<br/>messages, tasks, requests" --> daemon
```

- **The daemon is the source of truth.** Closing the app never stops a bot. `botloftd` runs as a
scheduled task of your Windows user, a systemd user service on Linux or a launch agent on macOS:
it starts when you sign in and comes back soon if it dies.
- **Claude Code is the runtime.** Each bot is a real `claude -p` session with its own workspace,
memory (`CLAUDE.md`) and conversation. There is no custom agent SDK and no API key to manage.
- **Your agent is the runtime.** Each bot is a real session of the agent you chose (`claude -p` for
Claude Code, `agy -p` for Antigravity) with its own workspace, memory and conversation. There is
no custom agent SDK and no API key to manage.
- **One conversation per bot.** Your messages, messages from other bots and notices from the daemon
reach the bot in order. What it does comes back as a chat: replies, tool use and requests you
allow or deny.
Expand All @@ -134,7 +152,7 @@ The full design, from the protocol to the states of a bot, lives in [`docs/spec.

- **Local first.** The daemon listens on `127.0.0.1`, and there is no telemetry. An account is
optional: without one, Botloft uses the network for nothing beyond updates and what bots send to
Claude, which goes through Claude Code as when you use it yourself. With one, the server only ever
the agent's provider, which goes through the agent as when you use it yourself. With one, the server only ever
holds a backup sealed with a password that never leaves your computer (see the FAQ).
- **You decide what bots may do.** Per bot: ask before anything, accept edits, plan only, or decide on
its own what needs your OK. Unless it decides on its own, a bot also asks before its first visit
Expand All @@ -143,7 +161,7 @@ The full design, from the protocol to the states of a bot, lives in [`docs/spec.
Each bot gets its own token per start, and the daemon keeps only its SHA-256 hash.
- **Botloft is not a sandbox.** Every bot runs as your user account, so isolation between bots is
cooperative: a bot can read other bots' folders if it decides to. Give bots only the permissions
you would give Claude Code directly.
you would give that agent directly.

What Botloft sends over the network, what it changes on your computer and how to remove it are in
the [code signing policy](docs/code-signing-policy.md).
Expand All @@ -154,8 +172,8 @@ the [code signing policy](docs/code-signing-policy.md).
<summary><b>Does Botloft cost anything?</b></summary>

No. Botloft is free to use, at home or at work, and its source code is public (see
[License](#license)). The bots run on Claude Code with
your own Claude account, so they use your plan's limits; Botloft shows how much is left.
[License](#license)). The bots run on the agent you chose, with
your own account, so they use your plan's limits; with Claude Code, Botloft shows how much is left.
</details>

<details>
Expand Down Expand Up @@ -212,8 +230,8 @@ Windows came first and has had the most use; if something does not work elsewher
<details>
<summary><b>Is Botloft made by Anthropic?</b></summary>

No. Botloft is an independent project. It uses Claude Code as installed on your computer
and is not affiliated with or endorsed by Anthropic.
No. Botloft is an independent project. It uses Claude Code and Antigravity as installed on your
computer and is not affiliated with or endorsed by Anthropic or Google.
</details>

## Status
Expand All @@ -228,8 +246,8 @@ and a code-signed installer. `main` can be ahead of the latest release.

Requirements: Rust stable (the MSVC toolchain on Windows), Node.js 24 and pnpm. On Linux, the app
also needs the desktop libraries Tauri uses (on Ubuntu: `libwebkit2gtk-4.1-dev`,
`libayatana-appindicator3-dev`, `librsvg2-dev`, `libxdo-dev`). Running real bots also needs Claude
Code (the native `claude` executable) installed and signed in.
`libayatana-appindicator3-dev`, `librsvg2-dev`, `libxdo-dev`). Running real bots also needs an agent
installed and signed in: Claude Code (the native `claude` executable) or Antigravity (`agy`).

```powershell
# Daemon and libraries
Expand Down
19 changes: 19 additions & 0 deletions app/src/features/account/GeneralSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,16 @@ import {
useLocale,
useT,
} from "../../i18n";
import type { AgentKind } from "../../lib/protocol.gen";
import { SYSTEM } from "../../lib/system";
import { prefs, usePref } from "../../shell/prefs";
import { useApp } from "../../store/context";
import { Select } from "../../ui/Select";
import { Section, Toggle } from "./settingsParts";
import { useDaemonSettings } from "./useDaemonSettings";

const NO_AGENTS: AgentKind[] = [];

export function GeneralSettings() {
const t = useT();
const s = t.account.settings;
Expand All @@ -23,6 +27,7 @@ export function GeneralSettings() {
const openAtSignIn = usePref(prefs.openAtSignIn);
const nearClock = whenClosed === "keep" && tray;
const { settings, change } = useDaemonSettings();
const enabledAgents = useApp((state) => state.system?.enabledAgents ?? NO_AGENTS);
const startWithWindows = settings?.startWithWindows ?? true;
const { choice: locale } = useLocale();
const systemName = LOCALES.find((entry) => entry.id === systemLocale())?.name ?? "English";
Expand All @@ -36,6 +41,20 @@ export function GeneralSettings() {
<Section title={s.language}>
<Select label={s.language} value={locale} options={languages} onChange={setLocaleChoice} />
</Section>
{settings && enabledAgents.length > 1 && (
<Section title={s.defaultAgent}>
<Select
label={s.defaultAgent}
value={settings.defaultAgent}
options={enabledAgents.map((agent) => ({
value: agent,
label: t.bots.dialog.agent.names[agent],
}))}
onChange={(agent) => change({ defaultAgent: agent })}
/>
<p className="text-muted text-xs leading-relaxed">{s.defaultAgentHint}</p>
</Section>
)}
<Section title={s.background}>
<Toggle
label={s.keepWorking}
Expand Down
1 change: 1 addition & 0 deletions app/src/features/account/settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ describe("settings", () => {
startWithWindows: false,
keepAwake: false,
approvalWaitMinutes: 60,
defaultAgent: "claude",
}),
);
expect(
Expand Down
1 change: 1 addition & 0 deletions app/src/features/account/settingsIndex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export function settingsIndex(t: ReturnType<typeof useT>): Entry[] {
...entry(
"general",
s.language,
s.defaultAgent,
s.background,
s.keepWorking,
s.tray,
Expand Down
1 change: 1 addition & 0 deletions app/src/features/account/useDaemonSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export function useDaemonSettings(): {
startWithWindows: update.startWithWindows ?? before.startWithWindows,
keepAwake: update.keepAwake ?? before.keepAwake,
approvalWaitMinutes: update.approvalWaitMinutes ?? before.approvalWaitMinutes,
defaultAgent: update.defaultAgent ?? before.defaultAgent,
});
}
attempt(failed, async () => putSettings(await api.call("settings.update", update))).then(
Expand Down
34 changes: 25 additions & 9 deletions app/src/features/bots/BotDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useApi, useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { Dialog } from "../../ui/Dialog";
import { SelectField, TextArea, TextField } from "../../ui/Field";
import { Toggle } from "../account/settingsParts";
import { BotAvatar } from "./BotAvatar";
import { ColorPicker } from "./ColorPicker";

Expand Down Expand Up @@ -41,8 +42,13 @@ export function BotDialog(props: Props) {
const [commands, setCommands] = useState((editing?.allowedCommands ?? []).join("\n"));
// Which agent runs the bot is chosen once, when it is made (spec 30).
const agents = useApp((state) => state.system?.enabledAgents ?? CLAUDE_ONLY);
const [agent, setAgent] = useState<AgentKind>("claude");
const preferred = useApp((state) => state.settings?.defaultAgent ?? "claude");
const [agent, setAgent] = useState<AgentKind>(agents.includes(preferred) ? preferred : "claude");
const runsOn: AgentKind = editing?.agent ?? agent;
const fullAccess = commands
.split("\n")
.map((line) => line.trim())
.includes("*");
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);

Expand Down Expand Up @@ -148,14 +154,24 @@ export function BotDialog(props: Props) {
</div>
)}
{editing && editing.agent !== "claude" && (
<TextArea
label={t.bots.dialog.commands.title}
value={commands}
rows={4}
onChange={(event) => setCommands(event.target.value)}
placeholder={t.bots.dialog.commands.placeholder}
hint={t.bots.dialog.commands.hint}
/>
<>
<Toggle
label={t.bots.dialog.fullAccess.title}
hint={fullAccess ? t.bots.dialog.fullAccess.on : t.bots.dialog.fullAccess.off}
checked={fullAccess}
onChange={(on) => setCommands(on ? "*" : "")}
/>
{!fullAccess && (
<TextArea
label={t.bots.dialog.commands.title}
value={commands}
rows={4}
onChange={(event) => setCommands(event.target.value)}
placeholder={t.bots.dialog.commands.placeholder}
hint={t.bots.dialog.commands.hint}
/>
)}
</>
)}
{runsOn === "claude" && (
<SelectField
Expand Down
2 changes: 1 addition & 1 deletion app/src/features/catalog/agency.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ describe("the Bot agency", () => {
target: { value: "zzz" },
});
expect(within(dialog).getByText("No bot matches that.")).toBeDefined();
});
}, 20_000);

test("Learn more shows what the bot does and what it is told, and adds it from there", async () => {
const fake = await openCrewWithChief();
Expand Down
40 changes: 36 additions & 4 deletions app/src/features/onboarding/Welcome.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CircleCheck, CircleX, LoaderCircle, Plus } from "lucide-react";
import { CircleCheck, CircleX, LoaderCircle, Minus, Plus } from "lucide-react";
import { type ReactNode, useState } from "react";
import { useT } from "../../i18n";
import type { AgentCheck } from "../../lib/protocol.gen";
import { SYSTEM } from "../../lib/system";
import { prefs, usePref } from "../../shell/prefs";
import { useApp } from "../../store/context";
Expand All @@ -12,6 +13,8 @@ import { CREW_TEMPLATES, type CrewTemplate } from "../crews/crewTemplates";
import { ClaudeCodeHelp } from "./ClaudeCodeHelp";
import { SignInButton } from "./SignIn";

const NO_CHECKS: AgentCheck[] = [];

/** Templates shown as ideas on the first screen. */
const IDEAS = 4;

Expand All @@ -23,6 +26,8 @@ export function Welcome() {
const system = useApp((state) => state.system);
const [creating, setCreating] = useState(false);
const [idea, setIdea] = useState<CrewTemplate | undefined>(undefined);
const checks = useApp((state) => state.system?.agentChecks ?? NO_CHECKS);
const otherReady = checks.some((check) => check.version !== null);
const whenClosed = usePref(prefs.whenClosed);
const { settings } = useDaemonSettings();
const running =
Expand All @@ -35,6 +40,9 @@ export function Welcome() {
let claude: ReactNode;
if (!system || (system.claudeVersion === null && system.runtimeError === null)) {
claude = <Check state="pending" title={w.claudeCode} detail={w.checking} />;
} else if (system.runtimeError && otherReady) {
// Another agent is ready: Claude Code is only for the bots that use it.
claude = <Check state="skipped" title={w.claudeCode} detail={w.claudeOptional} />;
} else if (system.runtimeError) {
claude = (
<Check
Expand All @@ -49,6 +57,15 @@ export function Welcome() {
);
}

const agents = checks.map((check) => (
<Check
key={check.agent}
state={check.version === null ? "bad" : "ok"}
title={t.bots.dialog.agent.names[check.agent]}
detail={check.version === null ? w.agentMissing : w.version(check.version)}
/>
));

// Only once Claude Code is there: signing in needs it.
let account: ReactNode = null;
if (system && !system.runtimeError && system.claudeVersion !== null) {
Expand Down Expand Up @@ -84,6 +101,7 @@ export function Welcome() {
<Check state="ok" title={w.botloft} detail={running} />
{claude}
{account}
{agents}
</ul>
<div className="mt-4 rounded-xl border border-line bg-panel p-4 text-sm leading-relaxed">
<p className="font-semibold">{w.askFirstTitle}</p>
Expand Down Expand Up @@ -134,12 +152,19 @@ function Check({
title,
detail,
}: {
state: "ok" | "bad" | "pending";
state: "ok" | "bad" | "pending" | "skipped";
title: string;
detail: ReactNode;
}) {
const w = useT().onboarding.welcome;
const Icon = state === "ok" ? CircleCheck : state === "bad" ? CircleX : LoaderCircle;
const Icon =
state === "ok"
? CircleCheck
: state === "bad"
? CircleX
: state === "skipped"
? Minus
: LoaderCircle;
const tone = state === "ok" ? "text-ok" : state === "bad" ? "text-danger" : "text-muted";
return (
<li className="flex items-start gap-3 border-line border-b px-4 py-3 last:border-b-0">
Expand All @@ -152,7 +177,14 @@ function Check({
<p className="font-semibold">
{title}
<span className="sr-only">
: {state === "ok" ? w.ready : state === "bad" ? w.notReady : w.stillChecking}
:{" "}
{state === "ok"
? w.ready
: state === "bad"
? w.notReady
: state === "skipped"
? w.notNeeded
: w.stillChecking}
</span>
</p>
<div className="text-ink-soft" data-selectable>
Expand Down
Loading
Loading