bullmoose is a self-hosted personal-data platform — email, contacts, calendar, and email-native agents — that runs serverless on Cloudflare's free tier. Your domain, your data, one core: modern clients speak JMAP, Apple devices sync over CardDAV/CalDAV, legacy apps reach mail through a POP3/SMTP shim, and agents are simply mailboxes with a runtime attached. A typical personal deployment costs $0/month (excluding your domain costs).
What it does ·
Who it's for ·
The stack ·
How it's built ·
Deploying your own ·
Agent-backed accounts ·
Status
Playbooks ·
Deploy checklist ·
Architecture ·
Cookbook
- Mail on your own domain — a full JMAP server (RFC 8620/8621): send, receive, threads, search, push, drafts, vacation responses. Inbound arrives via Cloudflare Email Routing; outbound relays through AWS SES with DKIM/SPF/DMARC wired by the provisioning API (swappable to Cloudflare Email Sending once it exits beta).
- Contacts and calendar as the source of truth — JSContact (RFC 9553/9610) and JSCalendar (RFC 8984) stored losslessly, with a capped on-demand recurrence engine (DST-correct, timezone-aware) and CLI importers to converge your existing data in (vCard exports, Google Calendar).
- Native app sync — the
anglebracketsworker serves CardDAV + CalDAV (the minimal verb subset real clients use), so Apple Contacts/Calendar sync against the same core your agents read. Idle device polls cost one row read. - Agents as mailboxes — bind a runtime to an address
(
editor@,analyst@) and it replies, extracts receipts into a spend ledger, or runs your own pipeline; SLA watchdogs auto-respond if an agent goes quiet. A read-only analytics MCP gives agents safe tools over the message log with zero external credentials. - Sharing and delegation, built correctly — cross-account grants
(effective rights = token ∩ grant, every access audited) back both
agent delegation and family sharing (
AddressBook.shareWith); a write-only, envelope-encrypted credential vault holds third-party API keys and OAuth refresh tokens. - A real CLI —
bullmoose: login/autodiscovery, offline-capable sync to local SQLite, send (Markdown → MIME with inline images and big-file links), watch (live push), contacts/calendar import and agenda, token/grant/credential administration. Fully self-documenting —bullmoose help,bullmoose help --json, or the CLI reference.
- Self-hosters who want mail + contacts + calendar on their own domain without running (or trusting) a mail VPS — there are no servers here to patch.
- Families — multi-tenant schema, shared address books, per-device revocable app-passwords.
- Agent builders who want email-native agents with real tools, scoped delegated access, and an auditable trail — not a bot with your whole inbox password.
- Protocol tinkerers — a compact, readable implementation of the modern IETF personal-data stack (see the table below), e2e-tested against real clients and real data.
| layer | contacts | calendar | |
|---|---|---|---|
| JSON model | JSContact — RFC 9553 | JSCalendar — RFC 8984 | JMAP Mail - RFC 8621 |
| JMAP methods | RFC 9610 | draft-ietf-jmap-calendars (pragmatic core) | RFC 8620/8621 |
| Compat APIs | vCard 6350 over CardDAV 6352 | iCalendar 5545 over CalDAV 4791 | POP3/SMTP via the popcorn shim |
| translation | RFC 9555 | JSCalendar ⇄ iCalendar | RFC 5322 MIME |
Five stateless workers around
one stateful actor: each account has a Durable Object
owning a monotonic state sequence and a collection-agnostic changelog
— mail, contacts, calendar, and agent queues all sync through the same
commit//changes/push machinery. D1
holds metadata and JSON documents; R2
holds bytes (raw messages, attachments, contact photos). Rationale, diagrams, and the free-tier
capacity story live in docs/architecture/.
The reusable logic lives in ten packages; the six
services compose them into deployed workers. Both
indexes link down into each component. Around them,
infra/ is the bootstrap runbook, tools/
the e2e suites, and src/ the (unrelated) bullmoose.cc marketing site.
Two pictures. The first is what runs; the second is what happens to a message.
graph TB
subgraph clients["Your clients"]
MUA["Apple Mail · Thunderbird<br/>(IMAP-era apps)"]
JMAP["JMAP clients<br/>· the webmail"]
DAV["Apple Contacts<br/>· Calendar"]
CLI["bullmoose CLI<br/>(one Go binary)"]
end
subgraph edge["Your Cloudflare account"]
ING["ingest<br/>inbound mail"]
API["jmap<br/>the API + webmail routes"]
AUTH["oauth<br/>identity · passkeys"]
AG["agent<br/>the runtime + MCP"]
SUB["submit<br/>outbound relay"]
BUR["bureau<br/>the credential vault"]
DAVW["anglebrackets<br/>CardDAV · CalDAV"]
PRV["provision<br/>the admin plane"]
D1[("D1<br/>mail · contacts · calendar")]
R2[("R2<br/>message bytes · files")]
end
POP["popcorn<br/>POP3/SMTP shim<br/>(your box)"]
LOCAL["agent serve<br/>(your box, optional)"]
SES["SES"]
MUA --> POP --> API
JMAP --> API
DAV --> DAVW
CLI --> API
CLI --> PRV
ING --> D1
ING --> AG
API --> D1
API --> R2
DAVW --> D1
AG --> BUR
AG --> SUB --> SES
AUTH -.principal.-> API
LOCAL -.claims the same queue.-> AG
sequenceDiagram
participant W as The world
participant CF as Email Routing
participant I as ingest
participant B as boundary
participant D as D1 + R2
participant A as agent
participant H as You
W->>CF: SMTP
CF->>I: the raw message
I->>B: sender sets · DMARC · sieve · bayes
alt refused at the edge
B-->>W: 5xx (zero storage)
else held
B->>D: quarantine mailbox (rescuable, never deleted)
else delivered
I->>D: bytes to R2, metadata to D1
I->>A: enqueue an invocation per binding
A->>H: a proposal — never an action
H-->>A: approve · edit · decline
end
Two ways in. Both end at the same place: your domain, your Cloudflare account, your data.
No checkout, no Node, no build, no toolchain of any kind — one static binary and a Cloudflare API token. The CLI downloads a published, checksummed stack and applies it to your Cloudflare account over documented APIs, including the webmail itself (uploaded to R2 and served by a worker). Nothing shells out; there is no step at the end that hands you back to a package manager.
# 1. get the binary — detects your platform, verifies the checksum, installs
# nothing if that fails (docs/install-cli.md for by-hand + platform notes)
curl -fsSL https://dl.bullmoose.cc/cli/install.sh | sh
# 2. see EXACTLY what would happen — read-only, nothing is created
export CLOUDFLARE_API_TOKEN=… # scopes: docs/install-cloud.md
bullmoose cloud plan --zone example.com
# 3. the same plan, one honest yes, then apply
bullmoose cloud install --zone example.com
# 4. install offers to connect this device — say yes and there is nothing
# to copy: it derived the admin URL and minted the token itself.
bullmoose admin tenant add home
bullmoose admin domain add example.com --tenant <tenantId> # MX + routing + DKIM
bullmoose cloud doctor --zone example.com # did the mail path land?The admin plane is one bearer token, and the installer is the only thing
that ever sees it: it mints the token, derives the one address the provision
worker can live at, and offers to write both down for you. Decline the offer
and bullmoose admin init --token <the token> still works — the URL is
derived either way.
cloud plan prints every resource by name — create / reuse / refuse — with
refusals first. The installer never overwrites a resource it did not make,
and a half-applied install is resumable: fix the cause, run the same command.
Everything it mints (admin token, vault key, signing keys) is generated on your
machine and lands only in your account. Full walkthrough:
docs/install-cloud.md.
For hacking on the platform itself — this is the only path that needs a toolchain. The workers are TypeScript and wrangler is what bundles and deploys them, so a source build needs Node 22+; that is a maintainer's concern, not a deployer's. The short way above consumes the artifacts this path produces, which is why it needs none of it.
Other prerequisites: a domain on a Cloudflare
account (free plan works) and an AWS account for
SES outbound. Authenticate wrangler once with npx wrangler login.
npm install && npm run typecheck
# resources → wire ids → schemas → secrets → deploy, in one idempotent pass.
# Preview everything first; drop --dry-run to execute. Re-runnable, and you
# can run a single phase, e.g. `node infra/bootstrap.mjs secrets`.
node infra/bootstrap.mjs --dry-run
# --url is derived from your Cloudflare account when omitted; pass it only
# to point somewhere else.
ADMIN_TOKEN=$(grep -m1 '^ADMIN_TOKEN=' .env | cut -d= -f2)
bullmoose admin init --token "$ADMIN_TOKEN"
bullmoose admin tenant create t_home --name "Home"
bullmoose admin domain add example.com --tenant t_home
bullmoose admin account create you@example.com --tenant t_home
bullmoose login you@example.comThe full checklist — SES identity verification, first-light testing, and what
each phase does under the hood — is docs/DEPLOY.md.
Then: docs/playbooks/ are step-by-step client
setups (Apple Mail + Calendar, a JMAP client like Mailtemi, family sharing),
docs/carddav-setup.md is the Apple Contacts/Calendar
detail, docs/README.md is the use-case cookbook (agents
included), and tools/ holds the e2e suites everything is
verified against.
An agent is an ordinary account plus a binding. Create the mailbox, bind a
runtime to it, and every delivery enqueues an AgentInvocation; --sla arms a
watchdog that answers if the agent goes quiet. The cloud worker and any homelab
runner claim from that same queue — whoever claims first wins, the watchdog
backstops both. So the account and binding are made identically; what differs is
which runtime claims the work.
Cloud-backed — the deployed agent worker runs the binding (ingest poke +
5-minute sweep). Persona, defaultModel, and modelAliases (Workers AI free tier,
openrouter — live since 2026-08-18, one key reaching any hosted model — or
gateway once AI Gateway is wired) live in
the binding config, beside the budget cap (budgets.spendPerMonth, µUSD) the
paid drain refuses to spend past. Nothing to run:
bullmoose admin account create emily@example.com --tenant t_home --name "Editor Emily"
bullmoose admin agent bind emily@example.com --name editor --reply-mode draft \
--config docs/examples/editor-emily.config.jsonHomelab-backed — you run the runtime on your own box; it logs in as the account, watches the queue over JMAP push, and calls a local or self-hosted model (API keys by env reference, never in the file). Same account + binding, plus a long-running serve:
bullmoose admin account create hermes@example.com --tenant t_home --name "Hermes"
bullmoose admin agent bind hermes@example.com --name hermes-responder --sla 45
# on the homelab (one static Go binary — no runtime to install):
cat > hermes.json <<'JSON'
{ "binding": "hermes-responder",
"persona": "You are Hermes, a terse, helpful assistant.",
"model": { "provider": "openai-compatible", "baseURL": "http://localhost:11434/v1",
"model": "llama3.3", "apiKeyEnv": "OLLAMA_KEY" },
"reply": { "send": false } }
JSON
bullmoose login hermes@example.com
bullmoose agent serve --fleet hermes.json # --once drains and exits (cron-friendly)
bullmoose agent install --fleet hermes.json # optional: survive a reboot (opt-in, prints the unit first)binding must match the bind --name; model.provider is mock | anthropic | openai-compatible (point baseURL at Ollama, a local gateway, anything). For a
fully custom loop that skips bindings entirely, the watch --json bridge in
docs/examples/hermes-bridge.sh is the
pattern. Deeper:
docs/architecture/agent-integration.md.
| you want to… | read |
|---|---|
| stand it up on your own domain | docs/install-cloud.md — one binary, one token |
| get the CLI | docs/install-cli.md · CLI reference |
| deploy from source | docs/DEPLOY.md · infra/ |
| set up Apple Mail / Contacts / Calendar | docs/playbooks/ · docs/carddav-setup.md |
| understand the design | docs/architecture/ |
| work with agents | docs/agents/ · docs/examples/ |
| see it used for something real | docs/README.md — the cookbook |
| follow a worked example on YOUR domain | docs/examples/on-your-own-domain.md |
| check the standards claims | conformance/ |
Live and e2e-tested: the full mail surface, contacts + CardDAV, calendar + CalDAV, grants/sharing, the credential vault, agent pipelines, and the CLI.
Deliberately out of scope for now: calendar scheduling (iTIP/iMIP), WebDAV LOCK/COPY/MOVE, and CRDT merge for shared collections (ETags carry v1).
Capacity headroom and the shelved scaling valves are documented in
docs/architecture/capacity-and-scaling.md.