Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

AIMX user guide

AIMX (AI Mail Exchange) is a self-hosted SMTP server that gives AI agents their own addresses on a domain you control. Mail is parsed into Markdown with TOML frontmatter and written to disk. Agents read and send through the built-in MCP server, the aimx CLI, or the filesystem directly. aimx serve is the daemon; every other subcommand is short-lived.

Join the AIMX Discord →   Questions, hook recipes, and agent integrations.

How it works

AIMX architecture: inbound (sender → port 25 → aimx serve → ingest → .md file + hooks) and outbound (MCP tool call → aimx send → UDS → aimx serve → DKIM sign → recipient MX)
  • Single binary. Written in Rust. No runtime dependencies.
  • aimx serve is the daemon. Embedded SMTP listener for inbound mail. Every other command is short-lived.
  • No IMAP / POP3. Agents read .md files via MCP or the filesystem.
  • Markdown-first. Mail is stored as Markdown with TOML frontmatter, LLM- and RAG-friendly without a parser.

Quick start

curl -fsSL https://aimx.email/install.sh | sh

This launches a guided setup with the following steps:

  • Preflight checks on port 25
  • Set up domain and DNS
  • Set up STARTTLS certificate
  • Set up trust policy
  • Install AIMX service
  • Optionally, wire up MCP for agent(s)

See Installation for install flags, verification, and upgrades, and Getting Started for the full walkthrough.

Guide contents

PageWhat it covers
Getting StartedRequirements, installation, first setup
InstallationOne-line installer, flags, verification, aimx upgrade, rollback
SetupDNS, verification, DKIM key management, production hardening
Configurationconfig.toml field reference, data / config directories, environment variables
SecurityThreat model, trust boundaries, what AIMX defends and what it does not
Mailboxes & EmailMailbox CRUD, email frontmatter, attachments, sending, threading
Markdown EmailHow outbound --body is rendered to HTML, the inlined stylesheet, escape hatches
Hooks & Truston_receive / after_send events, ownership-as-authorization, trust gate
Hook RecipesCopy-paste hook snippets per agent (Claude Code, Codex, OpenCode, Gemini, Goose, OpenClaw, Hermes, NanoClaw)
MCP ServerThe 12 MCP tools: parameters, frontmatter contract, workflow examples
Agent Integrationaimx agents setup installer, per-agent configuration, manual MCP wiring
CLI ReferenceEvery aimx subcommand and flag
TroubleshootingDiagnostics, common issues, useful commands
FAQDeployment, DNS, storage, MCP, and operations questions

Getting Started

Install AIMX and run setup.

Requirements

  • OS: Linux (x86_64 or aarch64, glibc or musl).
  • Server: A server (usually a VPS) with port 25 open Some providers block outbound 25 by default — check with yours before signing up, and run the connectivity check below before installing.
  • Domain: One you control with DNS access. Subdomain works too.

Optional: Pre-install: check port 25

Install step below starts with the port 25 check, but if you would rather run a standalone port 25 check. You can run the following without installing AIMX:

curl -fsSL https://aimx.email/portcheck.sh | sh

Install

curl -fsSL https://aimx.email/install.sh | sh

This launches a guided setup with the following steps:

  • Preflight checks on port 25
  • Set up domain and DNS
  • Set up STARTTLS certificate
  • Set up trust policy
  • Install AIMX service
  • Optionally, wire up MCP for agent(s)

The installer auto-detects your platform and installs aimx into /usr/local/bin/. Verify the binary is installed:

aimx --version

See Installation for install flags (--tag, --target, --to, --force), a skeptical-operator manual verify path (sha256sum -c against the published SHA256SUMS), aimx upgrade, and a source-build recipe for contributors.

Security model

AIMX is a single-operator server: it assumes one administrator and treats every local user on the host as inside the trust boundary. Mailbox storage is per-owner (<owner>:<owner> 0700), config and DKIM secrets stay root-only under /etc/aimx/, and every Unix domain socket (UDS) verb is authorized server-side via SO_PEERCRED. If multiple humans on the box cannot trust each other to operate the daemon, AIMX is the wrong tool — use Postfix or Stalwart.

See Security for the full threat model, trust boundaries, and non-goals.

Verify

After DNS records propagate, verify the setup:

# Check port 25 connectivity (requires root)
sudo aimx portcheck

# Check server health, mailbox counts, and DNS verification
aimx doctor

Send a test email

aimx send --from catchall@agent.yourdomain.com \
          --to your-personal@gmail.com \
          --subject "Hello from aimx" \
          --body "My agent can send email now."

Connect your AI agent or harness

Install AIMX into your agent with one command:

aimx agents setup

Installation

AIMX ships as a single statically-compiled binary. Install in one line:

curl -fsSL https://aimx.email/install.sh | sh

This downloads the latest release for your platform, installs aimx into /usr/local/bin/, and runs sudo aimx setup. When setup exits, run aimx agents setup yourself as your regular user to wire the MCP server into your agent. No Rust toolchain, no cargo build, no source checkout.

Supported platforms

AIMX is Linux-only. Every release ships four prebuilt targets:

Canonical target tripleTarball filename targetTypical distros
x86_64-unknown-linux-gnux86_64-linux-gnuDebian, Ubuntu, Fedora, RHEL, Rocky, Arch
aarch64-unknown-linux-gnuaarch64-linux-gnu64-bit ARM on any glibc distro
x86_64-unknown-linux-muslx86_64-linux-muslAlpine, statically-linked containers
aarch64-unknown-linux-muslaarch64-linux-muslAlpine ARM, statically-linked ARM containers

The install script auto-detects your OS, CPU arch (uname -m), and libc flavor (glibc vs. musl) and picks the matching tarball. Non-Linux platforms are refused with a single-line error — AIMX is Linux-only by policy.

What the installer does

  1. Detects platform and libc; picks the matching release asset.
  2. Acquires sudo (sudo -v </dev/tty so curl | sh still gets the password prompt). Fails fast if sudo is missing on a non-root box, before any network call.
  3. Resolves the target version (latest by default; override with --tag or AIMX_VERSION).
  4. Downloads the tarball over HTTPS from GitHub Releases.
  5. Extracts into a temp directory cleaned up on every exit path.
  6. Installs the binary as install -m 0755 /usr/local/bin/aimx (override with --to / AIMX_PREFIX).
  7. On a fresh box, backs up any pre-existing /etc/aimx/config.toml to config.toml.bak-YYYYMMDD-HHMMSS, then execs sudo aimx setup </dev/tty. DKIM keys and STARTTLS certs are preserved across re-runs.
  8. If an older aimx is already installed, the upgrade path runs instead: stop the service, swap the binary atomically, restart. No wizard re-run. If the running version matches the target, the script asks AIMX is already installed. Re-run setup to (re)configure it? [y/N] — answer y to skip the download and re-enter aimx setup (handy if you aborted the wizard partway through), or N / Enter / no usable TTY (CI, scripted callers) to exit 0 without touching anything. --force skips the prompt and reinstalls the binary.

In CI / non-TTY contexts, set AIMX_NONINTERACTIVE=1 and supply defaults — see Setup.

Flags and environment variables

Everything is optional; defaults cover the common case.

FlagEnv varPurpose
--tag <VERSION>AIMX_VERSIONInstall a specific release tag (e.g. 0.1.0). Tags are bare SemVer (no v prefix); a caller-supplied v is stripped leniently. Flag wins if both are set.
--target <TRIPLE>Override platform auto-detection. Useful for installing the musl build on a glibc box.
--to <DIR>AIMX_PREFIXInstall into <DIR>/aimx instead of /usr/local/bin/aimx.
--forceRe-install even if the target version is already present.
--helpPrint usage.
AIMX_DRY_RUN=1Print every step without downloading or installing anything. Useful for auditing the script before running it for real.
AIMX_VERBOSE=1Trace HTTP requests and filesystem actions.
GITHUB_TOKENBearer token for GitHub API rate-limited contexts (CI, shared NAT).

Examples:

# Install a specific version
curl -fsSL https://aimx.email/install.sh | sh -s -- --tag 0.1.0

# Install into /opt/aimx/bin instead of /usr/local/bin
curl -fsSL https://aimx.email/install.sh | AIMX_PREFIX=/opt/aimx/bin sh

# Audit what the script would do without touching anything
curl -fsSL https://aimx.email/install.sh | AIMX_DRY_RUN=1 sh

# Install the musl build on a glibc machine
curl -fsSL https://aimx.email/install.sh | sh -s -- --target x86_64-unknown-linux-musl

Custom install prefix

AIMX_PREFIX and --to pick the directory that receives the aimx binary. aimx setup picks up the actual binary location from /proc/self/exe, so the generated systemd / OpenRC service file resolves ExecStart to whatever prefix you installed into. A common non-default choice is AIMX_PREFIX=/opt/aimx/bin:

curl -fsSL https://aimx.email/install.sh | AIMX_PREFIX=/opt/aimx/bin sh
sudo /opt/aimx/bin/aimx setup

When you follow up with aimx agents setup as your regular user, it resolves itself from $PATH. Make sure the install prefix you chose is on your shell’s PATH, or invoke the binary by its full path (e.g. /opt/aimx/bin/aimx agents setup).

Manual verification

Every tarball is published with an accompanying .sha256 file and a release-wide SHA256SUMS aggregate. To skip curl | sh and verify by hand:

# Tags are bare SemVer (no `v` prefix). Tarball filenames drop the
# `-unknown-` vendor field from the canonical target triple.
TAG=0.1.0
TARBALL_TARGET=x86_64-linux-gnu
TARBALL=aimx-${TAG}-${TARBALL_TARGET}.tar.gz

curl -fL -O "https://github.com/uzyn/aimx/releases/download/${TAG}/${TARBALL}"
curl -fL -O "https://github.com/uzyn/aimx/releases/download/${TAG}/${TARBALL}.sha256"
sha256sum -c "${TARBALL}.sha256"

tar -xzf "${TARBALL}"
sudo install -m 0755 "aimx-${TAG}-${TARBALL_TARGET}/aimx" /usr/local/bin/aimx
aimx --version

Every GitHub Release also carries a verbatim curl + sha256sum -c block in its release notes so you can copy-paste it without reading the docs.

You can also inspect the install script itself before running it:

curl -fsSL https://aimx.email/install.sh | less

Upgrading

Two equivalent paths: use the installer again, or use aimx upgrade (recommended).

# Option 1: use the upgrade subcommand (preferred on an existing box).
sudo aimx upgrade

# Option 2: re-run the installer. Detects an older binary, stops aimx,
# swaps atomically, restarts. No wizard re-run.
curl -fsSL https://aimx.email/install.sh | sh

aimx upgrade checks https://api.github.com/repos/uzyn/aimx/releases/latest, compares the tag against the running binary’s version, and if newer:

If any step after the stop fails, the rollback path restores aimx.prev and restarts the service. A line names the failed step. The restart-confirmation line is suppressed on the rollback path.

Flags:

FlagPurpose
--dry-runResolve the target version and print the action sequence without touching the running service.
--version <tag>Target a specific release (also used for downgrades).
--forceRe-install the current tag. Useful for repair after a partial swap.

aimx upgrade is non-interactive by design: it never prompts, never runs the setup wizard, never touches DNS, never edits config.toml. It is strictly a binary swap plus service restart.

Config-schema backward compatibility is handled inline by serde (#[serde(alias = ...)] / #[serde(default)]) — there is no separate migration pass. If a future release ever does break config shape, the release notes will call it out and the new binary will refuse to start with a pointer back to them.

Manual rollback

Every successful upgrade preserves the previous binary at /usr/local/bin/aimx.prev (overwritten on the next upgrade). If a new release misbehaves and you want to roll back without waiting for a patch:

sudo systemctl stop aimx
sudo mv /usr/local/bin/aimx.prev /usr/local/bin/aimx
sudo systemctl start aimx
aimx --version

This only covers one generation back. Past that, install a specific older tag with sudo aimx upgrade --version <tag>.

Building from source

Source builds are for contributors and air-gapped environments. Everyone else should use the one-line installer.

# Prereqs: rustup, a recent stable toolchain, and git.
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env

git clone https://github.com/uzyn/aimx.git
cd aimx
cargo build --release
sudo install -m 0755 target/release/aimx /usr/local/bin/aimx
aimx --version

See the top-level CLAUDE.md for the full developer workflow (lint, format, tests, verifier service).

Troubleshooting

“AIMX is Linux-only” error. The install script runs uname and refuses anything other than Linux. Run it on a Linux box.

GitHub API rate limits. The installer calls https://api.github.com/repos/uzyn/aimx/releases/latest for version resolution. Unauthenticated API requests share a per-IP quota. If you hit it, set GITHUB_TOKEN to a personal access token with no scopes selected (public-read is implicit):

curl -fsSL https://aimx.email/install.sh | GITHUB_TOKEN=ghp_... sh

Shared-NAT environments (CI, corporate networks) are the usual culprit.

Unexpected arch. Uncommon CPUs like armv7l are not supported; override the detection with --target aarch64-unknown-linux-gnu if you have a compatible CPU and want to try the 64-bit ARM build.

Service start failed after upgrade. The upgrade path attempts rollback automatically. If aimx.service is still down, manually restore aimx.prev:

sudo mv /usr/local/bin/aimx.prev /usr/local/bin/aimx
sudo systemctl start aimx
journalctl -u aimx -n 50

Then file an issue with the service log.

Binary installs but aimx --version prints the wrong tag. --version is baked at build time from git describe --tags. If you built from source and the working tree is dirty or ahead of the last tag, the output will reflect that (e.g. 0.1.0-12-gabcdef1-dirty). Released tarballs always print the exact tag.

Setup

Run the setup wizard, add DNS records, and verify. Assumes the binary is installed; see Installation first if not.

For a condensed walkthrough, see Getting Started.

Prerequisites

Server

  • A Linux server with port 25 open inbound and outbound
  • A domain (or subdomain) you control with access to DNS management
  • Root access on the server (required for service installation and binding port 25)

Firewall

Ensure port 25 is open:

# If using ufw:
sudo ufw allow 25/tcp

# If using iptables:
sudo iptables -A INPUT -p tcp --dport 25 -j ACCEPT

Setup wizard

Run the setup wizard:

sudo aimx setup

The wizard prompts for the domain interactively and confirms you have DNS access before continuing.

First-time setup flow

The wizard prints a six-line checklist and walks each step, advancing each entry from pending to one of done, skipped, or handoff as it goes. Only the domain and trusted-sender list need operator input; everything else comes from disk and the network.

  1. Port 25 preflight. Verifies outbound and inbound port 25 connectivity, refusing to continue when SMTP is blocked. Runs before any file is written.
  2. Domain and DNS. Prompts for the domain (skipped when supplied as an argument), prints the DNS records, and enters the verify loop. Press Enter to re-run the checks, q to skip and defer to aimx doctor.
  3. STARTTLS certificate. Generates a self-signed cert at /etc/ssl/aimx/. Skipped on re-entry.
  4. Trust policy. Asks for a comma-separated list of addresses or globs (e.g. you@example.com, *@company.com). An empty list sets trust = "none" with a loud warning that hooks will not fire for inbound email until trusted senders are added. Skipped on re-entry. Under AIMX_NONINTERACTIVE=1 the prompt is skipped and the warning is logged.
  5. Install AIMX. Generates a 2048-bit DKIM keypair under /etc/aimx/dkim/, writes /etc/aimx/config.toml with the catchall mailbox, creates the aimx-catchall system user, generates the systemd (or OpenRC) service unit, starts aimx serve, and waits for port 25 to bind. Skipped on re-entry.
  6. Set up MCP for agent(s). Prints a guidance section listing every supported AI agent (driven by agents_setup::registry() so the list extends automatically) and a → aimx agents setup callout pointing the operator at the per-user agent wiring command. The wizard does not spawn a subprocess — agent wiring is operator-initiated as a separate step (the same idiom as apt install / gh auth login). Marked ⎘ Handoff in the closing checklist regardless of $SUDO_USER or AIMX_NONINTERACTIVE.

After step 6 returns, the wizard prints the final closing message:

AIMX has been set up successfully.

Your agents now have access to set up, send and receive emails from @<DOMAIN> emails.

Once you have linked up your MCP to your LLM, try asking it to set up a mailbox for you, e.g.
  claude -p "Set up agent@<DOMAIN> and respond to me via email the moment you receive my instructions via email."

Third-party mail-client workarounds (Gmail spam-filter whitelists and similar) are not part of aimx setup. The canonical deliverability story is the SPF / DKIM / DMARC triple plus a reverse-DNS (PTR) record at your VPS provider.

Catchall user

When the catchall is configured, setup creates the aimx-catchall system user (useradd --system --no-create-home --shell /usr/sbin/nologin, or the BusyBox adduser equivalent on Alpine) and chowns the catchall mailbox to it. Skipping the catchall skips the user.

The catchall is inbound-only and cannot run hooks: aimx-catchall has no shell and no resolvable login uid for setuid to drop into, so Config::load rejects any hook attached to a catchall mailbox. Wire automation on a non-catchall mailbox owned by a regular Linux user.

Provisioning your first mailbox

The wizard does not prompt for a mailbox. Provision one yourself after setup:

# As yourself, no sudo:
aimx mailboxes create hi

This registers hi@agent.yourdomain.com, creates inbox/hi/ and sent/hi/ chowned to your uid, and hot-reloads the daemon. To provision a mailbox owned by a different Linux user (a service account, an agent uid), pass --owner <user> under sudo:

sudo aimx mailboxes create support --owner support-agent

Mailbox CRUD is owner-gated. See Mailboxes § Managing mailboxes for the full rules.

Wiring agents

Step 6 of aimx setup prints the supported-agent list and the → aimx agents setup callout, then exits. It does not spawn the agent picker. To wire AIMX into your agents, run aimx agents setup yourself as your regular user after setup exits:

aimx agents setup

This launches the interactive picker. For each selected agent it writes plugin files under $HOME and (for Claude Code and Codex CLI) auto-registers the MCP server. The plugin teaches the agent how to call AIMX’s MCP tools and includes a “Wiring yourself up as a mailbox hook” recipe. See Agent Integration.

Step 6’s terminal state in the checklist is always ⎘ Handoff — it’s a handoff back to the operator, not a wizard-completed step. On a single-user root-login VPS where there is no separate regular user, pass aimx agents setup --dangerously-allow-root to wire AIMX into root’s home.

Non-interactive setup

Setting AIMX_NONINTERACTIVE=1 skips the trusted-senders prompt (defaults to empty list + logged warning) and the mailbox-owner prompt. Useful for provisioning scripts and CI. The domain still must be supplied as an argument. Step 6 is unaffected — it always prints the same → aimx agents setup guidance regardless of AIMX_NONINTERACTIVE, because there is no subprocess to skip.

sudo AIMX_NONINTERACTIVE=1 aimx setup agent.example.com

Re-running setup

Re-run sudo aimx setup to re-verify DNS. The wizard detects existing config (aimx serve running, STARTTLS cert present, DKIM key present) and marks the STARTTLS / trust / install steps as ☒ skipped — only the preflight and DNS verification steps run, and Step 6 reprints the agent-wiring guidance with the ⎘ Handoff marker. To (re-)wire an agent after re-entry, run aimx agents setup yourself as your regular user; the picker shows (AIMX MCP wired) next to already-wired agents so it won’t double-wire anything.

DNS retry loop

At the DNS verification step:

  • Enter re-checks DNS records (useful after updating DNS in another tab).
  • q defers verification; re-run sudo aimx setup later or use aimx doctor.

Uninstalling

To reverse aimx setup, stop the daemon, remove its init-system service file, and delete the installed aimx binary:

sudo aimx uninstall

Pass --yes to skip the confirmation prompt. Uninstall is intentionally scoped: it removes the service and the binary so a subsequent install.sh run starts fresh, but leaves your config (/etc/aimx/) and mailbox data (/var/lib/aimx/) in place so a subsequent aimx setup reuses them. If you also want to wipe those, remove them manually with rm -rf.

DNS configuration

After the setup wizard displays the required DNS records, add them at your domain registrar:

TypeNameValueWhere to set
Aagent.yourdomain.comYour server IPv4Domain registrar
AAAAagent.yourdomain.comYour server IPv6 (if available)Domain registrar
MXagent.yourdomain.com10 agent.yourdomain.com.Domain registrar
TXTagent.yourdomain.comv=spf1 ip4:YOUR_IP -all (or v=spf1 ip4:YOUR_IP ip6:YOUR_IPV6 -all with IPv6)Domain registrar
TXTaimx._domainkey.agent.yourdomain.comv=DKIM1; k=rsa; p=...Domain registrar
TXT_dmarc.agent.yourdomain.comv=DMARC1; p=rejectDomain registrar

Reverse DNS (PTR) is configured at your VPS provider’s control panel and is not covered by aimx setup. It is out of scope for AIMX. A correct PTR record pointing to your domain does improve deliverability. See the VPS provider’s documentation for how to set it.

The AAAA record and SPF ip6: mechanism are only shown and verified by aimx setup when enable_ipv6 = true is set in config.toml. See IPv6 delivery (advanced). By default, aimx serve delivers over IPv4 only and the single ip4: SPF mechanism is sufficient. Any existing AAAA record in DNS is left alone.

The DKIM public key value (p=...) is displayed by the setup wizard. To retrieve it again:

cat /etc/aimx/dkim/public.key

DNS propagation typically takes minutes but can take up to 48 hours.

Verifying DNS records

After adding DNS records, verify them manually:

# A record
dig +short A agent.yourdomain.com
# Expected: your server IPv4 address

# AAAA record (if your server has IPv6)
dig +short AAAA agent.yourdomain.com
# Expected: your server IPv6 address

# MX record
dig +short MX agent.yourdomain.com
# Expected: 10 agent.yourdomain.com.

# SPF record
dig +short TXT agent.yourdomain.com
# Should include: v=spf1 ip4:YOUR_IP -all (or v=spf1 ip4:YOUR_IP ip6:YOUR_IPV6 -all)

# DKIM record
dig +short TXT aimx._domainkey.agent.yourdomain.com
# Should include: v=DKIM1; k=rsa; p=...

# DMARC record
dig +short TXT _dmarc.agent.yourdomain.com
# Should include: v=DMARC1; p=reject

End-to-end verification

Run the automated verification:

sudo aimx portcheck

This tests outbound port 25 connectivity (via EHLO handshake) and inbound SMTP reachability (via EHLO probe). Requires root.

Check server health:

aimx doctor

Manual testing

Inbound: Send an email from an external account (e.g. Gmail) to catchall@agent.yourdomain.com, then check:

ls /var/lib/aimx/inbox/catchall/
cat /var/lib/aimx/inbox/catchall/*.md

Outbound: Send a test email:

aimx send \
    --from catchall@agent.yourdomain.com \
    --to your-personal@gmail.com \
    --subject "aimx test" \
    --body "Hello from aimx"

DKIM key management

DKIM keys are generated automatically during setup. To manage them independently:

# Generate DKIM keypair (default selector: "aimx")
aimx dkim-keygen

# Force regenerate (overwrites existing keys)
aimx dkim-keygen --force

# Use a custom selector
aimx dkim-keygen --selector mykey

Keys are stored at:

  • Private key: /etc/aimx/dkim/private.key (mode 0600, root-only)
  • Public key: /etc/aimx/dkim/public.key (mode 0644)

After regenerating keys, update the DKIM DNS record with the new public key.

Production hardening

  • Deliverability. Set DKIM, SPF, DMARC (and AAAA if you serve IPv6) correctly. Configure a PTR record at your VPS provider — AIMX does not manage PTR.
  • Firewall. Only port 25 needs to be open. No other ports are required.
  • File permissions. The DKIM private key is 0600 root:root. Verify with ls -la /etc/aimx/dkim/private.key.
  • Backups. Back up /etc/aimx/ (config + DKIM keys) and /var/lib/aimx/ (mailboxes). /run/aimx/ is runtime-only.

Verifier service

The verifier service is used during setup to test port 25 reachability. AIMX uses a public instance at check.aimx.email by default.

Self-hosting the verifier service

If you prefer not to use the public instance:

  1. Build the verifier service:

    cd services/verifier
    cargo build --release
    sudo cp target/release/aimx-verifier /usr/local/bin/
    
  2. Deploy with systemd:

    [Unit]
    Description=aimx verifier service
    After=network.target
    
    [Service]
    ExecStart=/usr/local/bin/aimx-verifier
    Environment=BIND_ADDR=127.0.0.1:3025
    Environment=SMTP_BIND_ADDR=0.0.0.0:25
    Restart=always
    User=aimx-verifier
    AmbientCapabilities=CAP_NET_BIND_SERVICE
    
    [Install]
    WantedBy=multi-user.target
    
  3. Set up a reverse proxy (e.g. Caddy) for HTTPS on the probe endpoint.

  4. Point AIMX to your instance in config.toml:

    verify_host = "https://verify.yourdomain.com"
    

    Or override it per-invocation with --verify-host:

    sudo aimx portcheck --verify-host https://verify.yourdomain.com
    

The verifier service provides:

  • GET /health: health check
  • GET /probe: connects back to caller’s IP on port 25, performs EHLO handshake
  • Port 25 listener: accepts TCP connections for outbound port 25 testing

See the verifier service README for full details.

Configuration

AIMX reads a single TOML file at /etc/aimx/config.toml.

Config file

The config file is at /etc/aimx/config.toml (mode 0640, owner root:root), created automatically by aimx setup. Config and DKIM secrets live under /etc/aimx/; mailbox storage lives under /var/lib/aimx/. The two trees are separate by design — see Security: File and socket layout.

Data directory override

The data directory (/var/lib/aimx/ by default) holds mailboxes only. Config and DKIM keys are under /etc/aimx/. To relocate it:

# CLI flag (works with any command)
aimx --data-dir /custom/path doctor

# Environment variable
export AIMX_DATA_DIR=/custom/path
aimx doctor

The --data-dir flag takes precedence over the environment variable.

Config directory override

For tests or non-standard installs, override the config directory with:

export AIMX_CONFIG_DIR=/custom/etc/path

This changes where config.toml and the DKIM keypair (dkim/private.key, dkim/public.key) are read from. Under normal operation you should not set this. aimx setup writes to /etc/aimx/ and every command picks it up from there.

Environment variables

VariableDefaultPurpose
AIMX_DATA_DIR/var/lib/aimxOverride the mailbox data directory. Equivalent to --data-dir. The flag wins when both are set.
AIMX_CONFIG_DIR/etc/aimxOverride the config + DKIM directory. For tests and non-standard installs only.
AIMX_TEST_MAIL_DROP(unset)When set to a directory path, aimx serve writes every outbound submission to that directory instead of delivering via SMTP. The daemon logs a startup warning so it cannot be left on in production by accident.
NO_COLOR(unset)Standard convention. When set to any value, AIMX CLI output disables ANSI color.

Hook commands receive additional AIMX_* env vars carrying the triggering email’s header fields. See Hooks & Trust: Hook context.

Settings reference

Top-level settings

SettingTypeDefaultDescription
domainstring(required)The email domain (e.g. agent.yourdomain.com)
data_dirstring/var/lib/aimxDirectory for storing mailboxes (config and keys live under /etc/aimx/)
dkim_selectorstringaimxDKIM selector name used in DNS records
truststringnoneDefault trust policy for every mailbox: none or verified. Per-mailbox trust replaces this default.
trusted_sendersarray[]Default allowlist of glob patterns applied to every mailbox. Per-mailbox trusted_senders replaces this list (no merging).
verify_hoststringhttps://check.aimx.emailBase URL of the verifier service used by aimx portcheck and aimx setup. Can be overridden per-invocation with the --verify-host flag.
enable_ipv6boolfalseAdvanced. Opt into IPv6 outbound delivery. See IPv6 delivery.
signaturestring(built-in)Outbound signature appended to every email’s body. Omit to use the built-in default Sent from AIMX. \nhttps://aimx.email. Set to a custom string to override. Set to "" to disable the signature entirely. See Outbound signature.

aimx setup asks for a list of trusted sender addresses interactively on the first run (comma-separated, accepts plain addresses and globs like *@company.com). A non-empty list sets trust = "verified" with that allowlist; leaving the prompt blank sets trust = "none" and the wizard prints a loud warning that hooks will NOT fire for inbound email. On re-entry the existing top-level values on disk are preserved and the prompt is skipped.

Mailbox settings

Mailboxes are defined under [mailboxes.<name>]:

SettingTypeDefaultDescription
addressstring(required)Email address pattern (e.g. support@domain.com or *@domain.com for catchall)
ownerstring(required)Linux username that owns the mailbox storage and runs hooks. Must resolve via getpwnam(3) at config load. The reserved username aimx-catchall is used for catchall mailboxes (created on demand by aimx setup); the reserved value root is allowed but only settable by hand-editing config.toml.
truststring(inherited)Override the global default. Allowed values: none or verified. Omit to inherit.
trusted_sendersarray(inherited)Override the global allowlist. Setting this replaces the global list (no merging). Omit to inherit.
hooksarray[]Hooks fired on on_receive (inbound) and after_send (outbound) events. Forbidden on catchall mailboxes (config-load error).

Reserved mailbox names. catchall and aimx-catchall are reserved literals; aimx mailboxes create rejects them, and MAILBOX-CREATE over the daemon’s UDS rejects them with Validation: reserved. The catchall is provisioned by aimx setup (when configured) under the wildcard address *@<domain> and uses owner aimx-catchall.

See Mailboxes for mailbox management and Hooks & Trust for hook configuration.

Inbound email verification

AIMX records three authentication results on every inbound email:

FieldValuesDescription
dkimpass, fail, noneDKIM signature result. none when no signature is present.
spfpass, fail, noneSPF check against the sending server’s IP. none when no SPF record or no extractable IP.
dmarcpass, fail, noneDMARC alignment of DKIM + SPF against the sender’s policy. none when no DMARC record.

All three are always written, so agents can reliably check authentication status without guessing whether a missing field means “not checked” or “failed.”

The trusted frontmatter field summarizes the effective trust evaluation for the mailbox:

ValueMeaning
"none"Effective trust is none (default). No evaluation performed.
"true"Effective trust is verified, sender matches trusted_senders, and DKIM passed.
"false"Effective trust is verified but the conditions above did not hold.

Trust gates hook execution only — every email is stored regardless. See Hooks: Trust gate for the model.

Hook settings

Hooks are defined as [[mailboxes.<name>.hooks]] arrays. cmd is an argv array exec’d directly as the mailbox’s owner — cmd[0] must be an absolute path. There is no shell wrapping; spell out ["/bin/sh", "-c", "..."] when you need shell expansion. See Hooks & Trust for the full model.

SettingTypeDescription
namestringOptional. Matches ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$. When omitted, AIMX derives a stable 12-char hex name from sha256(event + joined_argv + fire_on_untrusted). Names must be globally unique across mailboxes — including derived ones.
eventstring"on_receive" or "after_send"
typestringHook kind, default "cmd" (only cmd is supported today)
cmdarray of stringsArgv exec’d directly. Required and non-empty; cmd[0] must be an absolute path. There is no shell wrapping — spell out ["/bin/sh", "-c", "..."] explicitly when you need shell expansion.
timeout_secsintHard subprocess timeout in seconds. Default 60, range [1, 600]. SIGTERM at the limit, SIGKILL 5s later.
fire_on_untrustedboolon_receive only: fire even when trusted != "true". Rejected on after_send hooks at config load with ERR fire_on_untrusted is on_receive only.

The raw .md (frontmatter + body) is always piped to the hook’s stdin and the same path is exposed as $AIMX_FILEPATH. Hooks that only need the subject or sender can read $AIMX_SUBJECT / $AIMX_FROM and ignore stdin.

Unknown fields are rejected at config load. The legacy fields stdin, template, params, run_as, origin, and dangerously_support_untrusted are rejected with a pointer to Hooks & Trust.

Storage layout

/etc/aimx/                   # Config + secrets (root-owned, mode 0755)
├── config.toml              # Configuration file (mode 0640, root:root)
└── dkim/
    ├── private.key          # RSA private key (mode 0600, root-only)
    └── public.key           # RSA public key (mode 0644)

/run/aimx/                   # Runtime directory (mode 0755, root:root)
└── aimx.sock                # World-writable UDS for aimx send / hook / mailbox verbs

/var/lib/aimx/               # Mailbox storage
├── inbox/                   # Each mailbox dir is `<owner>:<owner> 0700`
│   ├── catchall/            # Default mailbox (owner: aimx-catchall)
│   │   ├── 2025-04-15-143022-hello.md
│   │   └── 2025-04-15-153300-invoice-march/   # Attachment bundle
│   │       ├── 2025-04-15-153300-invoice-march.md
│   │       ├── invoice.pdf
│   │       └── receipt.png
│   └── support/             # Named mailbox (owner: support-bot)
│       └── ...
└── sent/
    └── support/             # Outbound sent copies
        └── ...

Each mailbox directory is chowned to <owner>:<owner> mode 0700 at create time and stays that way through every subsequent write (ingest, send, mark-read). Files inside are written by the daemon as root:root 0644 — root bypasses dir perms regardless, while the mailbox owner reads via uid match. Other users cannot traverse the directory.

Outbound signature

Every outbound email gets a signature appended to the body before DKIM signing. By default this is:

Sent from AIMX.
https://aimx.email

Override via the top-level signature key in config.toml:

# Use the built-in default (omit the key entirely):
# signature = "Sent from AIMX.  \nhttps://aimx.email"

# Custom signature:
signature = "Best regards,\nThe team"

# Disable the signature entirely:
signature = ""

The signature is injected into the first text/plain body region: for plain messages it is appended after the body; for messages with attachments it lands inside the text part, before the first attachment boundary. Operators can edit signature and the new value applies to the next send (no daemon restart required) — aimx serve reads the live Config snapshot for each request.

IPv6 delivery (advanced)

By default, aimx serve delivers outbound email over IPv4 only (submitted to it by aimx send via /run/aimx/aimx.sock). This matches the SPF record that aimx setup generates (which lists only the server’s IPv4 address) and is the right choice for most users.

If your server has a global IPv6 address and you want outbound mail to use it:

  1. Set the flag in /etc/aimx/config.toml:

    enable_ipv6 = true
    
  2. Re-run aimx setup so the wizard detects the flag, displays the required AAAA + ip6: SPF records, and verifies them for you:

    sudo aimx setup agent.yourdomain.com
    

    aimx setup is re-entrant. It skips install steps on an existing configuration and jumps straight to DNS guidance + verification. When enable_ipv6 = true, it shows the extra AAAA row in the DNS table and includes the ip6: mechanism in the generated SPF record, then verifies both.

  3. Restart the SMTP daemon so the updated config is in effect:

    sudo systemctl restart aimx
    

Required DNS when enabled. Before IPv6 outbound will pass SPF at receivers like Gmail, you need:

TypeNameValue
AAAAagent.yourdomain.comyour server’s IPv6 address
TXT (SPF)agent.yourdomain.comv=spf1 ip4:<your-ipv4> ip6:<your-ipv6> -all

See the full DNS records table in Setup for formats. Without these DNS updates, messages delivered over IPv6 will fail SPF and may be rejected under your DMARC policy.

When enable_ipv6 is unset or false, aimx setup ignores IPv6 entirely: no AAAA advertised, no ip6: SPF generated, and any existing AAAA in DNS is left untouched. aimx portcheck is unaffected by the flag. Leave it off unless you have a global IPv6 address, control the AAAA / SPF records, and need outbound IPv6 delivery.

Full config example

# Domain for this email server (required)
domain = "agent.yourdomain.com"

# Data directory (default: /var/lib/aimx)
data_dir = "/var/lib/aimx"

# DKIM selector name (default: aimx)
dkim_selector = "aimx"

# Custom verifier service host (optional)
# verify_host = "https://verify.yourdomain.com"

# Opt into IPv6 outbound delivery (advanced, default: false)
# enable_ipv6 = true

# Outbound signature (omit for built-in default; "" disables; any other string overrides)
# signature = "Best regards,\nThe team"

# ----------------------------
# Default trust policy (applies to every mailbox unless overridden)
# ----------------------------
# trust = "verified"
# trusted_senders = ["*@yourcompany.com"]

# ----------------------------
# Mailboxes
# ----------------------------

# Catchall mailbox: receives all unmatched addresses.
# Owned by the reserved `aimx-catchall` system user; hooks on the catchall
# are forbidden at config load.
[mailboxes.catchall]
address = "*@agent.yourdomain.com"
owner = "aimx-catchall"

# ----------------------------
# Named mailbox with a per-mailbox trust override
# ----------------------------
[mailboxes.support]
address = "support@agent.yourdomain.com"
owner = "support-bot"  # Linux user 'support-bot' must exist on the host

# Per-mailbox overrides (both optional. Omit to inherit the top-level defaults).
# Setting `trusted_senders` here fully replaces the global list (no merging).
trust = "verified"
trusted_senders = ["*@yourcompany.com", "boss@gmail.com"]

# Log all incoming emails (default gate: only fires when trusted == "true")
[[mailboxes.support.hooks]]
name = "support_log"
event = "on_receive"
cmd = ["/bin/sh", "-c", 'echo "$AIMX_DATE | $AIMX_FROM | $AIMX_SUBJECT" >> /var/log/aimx-support.log']

# Trigger Claude Code on every trusted incoming email; runs as `support-bot`.
[[mailboxes.support.hooks]]
name = "support_agent"
event = "on_receive"
cmd = ["/usr/local/bin/claude", "-p", "Read the piped email and act on it via the aimx MCP server.", "--dangerously-skip-permissions"]

# ----------------------------
# Another mailbox
# ----------------------------
[mailboxes.notifications]
address = "notifications@agent.yourdomain.com"
owner = "ubuntu"

Security model

AIMX is a mail server for a single operator running AI agents on a host they control. This page describes the threat model, the trust boundaries that hold the design together, and what AIMX does not defend against.

AIMX has two boundaries: an external boundary at the root-only DKIM private key, and an internal boundary at server-side per-verb authorization keyed on the caller’s SO_PEERCRED uid. The DKIM key is what lets a sender credibly impersonate your domain on the public internet — local file ACLs and socket modes only decide who on this host gets to ask the daemon to sign. Letting unprivileged subjects reach the signing oracle but never the key itself is what makes the rest of the design ergonomic.

At a glance

  • Single-operator, single-host. Every local user and agent on the box is inside the trust boundary.
  • aimx serve runs as root and owns the DKIM key. Every other process (aimx send, aimx mcp, hook subprocesses) is unprivileged and cannot forge outbound signatures.
  • /run/aimx/aimx.sock is 0666, but every verb runs auth::authorize server-side using the caller’s SO_PEERCRED uid. It is a signing oracle for the configured mailboxes plus an owner-bound CRUD surface; it cannot run arbitrary commands.
  • Hook subprocesses always run as the mailbox owner. The daemon setuids to mailbox.owner_uid before exec and cmd is execvp’d directly — no shell wrapper unless the operator spelled ["/bin/sh", "-c", "..."].
  • DKIM, SPF, and DMARC results are recorded on every inbound email; only DKIM gates hook execution. Mail is always stored regardless of the result.
  • No IMAP, POP3, webmail, SMTP AUTH, retry queue, DSN bounces, spam filtering, or rate limiting. Out of scope by design.

Threat model

Who you are

The operator: a single administrator of a VPS or home server with port 25 open to the internet. You own the host, the domain, and every local user on it. You install AIMX to give AI agents their own addresses on your domain.

Who the adversaries are

AIMX is built to survive:

  • The public internet on port 25. Unauthenticated senders, spammers, phishers, scanners. AIMX records the authentication results and lets the operator (or an agent via a hook) decide what to do with the mail.
  • A confused or compromised local agent. A prompt-injected agent might try to exfiltrate mail, send spam under your domain, or install a shell-running hook. The design prevents the third outright and bounds the first two.
  • A curious local user. Reads every mailbox they own and submits mail through the socket, but cannot sign as a wildcard, forge DKIM, mutate or hook another uid’s mailbox, or run hooks as anyone but the owner.

Who the adversaries are not

AIMX does not attempt to defend against:

  • Hostile code running as root. Root can read the DKIM key, edit config.toml, and replace the daemon binary. Nothing below that privilege level can stop it.
  • Multiple mutually-distrustful humans on one host. Mailbox isolation (0700, per-owner) prevents reads across uids, but any local user can submit to the UDS and act on mailboxes they own. If two users on one box cannot trust each other to operate the daemon, AIMX is the wrong tool — run Postfix or Stalwart.

Trust boundaries

The clearest view of AIMX is a table of who-can-reach-what:

SubjectRuns asHolds the DKIM keyCan submit to UDSCan edit config.toml
aimx serverootyesn/a (handles the socket)yes (via its own UDS)
aimx sendinvoking usernoyesno
aimx mcpthe agent’s usernoyes (subset of verbs)no
Hook subprocessmailbox owner (setuid to mailbox.owner_uid)nono (env-only)no
Any local usertheir login UIDnoyes (socket is 0666)no

The only subject that touches the DKIM key is the daemon. Every other subject that wants to send mail under your domain has to ask the daemon nicely, over the socket — and the daemon, not the caller, decides whether to sign.

Per-action authorization

Every authorization decision in AIMX — CLI, UDS, or MCP — flows through the predicate in src/auth.rs. Root passes unconditionally; non-root callers are bound by the per-action rules below. The same predicate gates the host CLI verb, the UDS verb, and the MCP tool, so the model is symmetric end-to-end.

ActionRootOwner-gated (non-root)Notes
MailboxReadalwayscaller uid must equal mailbox owner_uidaimx mailboxes show, email_* MCP tools, email_list.
MailboxSendAsalwayscaller uid must equal mailbox owner_uidaimx send and email_send / email_reply.
MarkReadWritealwayscaller uid must equal mailbox owner_uidemail_mark_read / email_mark_unread.
MailboxCreatealways (may pass --owner to create cross-uid)caller may only create a mailbox owned by their own uid; the daemon synthesizes the owner from SO_PEERCRED and ignores any client-supplied Owner: header from non-rootaimx mailboxes create, MAILBOX-CREATE UDS, mailbox_create MCP.
MailboxDeletealwayscaller uid must equal mailbox owner_uidaimx mailboxes delete (incl. --force), MAILBOX-DELETE UDS, mailbox_delete MCP.
HookCrudalwayscaller uid must equal mailbox owner_uidaimx hooks create / list / delete, HOOK-* UDS verbs, hook_* MCP tools. Hooks always run as the mailbox owner.
SystemCommandalwaysrejectedsetup, serve, uninstall, dkim-keygen, portcheck.

Mailbox create and delete are owner-gated, not root-gated: every local uid on the box is already inside the trust boundary, so sudo for spinning up a mailbox owned by yourself bought zero security and broke the daily workflow. The privilege-escalation defense is structural — for every non-root UDS request, the daemon resolves the owner identity from the kernel-validated SO_PEERCRED peer uid and ignores any client-supplied Owner: field.

Two cases stay operator-only:

  • Cross-uid creates. Only root may pass --owner <other-user>. mailbox_create has no owner parameter, and the UDS handler discards Owner: headers from non-root callers.
  • The catchall. Owned by the reserved aimx-catchall user (no shell, no resolvable login uid). Provisioned only by aimx setup and not exposed through any agent surface.

The DKIM boundary

The DKIM private key is the one thing on disk that unprivileged subjects cannot forge, bypass, or reproduce.

PathModeOwner
/etc/aimx/config.toml0640root:root
/etc/aimx/dkim/private.key0600root:root
/etc/aimx/dkim/public.key0644root:root

aimx serve loads the private key once at startup into an Arc<DkimKey> and signs every outbound message in-process. The key is never passed to subprocesses and never written to a descriptor other than its original file. When the key is rotated (new selector), you SIGTERM the daemon, update dkim_selector in config.toml, and start the daemon again.

If the DKIM key leaks, anyone can sign mail under your domain until you rotate. Treat it like an SSH host key. See How do I rotate the DKIM key without a delivery gap? for the recipe.

File and socket layout

PathModeOwnerPurpose
/etc/aimx/0755root:rootConfig + DKIM directory
/etc/aimx/config.toml0640root:rootMailboxes, trust policy, hooks
/etc/aimx/dkim/private.key0600root:rootDKIM signing key
/etc/aimx/dkim/public.key0644root:rootPublished in the _domainkey TXT record
/var/lib/aimx/0755root:rootStorage root (traversable; per-mailbox dirs are 0700)
/var/lib/aimx/inbox/<mailbox>/0700<owner>:<owner>Inbound mail (owner = the configured Linux user)
/var/lib/aimx/sent/<mailbox>/0700<owner>:<owner>Outbound copies (same owner)
/run/aimx/0755root:rootRuntime directory (provided by systemd/OpenRC)
/run/aimx/aimx.sock0666root:rootUDS signing oracle + owner-gated CRUD (world-writable, server-side auth::authorize per verb)

Two choices are load-bearing: per-owner mailbox isolation, and the world-writable socket. Both are deliberate.

/etc/aimx/ and /var/lib/aimx/

/etc/aimx/ holds secrets and policy: the DKIM private key, the mailbox list, the hook config. The whole tree is root-owned, the key is 0600, the config is 0640, and the daemon is the only process that reads it.

/var/lib/aimx/ holds the mailboxes: Markdown files with TOML frontmatter, plus attachments as siblings in bundle directories. Each mailbox is <owner>:<owner> 0700. The daemon enforces this on every write. Storage is deliberately flat text so agents can ls, grep, RAG-index, or read from a shell hook without an IMAP layer. Per-mailbox ownership scopes each agent to its own inbox while preserving flat-corpus ergonomics inside.

Secrets never flow outward, mail never flows into the secrets tree.

Inbound SMTP

aimx serve listens on port 25 and accepts plain SMTP. STARTTLS is advertised and supported but not required — remote MTAs that speak plain SMTP are accepted, because that is still the norm for inter-MTA traffic. There is no AUTH extension, no SMTP-AUTH, and no rate limit. Any sender on the public internet can connect, complete an SMTP dialogue, and hand AIMX a message.

Port 25 is open, but AIMX is not an open relay

Every MTA on the public internet listens on port 25 — RFC 5321 says that is where mail arrives. Exposing it is not a security posture, it is a prerequisite for receiving mail. What matters is what the listener does with what it receives.

An open relay accepts mail over SMTP and forwards it back out to a third-party destination, typically for spam. AIMX is not an open relay, by construction:

  • Inbound and outbound paths never cross. Inbound writes the message to inbox/<mailbox>/ and returns. The outbound path is triggered only by a SEND request on the UDS, submitted by a process already on the host with a validated From: that resolves to a configured local mailbox.
  • Inbound recipients must be yours. Every RCPT TO is compared case-insensitively against config.domain. Unrelated domains, subdomains of config.domain, and malformed addresses are refused with 550 5.7.1 relay not permitted before any storage. The catchall only ever covers unknown locals on your domain.
  • Outbound senders must be yours. The daemon parses From: from the submitted body and refuses to sign anything whose domain is not exactly config.domain or whose local part does not resolve to a concrete (non-wildcard) configured mailbox. Even a local user with UDS access cannot cause AIMX to sign mail as someone-else@someone-elses-domain.

What the daemon does with an inbound message

When a message arrives, the daemon:

  1. Parses the raw .eml via mail-parser and extracts headers, body, and attachments.
  2. Runs DKIM / SPF / DMARC checks and records the result strings in the email’s frontmatter (dkim = "pass" | "fail" | "none", etc.). The three fields are always written, never omitted.
  3. Writes the Markdown file to inbox/<mailbox>/ atomically (temp file + rename).
  4. Evaluates the effective trust for the mailbox (see Trust evaluation) and gates on_receive hooks accordingly.

Inbound non-goals

AIMX has no spam filter, rate limiter, greylist, bounce generator, or retry queue:

  • No spam filter. The agent is the spam filter. An LLM reading a mailbox can classify a message far more accurately than a rule-based scorer. Storing the mail and flagging trusted = "false" is the right split.
  • No rate limiter. The design assumes a single-operator host at human-agent volumes, not a multi-tenant relay. Inbound DoS is better handled at the network edge.
  • No greylisting. Agents are supposed to react in real time. Deferring a first-time sender ten minutes defeats the purpose of an agent mailbox.
  • No bounce / DSN generation. Inbound failures (unknown recipient domain, malformed address) are reported synchronously as a 5xx in the SMTP dialogue. Async DSNs are how backscatter happens.
  • No outbound retry queue. Every send is initiated by a live caller, who can retry with better context than a blind queue. 4xx is returned to the caller; 5xx is persisted with the reason.

Bounds that do exist are sized for accidental misuse, not a determined DoS attacker:

  • DEFAULT_MAX_BODY_SIZE = 25 MB.
  • MAX_HEADER_LINE = 8 KiB.
  • UDS_REQUEST_TIMEOUT = 30 s per connection.

If your threat model includes hostile volumes of inbound mail, front AIMX with a firewall or a small greylisting MTA.

Outbound SMTP

Outbound flows through the UDS. Clients submit an unsigned message; the daemon validates From:, signs, and delivers directly to the recipient’s MX.

From: validation is strict:

  • Domain must be exactly config.domain (case-insensitive).
  • Local part must resolve to a concrete, non-wildcard mailbox in config.toml.
  • The catchall is inbound-only and is explicitly refused as a sender.

A local user with socket access can sign as any mailbox they own but cannot invent new senders or hide behind the wildcard. A compromised agent can send under its own mailbox (that is the point) but cannot impersonate another configured mailbox unless they share one.

Delivery is direct: AIMX resolves the recipient’s MX via hickory-resolver (falling back to A per RFC 5321) and connects to port 25. Opportunistic STARTTLS is attempted. There is no relay, no submission server, no queue. 4xx is returned to the caller and not persisted; the caller retries. 5xx is persisted to sent/<mailbox>/ with delivery_status = "failed" and the reason in delivery_details. No DSN is generated. The trade is reliability for visibility — the calling agent always knows the outcome.

aimx send refuses root: it is a thin UDS client that doesn’t need privilege, so it rejects it as belt-and-suspenders against an accidental sudo aimx send.

Unix domain socket

/run/aimx/aimx.sock is bound at mode 0666. Any local user can connect().

This is deliberate. The DKIM key never leaves the daemon, and every per-mailbox action is owner-gated server-side — the daemon resolves the caller’s uid via SO_PEERCRED and runs auth::authorize before any state work. Given those two boundaries, tightening the socket mode buys very little and costs real ergonomics: agents launched by humans run under unprivileged uids, and locking the socket to root would force every aimx send through sudo and every MCP client to spawn a privileged helper.

Alternatives considered and rejected:

  • Socket mode 0660 with a shared group. Fragile across reinstalls and user-management flows. Forgetting to add a new user to the group silently breaks their agent.
  • A userland auth handshake. Adds failure modes; the kernel already supplies peer credentials via SO_PEERCRED. A userland handshake on top would be redundant.
  • sudo for aimx send. Gates the common case (an agent sends mail) on root, defeating the unprivileged agent.

A malicious local user can send mail as a mailbox they own — which they could also do via the agent’s shell session, so gating the socket wouldn’t have stopped them anyway. What they cannot do: forge mail under a domain you don’t own, mutate or hook a mailbox owned by another uid, run hooks as anyone but the owner, run arbitrary commands as root, or read the DKIM key. That is the boundary the design defends.

The accepted verbs:

  • SEND — submit an unsigned RFC 5322 message for DKIM signing and MX delivery.
  • MARK-READ / MARK-UNREAD — rewrite the read field under a per-mailbox lock.
  • MAILBOX-CREATE / MAILBOX-DELETE — add or remove a configured mailbox; hot-swap Arc<Config>.
  • HOOK-CREATE — create a hook with a raw argv on a mailbox the caller owns. Stamped origin = "mcp".
  • HOOK-DELETE — remove an existing hook, subject to origin protection.
  • HOOK-LIST / MAILBOX-LIST — read-only enumeration filtered to owned mailboxes / hooks; root sees all.
  • VERSION — daemon build metadata.

The daemon parses a tagged Request enum with #[serde(deny_unknown_fields)]. There is no verb that writes raw shell strings to config.toml, runs subprocesses under arbitrary UIDs, reads the DKIM key, or reloads config from a caller-chosen path. Combined with the 30 s per-connection timeout and 25 MB body cap, the socket is small and auditable.

Hooks and MCP

Hooks are the one piece of AIMX that runs external commands. Every hook is a raw argv attached to a mailbox; there is no template layer and no per-hook run_as. The trust boundary is the mailbox owner. See Hooks & Trust for the full model.

Hooks always exec as the mailbox owner (the daemon setuids before exec); cmd[0] must be an absolute path; argv is execvp’d directly. The trust gate fires on_receive hooks only when email.trusted == "true" or the hook sets fire_on_untrusted = true. after_send hooks have no gate.

Every hook carries an origin tag: operator (hand-edited or via CLI direct-write fallback) or mcp (via the UDS). MCP-origin hooks can be deleted via MCP or CLI; operator-origin hooks can only be deleted via CLI. An agent cannot dismantle a policy hook the operator installed.

aimx mcp is a stdio MCP server launched by the client, running as the agent’s own user. Every tool routes through the daemon UDS, which authorizes via SO_PEERCRED against the target mailbox’s owner_uid. The MCP process never reads /etc/aimx/, never touches the DKIM key, and cannot mutate a mailbox it does not own. It cannot change which uid a hook runs as (no run_as knob exists) or forge an origin tag (the daemon stamps mcp itself). See MCP Server: Per-user authorization.

Explicitly out of scope

These are not on a roadmap. They are non-goals.

  1. Multi-user mailbox ACLs beyond owner/root. Each mailbox is owned by one Linux user at mode 0700; there is no shared-group readership and no fine-grained per-other-user ACL layer. Use Postfix or Stalwart if you need that.
  2. SMTP AUTH / submission port 587. AIMX is not a submission MTA. Its outbound path is UDS → DKIM-sign → direct MX.
  3. IMAP / POP3 / webmail. Agents read .md files via MCP or the filesystem. There is no mailbox server protocol.
  4. Reverse DNS (PTR). Configured at your VPS provider, not by aimx setup. Optional but improves deliverability.
  5. Socket-mode-based UDS gating. The socket is 0666 on purpose; per-verb authorization runs server-side on every request via SO_PEERCRED + auth::authorize. We don’t tighten the socket mode itself.
  6. Spam filtering, greylisting, inbound rate limits. Front AIMX with a firewall or small MTA if you need these.
  7. Retry queues, DSN generation. Failures are agent-visible in real time, not queued behind the scenes.
  8. Detailed audit logging. Every hook fire emits one structured line via tracing. That is the log. There is no separate audit file.

Hardening

Knobs you can tighten beyond the defaults:

  • Firewall port 25 inbound from known-bad netblocks. AIMX does not do this itself.
  • Run on a dedicated host if local users cannot be trusted to sign mail as any configured mailbox.
  • Rotate the DKIM selector periodically. See How do I rotate the DKIM key without a delivery gap?.
  • Keep the per-mailbox hook list minimal. Review with aimx hooks list --mailbox <name> before adding more.
  • Review hook-fire logs after a new hook lands: journalctl -u aimx | grep hook_name=<name>.
  • Switch trust = "verified" and populate trusted_senders once you know which senders should trigger agents. Default "none" is safe but silent.
  • Pick the mailbox owner deliberately. Hooks always run as mailbox.owner_uid. Pick the owner that matches what the hook needs to touch, not the user that’s most convenient.

Mailboxes & Email

A mailbox maps an email address to a directory on disk. Command starts with aimx mailboxes.

Concepts

  • Mailboxes are directories. Creating a mailbox creates two folders (one under inbox/, one under sent/) and registers an address. No passwords, no database.
  • Per-mailbox owner. Every mailbox has a single Linux owner in config.toml. Storage is chowned <owner>:<owner> 0700 at create and kept consistent through every write. Only the owner and root can read it; the MCP server and UDS both authorize on SO_PEERCRED matching the owner uid. See Security: Per-action authorization.
  • Catchall. The catchall mailbox catches mail for unrecognized addresses at your domain. It is inbound-only (no sent/catchall/), owned by the reserved aimx-catchall system user.
  • No sudo for the mailboxes you own. aimx mailboxes create / delete route through the daemon’s UDS, so the daemon synthesizes the owner from SO_PEERCRED and atomically rewrites config.toml. Root may still pass --owner <user> to provision a mailbox for another uid.
  • Hot-reload. When aimx serve is running, create and delete take effect on the next SMTP session — no restart needed.
  • Delete is file-safe. Non-empty mailboxes are refused with ERR NONEMPTY and a file count. Archive or remove the files first. The directories are left on disk after delete so an operator can rmdir them at leisure.
  • Force-delete is CLI-only. aimx mailboxes delete --force <name> recursively wipes inbox/<name>/ and sent/<name>/ first. It always prompts unless --yes is passed. The MCP mailbox_delete tool deliberately has no force variant — destructive wipes stay where the operator sees prompts. catchall is refused with or without --force.

On-disk layout

/var/lib/aimx/
├── inbox/              # inbound mail lives here
│   ├── catchall/
│   └── support/
└── sent/               # outbound sent copies
    └── support/

Each email is stored as either a flat YYYY-MM-DD-HHMMSS-<slug>.md file when it has zero attachments, or as a bundle directory YYYY-MM-DD-HHMMSS-<slug>/ containing <stem>.md plus every attachment as a sibling file when attachments are present.

Routing logic

When an email arrives, AIMX matches the local part of the recipient address (the part before @) against mailbox names in the config. If a mailbox with that exact name exists, the email is delivered there. Otherwise it falls through to the catchall mailbox.

RCPT TO addresses whose domain is not the configured domain (case-insensitive exact match) are rejected at SMTP time with 550 5.7.1 relay not permitted and never reach storage. AIMX is not an open relay: catchall only covers unrecognized local parts at your configured domain, not unrelated domains or subdomains.

For example, with mailboxes support and catchall configured:

  • support@agent.yourdomain.com -> delivered to the support mailbox
  • billing@agent.yourdomain.com -> delivered to the catchall mailbox (no billing mailbox exists)
  • anything@agent.yourdomain.com -> delivered to the catchall mailbox
  • anything@some-other-domain.com -> rejected at RCPT TO with 550 5.7.1 relay not permitted
  • anything@sub.agent.yourdomain.com -> rejected at RCPT TO with 550 5.7.1 relay not permitted

Managing mailboxes

Create a mailbox

# As yourself: create a mailbox owned by your own uid.
aimx mailboxes create support

This creates support@agent.yourdomain.com and both directories: /var/lib/aimx/inbox/support/ (for incoming mail) and /var/lib/aimx/sent/support/ (for outbound copies). Storage is chowned to your uid at mode 0700. Deletion removes both; catchall cannot be deleted.

Owner-binding rule. Non-root callers create and delete only mailboxes they own — the daemon synthesizes the owner from SO_PEERCRED and ignores any client-supplied owner. Root passes unconditionally and may use --owner <user> to provision a mailbox owned by another Linux user. Passing --owner <other> from a non-root shell prints a soft warning to stderr and submits the request with the synthesized owner anyway.

Cross-uid create (root only). Provision a shared mailbox owned by a service account:

# create the Linux user first
sudo useradd --system --shell /usr/sbin/nologin support-agent

# operator creates the mailbox owned by that user (cross-uid → sudo)
sudo aimx mailboxes create support --owner support-agent

# verify ownership landed where expected
ls -la /var/lib/aimx/inbox/support/    # drwx------  support-agent support-agent
ls -la /var/lib/aimx/sent/support/     # drwx------  support-agent support-agent

Any agent running under uid support-agent can now read /var/lib/aimx/inbox/support/ and use the MCP tools against the support mailbox. Other users cannot read it — isolation is filesystem-enforced.

For the day-to-day case, drop the --owner flag and skip sudo:

# As yourself, no sudo:
aimx mailboxes create agent-1

The daemon must be running for non-root mailbox CRUD; if it is stopped, the CLI exits with a precise error naming both remediations. See Troubleshooting.

Agents can self-serve via MCP

Agents call mailbox_create and mailbox_delete over MCP. Neither accepts an owner parameter — the daemon synthesizes the owner from the MCP process’s uid via SO_PEERCRED. An agent provisions an inbox for a transient task, sends and receives on it, then calls mailbox_delete("task-42", force: true) when done. No operator intervention required.

List mailboxes

aimx mailboxes list

Shows all mailboxes with their addresses and message counts (total and unread).

Inspect a single mailbox

aimx mailboxes show support

Prints the mailbox’s address, effective trust policy, full trusted_senders list, configured hooks grouped by event (on_receive / after_send. Each entry shows the hook name, cmd truncated to 60 chars with a suffix when longer, and the [fire_on_untrusted=true] marker where set), and inbox + sent + unread message counts. Example output:

Mailbox: support
  Address: support@agent.yourdomain.com
  Trust:   verified
  Trusted senders:
    - *@company.com
    - boss@example.com

Hooks
  on_receive
    - support_notify  cmd: ["/bin/sh","-c","curl -fsS https://hooks.example.com/no…   [fire_on_untrusted=true]
  after_send
    - aaaabbbbcccc    cmd: ["/usr/local/bin/notify","$AIMX_TO"]

Messages
  inbox: 12 (3 unread)
  sent:  5

Delete a mailbox

aimx mailboxes delete support

Prompts for confirmation. Use --yes to skip the prompt. When the daemon is running, the request routes through its UDS socket and the daemon refuses to delete a mailbox that still contains files (error NONEMPTY). Archive or remove them first, then retry. When the daemon is stopped, delete goes through the direct-edit fallback which removes the directory tree and prints a restart-hint banner.

Force-delete a non-empty mailbox

--force permanently removes every email under inbox/<name>/ and sent/<name>/ before unregistering the mailbox. There is no undo.

# Interactive: shows file counts and prompts before wiping
aimx mailboxes delete --force support

# Scripted: skip the confirmation prompt
aimx mailboxes delete --force --yes support

Without --force, a non-empty mailbox is refused with ERR NONEMPTY. catchall is refused even with --force. Force is CLI-only — the MCP mailbox_delete tool returns a hint pointing here on NONEMPTY rather than gaining its own force variant.

Mailboxes can also be managed via MCP tools (mailbox_list, mailbox_create, mailbox_delete).

Email format

Incoming emails are parsed from raw MIME (.eml) and stored as Markdown with TOML frontmatter:

+++
id = "2025-04-15-143022-hello"
message_id = "abc123@example.com"
thread_id = "a1b2c3d4e5f6a7b8"
from = "Alice <alice@example.com>"
to = "support@agent.yourdomain.com"
delivered_to = "support@agent.yourdomain.com"
subject = "Hello"
date = "2025-04-15T14:30:22Z"
received_at = "2025-04-15T14:30:23Z"
size_bytes = 1024
dkim = "pass"
spf = "pass"
dmarc = "pass"
trusted = "true"
mailbox = "support"
read = false
+++

Hello, this is the email body in plain text.

The format is agent-readable without a MIME parser: an agent can cat the file and act on it directly.

Frontmatter fields

FieldTypeDescription
idstringFilename stem (e.g. 2025-04-15-143022-hello)
message_idstringRFC 5322 Message-ID
thread_idstring16-hex-char SHA-256 of the thread root Message-ID
fromstringSender address with optional display name
tostringRecipient address
delivered_tostringActual RCPT TO address
subjectstringEmail subject line
datestringSender-claimed date in RFC 3339 format
received_atstringServer-side receipt datetime (RFC 3339 UTC)
size_bytesintegerRaw message size in bytes
attachmentsarrayAttachment metadata (see below)
in_reply_tostringMessage-ID of the email being replied to (optional, omitted when empty)
referencesstringFull threading chain of Message-IDs (optional, omitted when empty)
dkimstringDKIM verification result: pass, fail, or none
spfstringSPF verification result: pass, fail, softfail, neutral, or none
dmarcstringDMARC alignment result: pass, fail, or none
trustedstringEffective trust evaluation for the email’s mailbox (per-mailbox override if set, otherwise the top-level default): none, true, or false
mailboxstringMailbox name this email was routed to
readboolRead status (false on ingest)
read_atdatetimeRFC 3339 UTC timestamp set when the email is marked read. Removed on mark-unread. Reflects the most recent read, not the first. Optional, omitted when absent

Outbound frontmatter

Emails under sent/<mailbox>/ carry every inbound field plus an outbound block appended at the end:

FieldTypeAlways writtenDescription
outboundboolyesAlways true on sent copies. Distinguishes outbound files from inbound.
delivery_statusstringyesOne of "delivered", "failed", "deferred", "pending".
bccarray of stringsnoBCC recipients. Optional, omitted when empty.
delivered_atstringnoRFC 3339 UTC timestamp of the successful MX handoff. Optional, present only when delivery_status = "delivered".
delivery_detailsstringnoSMTP reason string on permanent failure (e.g. "550 no such user"). Optional.

Deferred (4xx) sends are not persisted. The submitting client is expected to retry. Permanent (5xx) failures are persisted with delivery_status = "failed" and the SMTP reason in delivery_details. On outbound files the inbound received_at and received_from_ip fields are omitted when empty.

Body extraction

  • Text/plain is preferred when available
  • Falls back to text/html converted to plaintext (via html2text)
  • Stored as Markdown content after the frontmatter

Attachments

When an email carries one or more attachments, AIMX writes a bundle directory whose name matches the .md file’s stem:

/var/lib/aimx/inbox/support/
├── 2025-01-15-103000-status-update.md         # flat: no attachments
└── 2025-01-15-104500-quarterly-report/        # bundle: one or more attachments
    ├── 2025-01-15-104500-quarterly-report.md
    ├── report.pdf
    └── image.png

Attachment metadata is stored in the email frontmatter:

[[attachments]]
filename = "report.pdf"
content_type = "application/pdf"
size = 45230
path = "report.pdf"
FieldDescription
filenameOriginal filename (path components stripped, duplicates suffixed -1, -2, …)
content_typeMIME type
sizeFile size in bytes
pathPath relative to the bundle directory

Read/unread tracking

Emails are marked read = false on ingest. Use MCP tools or update the frontmatter directly:

  • MCP: email_mark_read and email_mark_unread (see MCP Server)
  • CLI/filesystem: Edit the read field in the .md file’s frontmatter

The email_list MCP tool returns the read flag on every inbox row. Agents page through the listing and filter client-side to read == false; AIMX itself does not scan on the agent’s behalf.

Sending email

--body is interpreted as Markdown by default — recipients on rich-capable clients see styled HTML, recipients on text-only clients see the Markdown source. Two escape hatches cover the edge cases: --text-only for plain-text-only sends, --html-body for custom HTML templates. See Markdown Email for a full tour of the rendering pipeline and the inlined stylesheet.

Via CLI

# Basic send: --body is Markdown by default.
aimx send --from support@agent.yourdomain.com \
          --to recipient@gmail.com \
          --subject "Hello" \
          --body "# Hello\n\nThanks for reaching out — happy to help!"

# Plain text only (e.g. OTPs, transactional one-liners).
aimx send --from support@agent.yourdomain.com \
          --to recipient@gmail.com \
          --subject "Verification code" \
          --body "Your code: 184293" \
          --text-only

# Custom HTML layout (operator-supplied template).
aimx send --from support@agent.yourdomain.com \
          --to recipient@gmail.com \
          --subject "Newsletter" \
          --body "Plain-text fallback for text-only clients." \
          --html-body "$(cat newsletter.html)"

# With attachments (Markdown body + PDF).
aimx send --from support@agent.yourdomain.com \
          --to recipient@gmail.com \
          --subject "Report" \
          --body "See the attached PDF." \
          --attachment /path/to/report.pdf

# Reply to a message (preserves threading).
aimx send --from support@agent.yourdomain.com \
          --to recipient@gmail.com \
          --subject "Re: Hello" \
          --body "Reply body" \
          --reply-to "<original-message-id@example.com>"

# Advanced: supply the full References header for deep threading.
aimx send --from support@agent.yourdomain.com \
          --to recipient@gmail.com \
          --subject "Re: Hello" \
          --body "Reply body" \
          --reply-to "<parent@example.com>" \
          --references "<root@example.com> <parent@example.com>"

--reply-to sets the In-Reply-To header (single Message-ID). --references sets the References chain and is only needed for multi-step threads where In-Reply-To alone is not enough. Most users can omit it. For interactive agent use, prefer the email_reply MCP tool. It reads the original message and fills both headers automatically.

Via MCP

Agents send email using the email_send and email_reply MCP tools. See MCP Server for details.

Send pipeline

  1. aimx send composes an RFC 5322 message and submits it over /run/aimx/aimx.sock. The client does not read config.toml.
  2. aimx serve parses From: from the body, verifies the domain matches config.domain and the local part resolves to a configured non-wildcard mailbox, DKIM-signs the message with RSA-SHA256, and delivers it directly to the recipient’s MX over SMTP. The catchall (*@domain) is never accepted as an outbound sender.
  3. aimx send exits as soon as the daemon returns a status. Signing, mailbox resolution, and delivery happen entirely in the daemon — the client does not need root, does not read the DKIM key, and does not read config.toml.

Reply threading

Replies set In-Reply-To and References so the thread lands correctly in the recipient’s mail client. Pass --reply-to with the original message’s Message-ID value.

The email_reply MCP tool handles threading automatically by reading the original email and setting the headers.

Email ID format

Each email’s id field is the filename stem YYYY-MM-DD-HHMMSS-<slug> in UTC. The slug is derived from the subject: lowercase, non-alphanumeric runs collapsed to -, trimmed, capped at 20 characters, falling back to no-subject when empty. Two emails with the same subject in the same UTC second get -2, -3, … appended.

Markdown Email

AIMX is Markdown-native end-to-end. Inbound mail is stored as Markdown for direct LLM consumption; outbound --body is interpreted as Markdown by default and rendered to HTML on the wire. Agents read Markdown, write Markdown, and the binary handles the MIME plumbing.

How a default send is delivered

  1. Caller submits Markdown. aimx send --body "..." (or the MCP email_send tool with body: "...") ships the Markdown source to the daemon over the local UDS.
  2. Daemon appends the per-mailbox signature to the body so the recipient always sees it in the text/plain region. On the default Markdown path the signature is part of the Markdown source, so it also renders into the HTML alternative (and a [link](url) in the signature becomes a clickable <a>).
  3. Daemon renders Markdown to HTML. The renderer uses comrak configured for CommonMark + GFM extensions (tables, strikethrough, autolinks, task lists, footnotes, tag filter for unsafe tags). Raw HTML embedded in Markdown is escaped, not rendered — operators wanting raw HTML use --html-body instead.
  4. Inlined stylesheet pass. The renderer walks the rendered HTML tree and adds style="..." attributes per element. Inlining is required because Gmail, Outlook for Web, and Yahoo Mail strip or limit <style> blocks.
  5. Multipart assembly. The daemon builds a multipart/alternative MIME message with the Markdown source as the text/plain part and the rendered HTML as the text/html part. With one or more attachments, the multipart/alternative is wrapped in an outer multipart/mixed so attachments sit as siblings of the alternative block.
  6. DKIM signing. The signed body bytes are the canonical message body — the new MIME shape doesn’t affect the signing logic, and DKIM verification works identically across plain-text, Markdown-rendered, and --html-body paths.
  7. MX delivery. The daemon resolves the recipient domain via MX records and delivers the signed message.

The recipient sees:

  • Rich-text clients (Gmail, Outlook, Apple Mail, Thunderbird): the rendered HTML with headings, links, tables, blockquotes, code blocks — all styled by the inlined stylesheet.
  • Text-only clients (mutt, mailx, screen readers in plain mode): the Markdown source verbatim. Markdown is good plain text by design — # Heading is clear, [link](url) shows the URL inline, bullets with - look like bullets.

Supported Markdown features

The default render path supports CommonMark plus GFM extensions:

FeatureMarkdown inputRenders as
Headings# H1#### H4<h1><h4> with sized typography
Paragraphsblank-line separated<p> with comfortable line-height
Bold / italic**bold**, *italic*<strong>, <em>
Strikethrough (GFM)~~strike~~<del>
Links[text](https://url)<a> with non-default link color, no underline at rest
Autolinks (GFM)bare https://example.com<a>
Inline code`code`<code> with monospace + light gray background
Code blocks```lang … ```<pre><code> with monospace + padded background
Blockquotes> quoted<blockquote> with left border
Horizontal rule---<hr> thin neutral rule
Unordered list- item<ul><li> with sensible spacing
Ordered list1. item<ol><li> with sensible spacing
Tables (GFM)| h1 | h2 | / |---|---| / cells<table> with collapsed borders and subtle row separators
Task lists (GFM)- [x] done / - [ ] todocheckbox-prefixed list items
Footnotes (GFM)text[^1][^1]: notenumbered footnote anchor + reference list
Tag filter (GFM)<script>...</script>stripped (security)

Built-in stylesheet

The inlined stylesheet covers the elements above. It is intentionally minimal — operators wanting custom CSS use --html-body for now.

ElementStyle intent
bodysans-serif font stack, line-height: 1.55, max-width: 720px, comfortable padding
h1, h2, h3, h4size scale, modest top margin
p, ul, ol, lisensible spacing
anon-default link color (avoids the browser-default purple-after-visit), no underline at rest
code, premonospace, light gray background, padding
blockquoteleft border, dim foreground
table, th, tdcollapsed borders, subtle row separators
hrthin neutral rule

The stylesheet is self-contained: no <link> to Google Fonts, no remote CSS, no remote images injected by the renderer. Privacy-conscious clients and corporate firewalls do not strip the rendering. The total rendered HTML for a typical 5KB Markdown briefing fits comfortably under 25KB.

If your content uses an HTML element the inlined stylesheet does not cover (e.g. <details>, <sub>), it renders unstyled (browser defaults). The element list above is the v1 scope; expand on real demand.

Body size limit

The renderer enforces a 5 MiB cap on the Markdown source byte length (the constant is MAX_MARKDOWN_BODY_BYTES = 5 * 1024 * 1024). Above the cap, the daemon refuses with the canonical error:

markdown body exceeds 5 MiB; use --html-body for pre-rendered large documents or --attachment for sending the document as a file

Rationale: a 5 MiB Markdown body renders to roughly 15–25 MiB on the wire (Markdown source + HTML + inlined styles, with ~37% base64 overhead). That sits at the edge of mainstream receiver caps — Gmail 25 MB, Outlook.com / iCloud 20 MB, Microsoft 365 up to 150 MB. Send anything larger as an attachment, not as a body.

The cap is enforced at the renderer entry point so all callers (aimx send, MCP email_send, future cron jobs) share one limit. Operators on the wire surface that scripts can branch on the failure see the dedicated ERR BODY_TOO_LARGE ack code; the canonical reason string survives in the wire response for human readers.

Escape hatches

--text-only

Forces the wire to single-part text/plain with --body shipped verbatim. No Markdown rendering, no HTML part, no multipart/alternative wrapper.

aimx send --from alice@example.com --to bob@example.com \
  --subject "Verification code" \
  --body "Your code: 184293" \
  --text-only

The per-mailbox signature is auto-appended on this path. AIMX appends the configured signature (or the built-in default) to the body before encoding, so even one-liner text-only sends carry the configured footer. Disable globally with signature = "" in config.toml if your one-liner shape really needs to ship verbatim.

Use for:

  • OTPs, transactional one-liners, system-generated alerts.
  • Migrating existing scripts that depended on --body shipping text/plain. Adding --text-only preserves the old shape exactly.

--html-body

Supplies a custom HTML body. AIMX uses the --html-body value verbatim as the text/html part and uses --body as the text/plain fallback so text-only clients still see something readable.

aimx send --from alice@example.com --to bob@example.com \
  --subject "Newsletter" \
  --body "Plain-text fallback for text-only clients." \
  --html-body "$(cat newsletter.html)"

The shell pattern --html-body "$(cat template.html)" reads the template from a file and passes it on the command line. Linux’s typical ARG_MAX is around 1MB, well above any reasonable HTML email size. For templates that exceed ARG_MAX, write the template to a tempfile and use a wrapper script — but at that size you should consider attaching the document instead.

--html-body and --text-only are mutually exclusive. clap rejects the invocation before any UDS round-trip. The same canonical error fires server-side on the MCP email_send / email_reply tools so operators see one consistent message regardless of the surface.

The per-mailbox signature is appended to the text/plain fallback so text-only readers still see it. The supplied HTML stays operator-verbatim — if you want the signature visible in the HTML view, include it inside your template. Disable globally with signature = "" in config.toml if you want the text fallback to ship verbatim too.

--html-body bypasses the renderer’s tag-filter and other safety checks; the operator owns the consequences. Do not pass user-controlled HTML through this flag.

Sent storage

Sent records under sent/<mailbox>/ always store the Markdown source (or the literal text for --text-only, or the --body text part for --html-body). The recipient’s HTML view is recoverable by re-running the same renderer on the stored Markdown — output is deterministic given the pinned comrak version.

The frontmatter declares the wire shape so an operator browsing sent/ can tell at a glance what the recipient saw:

outbound_formatWire shapeSent body content
"markdown"multipart/alternative (text + rendered HTML)Markdown source with signature appended before render
"text"single-part text/plainbody with signature appended
"html"multipart/alternative (text + custom HTML)the --body text part with signature appended (custom HTML is not stored)

The outbound_format field appears immediately after outbound = true in the Outbound block of the frontmatter:

outbound = true
outbound_format = "markdown"
delivery_status = "delivered"

Pre-feature sent records lack the field; on read, those default to "text" (the historic single-part text/plain shape) so legacy records keep parsing cleanly.

No .html sibling file is ever written. Operators who need a record of the exact custom HTML they sent should keep their template under version control — the --html-body payload is not persisted by AIMX.

Determinism

The renderer is deterministic: the same Markdown input produces byte-identical HTML output across two invocations. This is what justifies dropping the rendered HTML from sent storage — the recipient’s view is recoverable. comrak is pinned to an exact patch version in Cargo.toml; bumping it requires re-blessing the renderer fixtures and is called out on the corresponding GitHub Release.

CI defends the determinism guarantee with two checked-in fixtures (tests/fixtures/markdown/briefing-5kb.md and tests/fixtures/markdown/report-50kb.md) and their pinned expected outputs. A comrak bump that changes whitespace or attribute ordering surfaces at CI time, not in production.

Worked example

Input Markdown:

# Daily briefing — 2026-05-07

## Open positions

| Symbol | Shares | Cost basis |
|--------|-------:|-----------:|
| AAPL   | 100    | $150.00    |
| GOOG   | 50     | $2,800.00  |

## Notes

- Earnings season starts next week.
- See the [shareholder letter](https://example.com/letter) for context.

> The market always overreacts in the short term.

```python
def total(positions):
    return sum(s * p for s, p in positions)
```

What the recipient sees:

  • Gmail / Outlook / Apple Mail: rendered <h1>, <h2>, a styled <table>, a clickable <a> link, a <blockquote> with a left border, and a <pre><code> block with monospace background.
  • Text-only client: the Markdown source verbatim — readable, with hierarchy preserved by # and ##, the table cells visible as pipes-and-dashes.

What gets stored in sent/alice/2026-05-07-120000-daily-briefing-2026-05-07.md:

+++
id = "2026-05-07-120000-daily-briefing-2026-05-07"
# ... (other frontmatter fields) ...
outbound = true
outbound_format = "markdown"
delivery_status = "delivered"
+++

# Daily briefing — 2026-05-07
# ... (Markdown source verbatim) ...

Re-running the daemon’s renderer on the stored body reproduces the recipient’s HTML view exactly.

Hooks & Trust

Hooks trigger commands on email events. Two events:

  • on_receive — fires during inbound ingest, after the email is stored.
  • after_send — fires during outbound delivery, after the MX attempt resolves (delivered, failed, or deferred).

Combined with the per-mailbox trust policy, hooks gate shell-side automation on DKIM-verified inbound mail and on outbound delivery outcomes. For copy-paste agent invocations, see Hook Recipes.

Mailbox ownership = hook authorization

Every mailbox declares one Linux owner. Ownership is the authorization predicate for everything that touches the mailbox:

  • Storage (/var/lib/aimx/inbox/<name>/ and /var/lib/aimx/sent/<name>/) is <owner>:<owner> 0700. Only the owner and root can read or list it.
  • Hook CRUD is gated to the owner or root (CLI checks euid; UDS checks SO_PEERCRED).
  • Hooks always exec as the owner uid — the daemon setuids to mailbox.owner_uid() before exec. There is no per-hook run_as override.

A hook on alice’s mailbox can do anything alice could already do (cron, ~/.bashrc, systemd --user). It cannot escalate privilege, and it cannot read bob’s mail.

To run a hook as root, set mailbox.owner = "root" in /etc/aimx/config.toml — which already requires root. Hooks on the catchall mailbox are forbidden at config load: aimx-catchall has no shell and no resolvable login uid for setuid to drop into.

Hook schema

Hooks live as [[mailboxes.<name>.hooks]] arrays-of-tables in /etc/aimx/config.toml:

[[mailboxes.support.hooks]]
name = "support_notify"
event = "on_receive"
cmd = ["/bin/sh", "-c", 'echo "New mail from $AIMX_FROM: $AIMX_SUBJECT" >> /tmp/email.log']

cmd is exec’d directly as the mailbox owner — there is no shell wrapping. If you need shell expansion (env-var substitution, redirection, pipes), spell out cmd = ["/bin/sh", "-c", "..."] explicitly so it’s visible at the call site.

Hook properties

PropertyTypeRequiredDescription
namestringnoMatches ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$. When omitted, AIMX derives a stable 12-char hex name from sha256(event + joined_argv + fire_on_untrusted). Names must be globally unique across mailboxes — including derived ones.
eventstringyes"on_receive" or "after_send".
typestringnoTrigger kind (default "cmd"). Only cmd is supported today.
cmdarray of stringsyesArgv exec’d directly. Must be non-empty; cmd[0] must be an absolute path. No shell wrapping — wrap in ["/bin/sh", "-c", "..."] explicitly when you need shell expansion.
timeout_secsintnoHard subprocess timeout in seconds. Default 60, range [1, 600]. SIGTERM at the limit, SIGKILL 5s later.
fire_on_untrustedboolnoon_receive only: when true, fire even if trusted != "true". Default false. Rejected on after_send hooks at config load.

Multiple hooks can be defined per mailbox; each is evaluated independently. Unknown fields on a hook table are rejected at config load.

Creating hooks

Via CLI (owner or root)

# As the mailbox owner — uses the daemon's UDS socket; takes effect on the next event
aimx hooks create \
  --mailbox support \
  --event on_receive \
  --cmd '["/bin/sh", "-c", "curl -fsS -X POST https://hooks.example.com/notify -d \"$AIMX_SUBJECT\""]' \
  --name support_notify

# As root — same UDS path; root passes every authorization check
sudo aimx hooks create \
  --mailbox catchall \
  --event on_receive \
  --cmd '["/usr/bin/logger", "-t", "aimx", "inbound mail"]' \
  --fire-on-untrusted

--cmd takes the argv as a JSON array string. cmd[0] must be an absolute path.

When the daemon is running, the CLI submits over /run/aimx/aimx.sock; the daemon hot-swaps its in-memory Config so the new hook is live on the very next event — no restart required. When the daemon is stopped, the CLI falls back to editing config.toml directly (root only) and prints a restart hint; non-root callers hard-error since they cannot write the root-owned config.

Via MCP (owner’s agent)

When aimx mcp runs under the mailbox owner’s uid, the agent can create hooks programmatically:

{"name": "hook_create", "arguments": {
  "mailbox": "accounts",
  "event": "on_receive",
  "cmd": ["/usr/local/bin/claude", "-p", "Read this email and act on it.", "--dangerously-skip-permissions"],
  "fire_on_untrusted": false
}}

See MCP Server: hook_create for the full tool reference. Each agent’s bundled skill ships a copy-paste cmd recipe — see Hook Recipes.

How hooks fire

  1. The email is parsed and saved (ingest), or the MX result is known (send).
  2. The daemon walks the mailbox’s hooks array and picks entries matching the event.
  3. For on_receive, the trust gate applies: the hook fires iff trusted == "true" OR fire_on_untrusted = true.
  4. The argv cmd is execvp’d directly. The daemon setuids to mailbox.owner_uid() first.
  5. On systemd, the subprocess runs under systemd-run with --uid=<owner>, ProtectSystem=strict, PrivateDevices=yes, NoNewPrivileges=yes, MemoryMax=256M, RuntimeMaxSec=<timeout_secs>. On OpenRC, the daemon fork+execs with setresgid/setresuid plus a manual timeout.
  6. stdout and stderr are captured and truncated at 64 KiB each.
  7. Failures (non-zero exit, timeout) are logged at warn but never block delivery.

Email is always stored (inbound) or attempted (outbound) regardless of hook outcome.

Hook context: env vars and stdin

Env vars

Env varDirectionDescription
AIMX_HOOK_NAMEbothEffective hook name (explicit or derived)
AIMX_EVENTbothon_receive or after_send
AIMX_MAILBOXbothMailbox name
AIMX_FROMbothSender address (may include display name on inbound)
AIMX_TObothRecipient address
AIMX_SUBJECTbothSubject line
AIMX_FILEPATHbothPath to the .md file (inbox on on_receive, sent copy on after_send)
AIMX_MESSAGE_IDbothRFC Message-ID
AIMX_IDbothFilename stem
AIMX_DATEbothEmail date (inbound) or send timestamp (outbound)
AIMX_SEND_STATUSafter_send"delivered", "failed", or "deferred"

Always expand env vars inside double quotes ("$AIMX_SUBJECT"). Values from sender-controlled headers can contain $(), backticks, quotes, or newlines — when you wrap your hook in ["/bin/sh", "-c", "..."], these pass through as literal bytes inside the quoted expansion.

Stdin

The raw .md (frontmatter + body) is always piped to the hook’s stdin. The same path is also exposed as $AIMX_FILEPATH.

If your hook only needs the subject or sender, read $AIMX_SUBJECT / $AIMX_FROM and ignore stdin — the daemon writes the full email but does not require the child to consume it. Agent CLIs that don’t read stdin in headless mode (OpenCode, Hermes) use $AIMX_FILEPATH to open the file directly.

The earlier per-hook stdin = "email" | "none" knob has been removed; Config::load rejects any stdin line in [[mailboxes.<name>.hooks]] with an error that names the offending hook.

UDS authorization (SO_PEERCRED)

/run/aimx/aimx.sock is world-writable (0666). Every UDS request is authorized by reading the caller’s uid via SO_PEERCRED and applying per-verb rules. Filesystem permissions are not the security boundary on the socket — the kernel-enforced peer uid is.

VerbAuthorization
SENDCaller uid must own the mailbox resolved from the From: local part, OR be root.
MARK-READ / MARK-UNREADCaller uid must own the target mailbox, OR be root.
MAILBOX-CREATECaller uid synthesized as the owner from SO_PEERCRED for non-root callers (any client-supplied Owner: header is ignored); root may pass Owner: to create cross-uid.
MAILBOX-DELETECaller uid must own the target mailbox, OR be root.
HOOK-CREATE / HOOK-DELETECaller uid must own the target mailbox, OR be root.

Rejected requests return an AIMX/1 ERR response with code = "EACCES" and the canonical reason not authorized (no information leakage about whether the mailbox exists). Caller uid 0 (root) bypasses all mailbox-ownership checks and is logged at info level so aimx logs shows the escalation.

Trust gate (on_receive only)

An on_receive hook fires iff email.trusted == "true" OR the hook sets fire_on_untrusted = true.

fire_on_untrusted is the per-hook escape hatch. Mailbox isolation (uid-scoped storage and exec) is the relevant defense — an agent opting into untrusted mail on its own mailbox cannot escalate beyond what the owner already has. The flag is illegal on after_send hooks; Config::load rejects it with ERR fire_on_untrusted is on_receive only.

email.trusted is computed from the mailbox’s trust + trusted_senders policy and written to frontmatter at ingest:

  • "none": effective trust = "none". No evaluation performed. (Default.)
  • "true": effective trust = "verified", sender matches trusted_senders, AND DKIM passed.
  • "false": effective trust = "verified", but conditions were not met.

Recommended configuration:

  1. Set trust = "verified" + trusted_senders = [...] at the top level of config.toml.
  2. Leave per-hook fire_on_untrusted off for anything that invokes an agent or writes to the filesystem in an irreversible way.

trust modes

ModeEffect on trusted frontmatterEffect on hooks
none (default)Always "none"Default hooks do NOT fire
verified"true" iff sender allowlisted AND DKIM pass; else "false"Default hooks fire only when trusted == "true"
trust = "verified"
trusted_senders = ["*@yourcompany.com"]

[mailboxes.public]
address = "hello@agent.yourdomain.com"
owner = "ubuntu"
trust = "none"

Per-mailbox trusted_senders fully replaces the global list (no merging).

How trust interacts with storage

Email is always stored regardless of trust result. Trust only gates hook execution. An email from an unverified sender is still saved as a .md file and visible to the mailbox owner via email_list / email_read.

DKIM/SPF verification

During email ingest, AIMX verifies DKIM, SPF, and DMARC. Results are stored in the email frontmatter (dkim, spf, dmarc as "pass" | "fail" | "none", with SPF additionally allowing "softfail" / "neutral"). The verified trust mode requires a DKIM pass specifically, combined with an allowlist match on trusted_senders.

Managing hooks via CLI

aimx hooks list / create / delete route through the daemon’s UDS so changes hot-swap into the live config — no restart while aimx serve is running. See CLI Reference: Hook management for every flag.

Structured hook-fire logs

Every hook fire emits one info-level log line with a stable shape:

hook_name=<name> event=<on_receive|after_send> mailbox=<m> owner=<owner> sandbox=<systemd-run|fallback> email_id=<id> exit_code=<n> duration_ms=<n> timed_out=<true|false> stderr_tail="..."

owner is the resolved uid the subprocess ran as (matching mailbox.owner at fire time). When the configured owner has been removed (userdel alice), the daemon soft-skips the hook with a WARN carrying reason = "owner-not-found" — see aimx doctor.

Operators can build journalctl -u aimx | grep hook_name=<name> workflows around it to trace every fire.

Examples

Trigger Claude Code on verified mail

[mailboxes.schedule]
address = "schedule@agent.yourdomain.com"
owner = "alice"
trust = "verified"
trusted_senders = ["*@yourcompany.com"]

[[mailboxes.schedule.hooks]]
name = "schedule_claude"
event = "on_receive"
cmd = ["/usr/local/bin/claude", "-p", "Handle this scheduling request.", "--dangerously-skip-permissions"]

The hook fires only when the email’s trusted == "true". Claude runs as alice (matching the mailbox owner), reads the piped .md from stdin, and uses its own MCP tooling to reply.

Notify via ntfy on every inbound (untrusted)

[mailboxes.catchall]
address = "*@agent.yourdomain.com"
owner = "aimx-catchall"
# Note: hooks on the catchall are forbidden at config load.
# Wire notifications via a non-catchall mailbox instead.

[mailboxes.notify]
address = "notify@agent.yourdomain.com"
owner = "ubuntu"

[[mailboxes.notify.hooks]]
event = "on_receive"
cmd = ["/bin/sh", "-c", 'ntfy pub agent-mail "New email: $AIMX_SUBJECT from $AIMX_FROM"']
fire_on_untrusted = true

After-send audit log

[[mailboxes.alice.hooks]]
name = "after_send_audit"
event = "after_send"
cmd = ["/bin/sh", "-c", 'echo "$AIMX_SEND_STATUS $AIMX_TO $AIMX_SUBJECT" >> /var/log/aimx/alice-sent.log']

Webhook (POST the email body to a URL)

[[mailboxes.alerts.hooks]]
name = "alerts_webhook"
event = "on_receive"
cmd = ["/usr/bin/curl", "-sS", "-X", "POST", "-H", "Content-Type: application/json", "--data-binary", "@-", "https://hooks.example.com/aimx"]

--data-binary @- posts whatever lands on curl’s stdin verbatim, which is the raw .md (frontmatter + body) — the daemon always pipes the email to a hook’s stdin.

Hook Recipes

Copy-paste aimx hooks create invocations for every supported AI agent. For the underlying mechanics, see Hooks & Trust; for installing an agent’s plugin so its MCP tools are discoverable, see Agent Integration.

The /var/log/aimx/<agent>.log paths below are user-chosen destinations for hook script output. They are not AIMX’s own logs — AIMX logs to journald (systemd) or the system logger (OpenRC). See Troubleshooting: Logs.

What counts as a hook recipe?

A hook recipe is a [[mailboxes.<name>.hooks]] block (or equivalent aimx hooks create invocation) whose cmd invokes an AI agent or HTTP endpoint non-interactively against an incoming email. AIMX writes the .md file, evaluates the trust gate, then execvps the argv as the mailbox owner with the raw .md piped to stdin and $AIMX_FILEPATH set. The agent reads the email and takes action (reply, file a ticket, update a calendar). Exit code is logged but never blocks delivery.

cmd is execvp’d directly: there is no /bin/sh between AIMX and the agent. User-controlled header fields like From: and Subject: can contain shell metacharacters ($(), backticks, ;, quotes), so AIMX delivers them as env vars and avoids the shell parser entirely. When you need shell expansion, spell out ["/bin/sh", "-c", "..."] so it is visible at the call site.

Two consumption shapes

The daemon always pipes the .md to stdin, but recipes split into two camps based on whether the agent reads stdin in headless mode:

  • Native stdin (Claude Code, Codex, Gemini, Goose, webhook): the agent reads the piped email off stdin; the prompt only tells it what to do.
  • Filepath only (OpenCode, Hermes): the agent does not read stdin in headless mode, so the prompt instructs it to read $AIMX_FILEPATH via its filesystem tool. argv is not shell-expanded — the literal $AIMX_FILEPATH token reaches the agent, which expands it at run time.

Hooks that only need the subject or sender can read $AIMX_SUBJECT / $AIMX_FROM and ignore stdin.

Absolute paths

Every recipe uses /usr/local/bin/<agent> for cmd[0]. Config::load rejects relative paths (hook has non-absolute cmd[0]). Adjust the path to match your install — which <agent> shows where each binary lives. The cmd[0] literal in your config.toml (or in the --cmd argv array passed to aimx hooks create) must point at the binary that exists on the box.

Self-loop reminder

The headless agent process runs as the mailbox owner’s uid. That user must have the agent binary installed at the cmd[0] path AND the AIMX skill installed (run aimx agents setup as that user and pick the agent). Without both, the agent starts without the skill loaded and does not know what to do with the email.

Claude Code

aimx hooks create \
  --mailbox accounts \
  --event on_receive \
  --cmd '["/usr/local/bin/claude", "-p", "Read the piped email and act on it via the aimx MCP server.", "--dangerously-skip-permissions"]' \
  --name accounts_claude

Optional: --mcp-config /path + --strict-mcp-config to pin which MCP config Claude reads, and --model sonnet (or --model opus) to tune the routing model.

Codex CLI

  • Docs: https://github.com/openai/codex (run codex exec --help).
  • Stdin: native (the trailing - argument tells Codex to read stdin as the prompt — the email itself becomes Codex’s task).
  • Bypass: --full-auto is the safe sandboxed default; --dangerously-bypass-approvals-and-sandbox only inside a contained host.
  • Required: --skip-git-repo-check because hooks fire from /var/lib/aimx/inbox/<mailbox>/, which is not a git repo.
aimx hooks create \
  --mailbox triage \
  --event on_receive \
  --cmd '["/usr/local/bin/codex", "exec", "--skip-git-repo-check", "--full-auto", "-"]' \
  --name triage_codex

OpenCode

  • Docs: https://opencode.ai/docs/cli/
  • Stdin: inline-only (opencode run does not read stdin in headless mode).
  • Bypass: --dangerously-skip-permissions.
aimx hooks create \
  --mailbox research \
  --event on_receive \
  --cmd '["/usr/local/bin/opencode", "run", "--dangerously-skip-permissions", "Read the aimx email at the path in env var AIMX_FILEPATH and act on it via the aimx MCP server (e.g. email_reply)."]' \
  --name research_opencode

The literal token $AIMX_FILEPATH is not shell-expanded (argv is not shell-parsed). The agent sees the literal string AIMX_FILEPATH (or $AIMX_FILEPATH if you write it that way) inside its prompt and uses its Bash/Read tool to read the env var at run time. Optional: --format json for a structured run log.

Gemini CLI

aimx hooks create \
  --mailbox notes \
  --event on_receive \
  --cmd '["/usr/local/bin/gemini", "-p", "Read the piped email and file it into my notes via the aimx MCP server.", "--yolo"]' \
  --name notes_gemini

Optional: -m gemini-2.5-flash to pin the routing model.

Goose

  • Docs: https://block.github.io/goose/docs/guides/headless-goose
  • Stdin: native via --instructions - (reads instructions from stdin; the recipe form is preferred for production).
  • Two recipes shown: ad-hoc (instructions piped via stdin) and recipe-based (the inner recipe pre-binds the aimx mcp extension).

Ad-hoc:

aimx hooks create \
  --mailbox ops \
  --event on_receive \
  --cmd '["/usr/local/bin/goose", "run", "--instructions", "-", "--quiet"]' \
  --name ops_goose

Recipe-based (preferred for production):

aimx hooks create \
  --mailbox ops \
  --event on_receive \
  --cmd '["/usr/local/bin/goose", "run", "--recipe", "/etc/aimx/goose-aimx-hook.yaml"]' \
  --name ops_goose_recipe

The referenced recipe pre-binds the aimx mcp extension and parameterizes the inbound email path. See Goose’s recipe documentation for the inner-recipe shape.

OpenClaw

OpenClaw does not document a one-shot prompt mode as of Apr 2026. Its surfaces are interactive (chat-channel, Control-UI dashboard) plus admin subcommands; there is no --prompt-style entry point that fits the headless hook pattern.

To use OpenClaw with AIMX, treat AIMX as a read source via the MCP server and trigger the agent through OpenClaw’s documented interactive entry points. For shell-side notifications on new mail (so OpenClaw operators know there is mail to look at), wire a simple non-agent on_receive hook:

aimx hooks create \
  --mailbox openclaw \
  --event on_receive \
  --cmd '["/bin/sh", "-c", "ntfy pub openclaw-mail \"New email: $AIMX_SUBJECT from $AIMX_FROM\""]' \
  --fire-on-untrusted \
  --name openclaw_notify

When OpenClaw later publishes a one-shot CLI, this section will be updated; track upstream at https://docs.openclaw.ai/.

Hermes

  • Docs: https://hermes-agent.nousresearch.com/
  • Stdin: inline-only (stdin handling for hermes chat -q is undocumented as of Apr 2026; this recipe ships the inline-only form).
  • Bypass: --yolo skips dangerous-command approval; --ignore-user-config --ignore-rules for fully isolated runs.
aimx hooks create \
  --mailbox hermes \
  --event on_receive \
  --cmd '["/usr/local/bin/hermes", "chat", "-q", "Read the aimx email at the path in env var AIMX_FILEPATH and act on it via the aimx MCP server.", "--yolo"]' \
  --name hermes_chat

If a future Hermes release confirms that chat -q accepts piped stdin, shorten the inline prompt to “Read the piped email and act on it via the AIMX MCP server.” — the daemon already pipes the email regardless.

Webhook (POST to URL)

A pure-curl recipe that POSTs the raw .md to an HTTPS endpoint. No agent required.

aimx hooks create \
  --mailbox alerts \
  --event on_receive \
  --cmd '["/usr/bin/curl", "-sS", "-X", "POST", "-H", "Content-Type: application/json", "--data-binary", "@-", "https://hooks.example.com/aimx"]' \
  --name alerts_webhook

--data-binary @- tells curl to POST whatever lands on its stdin verbatim. Since the daemon always pipes the email to the hook, the receiver gets the raw .md (TOML frontmatter + body) as the request body. Use Content-Type: text/markdown instead if your receiver expects that explicitly.

after_send recipes

Send-side hooks run after AIMX resolves the MX delivery attempt. They cannot affect the send result (hooks are observability-only) but are ideal for audit logs, outbound notifications, or post-send bookkeeping.

Append to an audit log

aimx hooks create \
  --mailbox alice \
  --event after_send \
  --cmd '["/bin/sh", "-c", "printf \"%s %s %s %s\\n\" \"$AIMX_SEND_STATUS\" \"$AIMX_TO\" \"$AIMX_SUBJECT\" \"$AIMX_HOOK_NAME\" >> /var/log/aimx/alice-sent.log"]' \
  --name alice_audit

Page on failed sends

aimx hooks create \
  --mailbox alerts \
  --event after_send \
  --cmd '["/bin/sh", "-c", "if [ \"$AIMX_SEND_STATUS\" != \"delivered\" ]; then ntfy pub on-call \"aimx send to $AIMX_TO $AIMX_SEND_STATUS: $AIMX_SUBJECT\"; fi"]' \
  --name alerts_failed_page

Recipient-based filtering (shell guard)

after_send hooks have no built-in filter fields — do recipient/subject matching in the cmd itself with a shell guard.

aimx hooks create \
  --mailbox marketing \
  --event after_send \
  --cmd '["/bin/sh", "-c", "case \"$AIMX_TO\" in *@customer-co.com) curl -fsS -X POST https://hooks.internal/marketing-sent -d \"to=$AIMX_TO&status=$AIMX_SEND_STATUS\" ;; esac"]' \
  --name marketing_customer_notify

Operational tips

Logging

AIMX itself emits one structured log line per hook fire to journald:

hook_name=<name> event=<on_receive|after_send> mailbox=<m> owner=<u> exit_code=<n> duration_ms=<n>

Grep by hook_name=<name> (journalctl -u aimx | grep hook_name=accounts_claude) to trace every fire of a specific hook. Hook stdout / stderr are captured by the daemon (truncated at 64 KiB each) and surfaced as stderr_tail=... on the structured line.

If you want a separate per-agent log file too, wrap the agent invocation in ["/bin/sh", "-c", "<agent> ... >> /var/log/aimx/<agent>.log 2>&1"]. Most operators find the journald line sufficient.

Exit codes

A non-zero exit from the hook command is logged at warn but does not block delivery or the send. Email is always stored as a .md file. This is intentional: you do not want flaky agent CLIs to stall your mailbox.

Concurrent hooks

If two emails arrive in rapid succession, aimx serve fires two hook subprocesses in parallel. Agent CLIs that lock a resource (e.g. a per-repo Aider session) can collide. Serialise inside your own wrapper using flock:

aimx hooks create \
  --mailbox bugs \
  --event on_receive \
  --cmd '["/usr/bin/flock", "/tmp/myapp.lock", "/usr/local/bin/claude", "-p", "Reproduce the reported bug.", "--dangerously-skip-permissions"]' \
  --name bugs_serialised

Testing a recipe locally

Use the aimx ingest CLI to simulate a real delivery without a live SMTP exchange:

sudo aimx --data-dir /tmp/aimx-test ingest catchall@agent.example.com \
     < tests/fixtures/plain.eml

Watch journald (aimx logs --follow) and confirm the agent ran with the expected env vars.

MCP Server

AIMX includes a Model Context Protocol server that gives AI agents programmatic access to email. Agents can list, read, send, reply to, and manage email through standard MCP tool calls.

Overview

Running the MCP server

As this is a stdio MCP server, you do not need to “start a listening MCP server”. Your AI agents would spawn a server on demand via aimx mcp . Just to be clear, you aimx mcp is for your AI agents than for you to run.

The server reads from stdin and writes to stdout. To install it into a supported agent, see Agent Integration.

Per-user authorization

The MCP server inherits the uid of the user that launched the client. Every tool call routes through the daemon UDS, which authorizes against the caller’s uid via SO_PEERCRED: caller is root, or caller’s uid equals the target mailbox’s owner_uid. Tools acting on mailboxes the caller does not own return EACCES not authorized; hook_delete for an unowned hook collapses to Hook '<name>' not found so foreign mailbox names do not leak.

Root running the MCP server bypasses mailbox-ownership checks (and is logged at info level). See Security: Per-action authorization.

MCP tools

AIMX exposes 12 MCP tools organized into mailbox lifecycle, email operations, and hook management.

Mailbox tools

mailbox_list

List mailboxes you own.

Parameters: none

Returns: JSON array. One row per visible mailbox with these fields:

FieldTypeDescription
namestringMailbox name (the local part).
inbox_pathstringAbsolute path to the inbox directory (/var/lib/aimx/inbox/<name>).
sent_pathstringAbsolute path to the sent directory (/var/lib/aimx/sent/<name>).
totalnumberTotal emails in the inbox.
unreadnumberInbox emails with read = false.
sent_countnumberTotal emails in the sent folder.
registeredbooltrue for mailboxes in config.toml; false for stray on-disk dirs only.

The empty case returns []. Filtered to caller-owned mailboxes for non-root callers; root sees everything. The MCP process resolves the listing through the daemon over /run/aimx/aimx.sock, so it works without read access to root-owned config.toml.


mailbox_create

Provision a new mailbox owned by the calling agent’s uid.

ParameterTypeRequiredDescription
namestringyesMailbox name (the local part of the resulting address). Must match [a-z0-9._-]+, must not be the reserved literals catchall / aimx-catchall.

There is no owner parameter, by construction. The daemon synthesizes the owner from the MCP process’s uid via SO_PEERCRED — agents can only create mailboxes owned by themselves. To provision a mailbox owned by another uid (e.g. a service account), an operator must run sudo aimx mailboxes create <name> --owner <user> from the host CLI.

Returns: the new mailbox’s full address (<name>@<domain>) on success. Idempotent: re-running mailbox_create("foo") on a mailbox you already own returns the existing address with no side effects.

Errors: surfaces the daemon’s ErrCode + reason verbatim. Common cases:

  • Validation: reservedname was catchall or aimx-catchall.
  • Validation: ... — name matched a structural rule violation (empty, contains .., leading/trailing ., invalid character, etc.).
  • daemon must be running for non-root mailbox CRUD — daemon is offline; agents cannot fall back to a direct config edit.

mailbox_delete

Remove a mailbox the calling agent owns.

ParameterTypeRequiredDescription
namestringyesMailbox name to delete. Caller’s uid must equal the mailbox’s owner_uid.
forceboolnoDefault false. When true, the daemon wipes inbox/<name>/ and sent/<name>/ under per-mailbox lock + CONFIG_WRITE_LOCK before unregistering the mailbox.

Without force, the daemon refuses non-empty mailboxes with ERR NONEMPTY and the tool surfaces a hint pointing at the CLI’s interactive --force prompt. The catchall mailbox is refused with or without force.

Errors: surfaces the daemon’s ErrCode + reason verbatim. Common cases:

  • EACCES not authorized — caller’s uid does not own the target mailbox.
  • NONEMPTY: inbox=N sent=M — mailbox has files; pass force: true (or use the CLI’s interactive --force prompt) to wipe them.
  • daemon must be running for non-root mailbox CRUD — daemon is offline.
{"name": "mailbox_create", "arguments": {"name": "task-42"}}
{"name": "mailbox_delete", "arguments": {"name": "task-42", "force": true}}

Email tools

email_list

List a page of email metadata in a mailbox, sorted descending by filename (newest first). AIMX never scans on the agent’s behalf — agents page through the listing and filter client-side.

ParameterTypeRequiredDescription
mailboxstringyesMailbox name to list emails from. Must be owned by the caller.
folderstringno"inbox" (default) or "sent". Picks which side of the mailbox to list
limitu32noPage size; default 50, hard-capped at 200. Values above 200 silently clamp
offsetu32noNumber of newest rows to skip; default 0

Returns: A JSON array of metadata rows. Inbox rows carry { id, from, to, subject, date, read }. Sent rows carry { id, from, to, subject, date, delivery_status } — the read field is intentionally absent from sent rows, since agents do not mark sent mail. An empty mailbox returns the literal []. Returns EACCES not authorized if the caller does not own the target mailbox.


email_read

Read the full content of an email.

ParameterTypeRequiredDescription
mailboxstringyesMailbox name
idstringyesEmail ID, i.e. the filename stem (e.g. 2025-01-15-103000-meeting)
folderstringno"inbox" (default) or "sent"

Returns: Complete .md file content including frontmatter and body. Returns EACCES not authorized if the caller does not own the target mailbox.


email_send

Compose and send an email with DKIM signing.

ParameterTypeRequiredDescription
from_mailboxstringyesMailbox name to send from. Must be owned by the caller.
tostringyesRecipient email address
subjectstringyesEmail subject
bodystringyesEmail body. Interpreted as Markdown by default — rendered to HTML and shipped as multipart/alternative with the Markdown source as the text part.
text_onlyboolnoWhen true, ship the body verbatim as text/plain. No Markdown rendering, no HTML alternative. Mutually exclusive with html_body.
html_bodystringnoOperator-supplied HTML used verbatim as the text/html part; body becomes the text/plain fallback. Mutually exclusive with text_only.
attachmentsarray of stringsnoFile paths to attach
reply_tostringnoMessage-ID of the email being replied to. Sets the In-Reply-To header and (when references is omitted) builds the References chain automatically. Required to enable threading. Without reply_to, any references value is silently ignored and no threading headers are emitted
referencesstringnoFull References header chain (space-separated Message-IDs). Only applied when reply_to is also set. Supplied alone, it is silently ignored

The MCP server composes the RFC 5322 message and submits it to aimx serve over the local /run/aimx/aimx.sock UDS. aimx serve parses From: from the body, validates that the caller’s uid owns the resolved mailbox, DKIM-signs the message, and delivers it directly to the recipient’s MX server via SMTP. See Markdown Email for the rendering pipeline and the --text-only / --html-body semantics that mirror these MCP parameters.

For replies to a single sender, prefer email_reply. It handles threading headers and the Re: subject prefix automatically. Use email_send with reply_to / references only when you need to override the recipient list (e.g. reply-all) or build a custom threading chain.


email_reply

Reply to an email with correct threading.

ParameterTypeRequiredDescription
mailboxstringyesMailbox name containing the email to reply to. Must be owned by the caller.
idstringyesEmail ID to reply to (e.g. 2025-01-15-001)
bodystringyesReply body. Interpreted as Markdown by default — same semantics as email_send’s body.
text_onlyboolnoWhen true, ship the body verbatim as text/plain. Mutually exclusive with html_body.
html_bodystringnoOperator-supplied HTML used verbatim as the text/html part; body becomes the text/plain fallback. Mutually exclusive with text_only.

Automatically sets In-Reply-To and References headers from the original email for proper thread grouping in the recipient’s mail client.


email_mark_read

Mark an inbox email as read. Sent-mail mark has no agent use case and is not supported.

ParameterTypeRequiredDescription
mailboxstringyesMailbox name. Must be owned by the caller.
idstringyesEmail ID (filename stem, e.g. 2025-01-15-103000-meeting)

Updates read = true in the email’s frontmatter. The MCP server is non-root so it routes the write through aimx serve over the local UDS (/run/aimx/aimx.sock) rather than touching the root-owned mailbox file directly. If aimx serve is not running the tool returns an error hint to start the daemon.


email_mark_unread

Mark an inbox email as unread. Sent-mail mark has no agent use case and is not supported.

ParameterTypeRequiredDescription
mailboxstringyesMailbox name. Must be owned by the caller.
idstringyesEmail ID (filename stem, e.g. 2025-01-15-103000-meeting)

Updates read = false in the email’s frontmatter. Same daemon-mediated write path as email_mark_read. Requires a running aimx serve.


Hook tools

Three tools let agents self-configure hooks on mailboxes they own. See Hooks & Trust for the model and Hook Recipes for verified per-agent cmd argv.

hook_create

Create a new hook on a mailbox you own. The daemon validates the caller’s uid against the mailbox’s owner_uid via SO_PEERCRED and rejects with EACCES not authorized if the predicate fails.

ParameterTypeRequiredDescription
mailboxstringyesTarget mailbox name. Must be owned by the caller.
eventstringyes"on_receive" or "after_send"
cmdarray of stringsyesArgv exec’d directly when the hook fires. cmd[0] must be an absolute path.
namestringnoExplicit hook name. When omitted, a stable 12-hex-char name is derived from sha256(event + joined_argv + fire_on_untrusted).
timeout_secsintnoHard subprocess timeout in seconds. Default 60, range [1, 600].
fire_on_untrustedboolnoon_receive only: fire even when trusted != "true". Default false. Rejected on after_send.

The raw .md (frontmatter + body) is always piped to the hook’s stdin and the same path is exposed as $AIMX_FILEPATH. If your hook only needs the subject or sender, read $AIMX_SUBJECT / $AIMX_FROM and ignore stdin — the daemon writes the full email but does not require the child to consume it.

Returns: {effective_name} — the hook name the daemon wrote.

Example (Claude Code self-wiring):

{"name": "hook_create", "arguments": {
  "mailbox": "accounts",
  "event": "on_receive",
  "cmd": ["/usr/local/bin/claude", "-p", "Read the piped email and act on it via the aimx MCP server.", "--dangerously-skip-permissions"],
  "name": "accounts_claude"
}}

Error examples:

  • EACCES not authorized — caller’s uid does not own the target mailbox
  • mailbox-not-found: <name> — mailbox does not exist
  • hook has non-absolute cmd[0]cmd[0] must be an absolute path
  • fire_on_untrusted is on_receive only — flag set on an after_send hook
  • catchall does not support hooks — target was a catchall mailbox

hook_list

List hooks on mailboxes you own.

ParameterTypeRequiredDescription
mailboxstringnoFilter to one mailbox (must be owned by the caller); omit to list every owned mailbox

Returns: JSON array. Each entry has name, mailbox, event, cmd, timeout_secs, and fire_on_untrusted.

[
  {"name": "accounts_claude", "mailbox": "accounts", "event": "on_receive", "cmd": ["/usr/local/bin/claude", "-p", "...", "--dangerously-skip-permissions"], "timeout_secs": 60, "fire_on_untrusted": false}
]

hook_delete

Delete a hook by name. Caller must own the hook’s mailbox.

ParameterTypeRequiredDescription
namestringyesEffective hook name (explicit or derived)

Returns Hook '<name>' not found for hooks on mailboxes the caller does not own (the lookup is filtered before the existence check, so foreign mailbox names do not leak).


Frontmatter reference

Every email carries a TOML frontmatter block between +++ delimiters. See Mailboxes: Frontmatter fields for the full inbound schema and Mailboxes: Outbound frontmatter for the outbound additions.

Agent-facing documentation

Two reference documents help agents understand AIMX:

  • agents/common/aimx-primer.md — the canonical primer bundled into every agent plugin. Covers MCP tools, storage layout, frontmatter, trust model, workflows.
  • /var/lib/aimx/README.md — the runtime datadir guide written by aimx setup and refreshed on aimx serve startup. Covers on-disk layout, file naming, slug algorithm, bundle rules.

Example workflow

  1. Call email_list and filter rows where read == false.
  2. Call email_read with the mailbox and email ID, or read <inbox_path>/<id>.md from the filesystem.
  3. Process the content.
  4. Call email_reply with the response body.
  5. Call email_mark_read.

For automated processing without MCP, use hooks.

Agent Integration

aimx agents setup wires AI agents into AIMX in one command. It launches an interactive picker, then installs each selected agent’s plugin or skill bundle under $HOME so the agent can call AIMX’s MCP tools and create hooks via MCP. No sudo, no manual config edit. aimx agents remove <agent> is the inverse.

AIMX is a standard MCP stdio server — any MCP-compatible client works. The agents below come with batteries included: aimx agents setup writes the MCP config and skill bundle in one command. For any other harness, see Any MCP-compatible client (manual wiring).

For email-triggered workflows after installation, see Hook Recipes.

The one-command flow

aimx agents setup

This launches an interactive picker showing every supported agent with its detected state. Pick one or more agents, confirm, and for each selected agent the installer:

  1. Refuses root — run it as the user whose agent you are configuring.
  2. Writes the embedded skill tree under the agent’s per-user destination (e.g. ~/.claude/skills/aimx/).
  3. Auto-registers the MCP server when the agent has a registration CLI (Claude Code → claude mcp add, Codex → codex mcp add, NanoClaw → merge into <fork>/.mcp.json). Falls back to printing the equivalent command if the CLI is not on PATH.
  4. Prints an activation hint for snippet-style agents (OpenCode, Gemini CLI, OpenClaw, Hermes) so you can paste the JSON/YAML block into the agent’s config.

After this, each agent can call AIMX’s MCP tools (including hook_create, hook_list, hook_delete) for any mailbox the calling user owns. The bundled plugin includes a “Wiring yourself up as a mailbox hook” section with the verified cmd argv.

The interactive picker

The picker lists every supported agent with its detected state (AIMX MCP wired, installed but not wired, or not detected). Arrow keys to move, Space to toggle, Enter to confirm, q to cancel. Installed-but-not-wired agents are pre-checked; undetected rows are dimmed and skipped.

Enter shows a confirmation screen with the right verb per task (Install AIMX MCP for ... / Re-install AIMX MCP for ...) and asks Confirm? [Y/n] before writing any files.

aimx agents list and aimx agents setup --no-interactive print the same registry as a plain table. Piping to cat or less also falls back to the plain table.

Reference: TUI visual

Setting up MCP integration for AI agents for `alice`.
Select which AI agents you want to set up AIMX MCP for:

❯ [ ] Claude Code
  [x] Codex CLI  (AIMX MCP wired)
  [-] Gemini CLI (not detected)
  [ ] OpenClaw
  [-] OpenCode (not detected)
  [-] Hermes (not detected)
  [-] Goose (not detected)

  → Space toggles, Enter confirms, q cancels.
  • is the colored caret on the focused row.
  • [x] / [ ] are selected / unselected checkboxes.
  • [-] ... (not detected) marks agents whose config directory isn’t present on this machine — the cursor skips those rows entirely.
  • (AIMX MCP wired) marks agents whose plugin destination already exists on disk — they’re listed but default to unchecked.

Scripting and non-interactive use

The interactive picker is the recommended path. For provisioning scripts and CI, you can pass an agent name as a positional argument to skip the picker and install that agent directly:

aimx agents setup claude-code

The same --force, --print, --data-dir, and --dangerously-allow-root flags apply. --no-interactive (with no agent name) prints the registry instead of opening the picker.

Following aimx setup

When sudo aimx setup completes, Step 6 prints the list of supported agents and a → aimx agents setup callout, then marks Step 6 as ⎘ Handoff and exits. Agent wiring is a separate, operator-initiated step — the same idiom as apt install / gh auth login. Run aimx agents setup yourself as your regular (non-root) user once the wizard finishes. See Setup — wiring agents for the wizard-side details.

Any MCP-compatible client (manual wiring)

AIMX speaks standard MCP over stdio. Any MCP-compatible client can connect by adding AIMX as a stdio MCP server. Most clients accept a JSON snippet of this form:

{
  "mcpServers": {
    "aimx": {
      "command": "/usr/local/bin/aimx",
      "args": ["mcp"]
    }
  }
}

For a custom data directory, extend args:

"args": ["--data-dir", "/custom/path", "mcp"]

The location that JSON goes in is agent-specific. Check your agent’s MCP documentation. The MCP Server chapter documents the available tools.

Key properties

  • Refuses root. Run aimx agents setup as the user whose agent you are configuring. For single-user root-login VPS setups, pass --dangerously-allow-root to wire AIMX into root’s home. The flag applies to the TUI, per-agent runs, and --no-interactive.
  • Writes only to $HOME. Nothing under /etc/ or /var/ is touched by the plugin-install step.
  • Offline. The plugin tree is embedded at compile time.
  • Idempotent. --force overwrites existing plugin files.
  • Hook authorization is by mailbox ownership. When the agent later calls hook_create, the daemon authorizes via SO_PEERCRED and runs hooks as the mailbox’s owner uid — see Security: Per-action authorization.

Flags

FlagPurpose
--listPrint the registry (agent name, destination, activation hint). No TUI.
--no-interactiveSkip the checkbox TUI when no agent is named; print the same plain registry dump as --list. Intended for scripting.
--dangerously-allow-rootBypass the root-refusal check and wire AIMX into /root’s home. Applies to the TUI, per-agent runs, and --no-interactive. Prefer sudo -u <user> aimx agents setup on any machine with a regular user.
--forceOverwrite existing destination files without prompting.
--printPrint plugin contents to stdout instead of writing to disk. Useful for CI and dry runs.
--data-dir <path>Global flag. If AIMX was set up with a non-default data directory, pass this so the plugin’s MCP command is rewritten to include --data-dir.

Removing an agent: aimx agents remove

aimx agents remove <agent> is the inverse of aimx agents setup. It runs per-user and refuses root. The command removes the plugin files under $HOME and prints an agent-specific cleanup hint pointing at any external command you still need to run (for example claude mcp remove aimx).

aimx agents remove claude-code

Agents with batteries included

Pick the agents you want from the aimx agents setup picker. The reference table below covers per-agent destinations, activation steps, and how the AIMX primer is laid out.

AgentDestinationActivationProgressive disclosure
Claude Code~/.claude/skills/aimx/Auto-registered via claude mcp add (fallback hint printed if claude is not on PATH). Restart Claude Code so the new server is loaded.Primer as skill + references/ directory copied as siblings
Codex CLI~/.codex/skills/aimx/Auto-registered via codex mcp add (fallback hint printed if codex is not on PATH). Restart Codex CLI so the new server is loaded.Primer as skill + references/ directory copied as siblings
OpenCode~/.config/opencode/skills/aimx/Paste the printed JSONC block into opencode.json, then restart OpenCode.Single skill file (primer body). References inlined
Gemini CLI~/.gemini/skills/aimx/Merge the printed JSON block into ~/.gemini/settings.json, then restart Gemini CLI.Single skill file (primer body). References inlined
Goose~/.config/goose/recipes/aimx.yamlRun goose run --recipe aimx. The recipe bundles its own MCP extension, so no separate config step.Single YAML blob (primer as prompt block scalar). References inlined
OpenClaw~/.openclaw/skills/aimx/Run the printed openclaw mcp set aimx '...' command, then restart the OpenClaw gateway.Primer as skill + references/ directory copied as siblings
Hermes~/.hermes/skills/aimx/Paste the printed YAML block under mcp_servers: in ~/.hermes/config.yaml, then run /reload-mcp inside Hermes.Primer as skill + references/ directory copied as siblings
NanoClaw<fork>/skills/aimx/ (default ~/nanoclaw/, override via $NANOCLAW_HOME)Auto-merged into <fork>/.mcp.json under mcpServers.aimx. Restart NanoClaw so the new server is loaded.Primer as skill + references/ directory copied as siblings

Every agent receives the canonical AIMX primer (agents/common/aimx-primer.md). Multi-file targets (Claude Code, Codex CLI, OpenClaw, Hermes, NanoClaw) also get agents/common/references/ as siblings for progressive disclosure. Single-file targets (OpenCode, Gemini CLI, Goose) get the primer inline.

NanoClaw is the only supported agent where aimx agents setup writes to the agent’s MCP config file: NanoClaw has no mcp add CLI and ships MCP servers as JSON5 in the per-fork .mcp.json. The installer reads that file (if present), merges an mcpServers.aimx entry preserving any other servers, and writes back via temp-file + atomic rename. --print shows the proposed JSON without touching disk; --force overwrites an existing aimx entry. Every other agent either has a registration CLI or expects the user to paste a snippet — AIMX does not mutate their config files.

Per-agent hook recipes

The plugin bundle for each agent ships a “Wiring yourself up as a mailbox hook” section with a copy-paste cmd argv. The agent reads its own skill and writes the recipe at hook-creation time — see Hook Recipes for the verified invocations.

On a multi-user host, alice and bob each install their own per-$HOME copy of the plugin, and each can call hook_create only on the mailboxes they own — the daemon enforces ownership via SO_PEERCRED on every UDS request.

See MCP Server § Hook tools for the full tool reference.

Claude Code

Claude Code auto-discovers user-scope skills under ~/.claude/skills/, but the MCP server is not auto-activated — claude -p (headless mode, used by hook recipes) needs an explicit claude mcp add. The skill ships SKILL.md (the AIMX primer) plus a references/ directory loaded on demand.

Run aimx agents setup and select Claude Code from the picker. The installer auto-runs claude mcp add --scope user aimx -- /usr/local/bin/aimx mcp, which updates ~/.claude.json so both the interactive REPL and claude -p see the server. Restart Claude Code after install. If claude is not on PATH, the installer prints the equivalent command instead.

Custom data directory:

aimx --data-dir /custom/path agents setup

The installer threads --data-dir /custom/path into the auto-runned claude mcp add invocation (and into the fallback hint when the CLI is missing).

Codex CLI

Codex CLI’s MCP wiring lives in ~/.codex/config.toml under [mcp_servers.<name>] and is managed via codex mcp add. The skill ships at ~/.codex/skills/aimx/ with SKILL.md plus a references/ directory.

Run aimx agents setup and select Codex CLI from the picker. The installer auto-runs codex mcp add aimx -- /usr/local/bin/aimx mcp. If codex is not on PATH, the equivalent command is printed instead.

Custom data directory:

aimx --data-dir /custom/path agents setup

The installer threads --data-dir /custom/path into both the auto-registration command and the fallback hint.

OpenCode

OpenCode discovers skills from ~/.config/opencode/skills/<name>/ (user) or <repo>/.opencode/skills/<name>/ (project). MCP servers are configured separately in opencode.json, not alongside the skill.

Run aimx agents setup and select OpenCode from the picker. The installer writes ~/.config/opencode/skills/aimx/SKILL.md and prints a JSONC block. Paste it into the mcp object in ~/.config/opencode/opencode.json (or project-level <repo>/opencode.json):

{
  "mcp": {
    "aimx": {
      "command": ["/usr/local/bin/aimx", "mcp"]
    }
  }
}

For a custom data directory:

aimx --data-dir /custom/path agents setup

The printed JSONC snippet will have "--data-dir", "/custom/path" inserted into the command array.

Restart OpenCode (or reload its config) after editing opencode.json. See agents/opencode/README.md for the schema reference.

Gemini CLI

Gemini CLI picks up skills from ~/.gemini/skills/<name>/ and configures MCP servers in ~/.gemini/settings.json. The installer prints the exact JSON block to merge rather than mutating settings.json directly.

Run aimx agents setup and select Gemini CLI from the picker. The installer writes ~/.gemini/skills/aimx/SKILL.md and prints:

{
  "mcpServers": {
    "aimx": {
      "command": "/usr/local/bin/aimx",
      "args": ["mcp"]
    }
  }
}

Merge that block into ~/.gemini/settings.json. If the file does not exist, create it with the object above as its full contents. If mcpServers already exists, add the aimx key inside it.

For a custom data directory:

aimx --data-dir /custom/path agents setup

The printed args array will include "--data-dir", "/custom/path".

Restart Gemini CLI after editing settings.json. See agents/gemini/README.md for the schema reference.

Goose

Goose uses YAML recipes — one file bundles the goal, agent-facing prompt, and MCP extensions. The recipe carries both the MCP wiring and the AIMX primer; no separate config edit is needed.

Run aimx agents setup and select Goose from the picker. The installer writes ~/.config/goose/recipes/aimx.yaml. Run it with:

goose run --recipe aimx

Goose resolves the --recipe aimx argument to aimx.yaml in the recipes directory.

For a custom data directory:

aimx --data-dir /custom/path agents setup

The recipe’s stdio extension args will be rewritten to include --data-dir /custom/path before mcp.

Sharing recipes with a team: if you set the GOOSE_RECIPE_GITHUB_REPO environment variable, Goose loads recipes from a GitHub repo. In that case, commit the generated ~/.config/goose/recipes/aimx.yaml into your repo so every user can invoke goose run --recipe aimx. The installer detects the env var at install time and prints a pointer to this workflow.

See agents/goose/README.md for the schema reference.

OpenClaw

OpenClaw uses skill directories like Claude Code, with MCP servers configured in ~/.openclaw/openclaw.json. The installer uses OpenClaw’s openclaw mcp set CLI to register the server non-interactively.

Run aimx agents setup and select OpenClaw from the picker. The installer writes ~/.openclaw/skills/aimx/SKILL.md and prints a command like:

openclaw mcp set aimx '{"command":"/usr/local/bin/aimx","args":["mcp"]}'

Run that command (it edits ~/.openclaw/openclaw.json for you), then restart the OpenClaw gateway so the new MCP server is loaded.

For a custom data directory:

aimx --data-dir /custom/path agents setup

The printed openclaw mcp set command’s JSON will include --data-dir /custom/path in the args array.

See agents/openclaw/README.md for the schema reference.

Hermes

Hermes Agent loads skills from ~/.hermes/skills/<name>/SKILL.md (optionally with references/ siblings) and reads MCP server definitions from ~/.hermes/config.yaml under mcp_servers:. There is no shell-side CLI for registering external MCP servers in Hermes, so the installer prints a YAML snippet to paste into the config.

Run aimx agents setup and select Hermes from the picker. The installer writes ~/.hermes/skills/aimx/SKILL.md and the bundled references/ directory, then prints a YAML block like:

mcp_servers:
  aimx:
    command: /usr/local/bin/aimx
    args: [mcp]
    enabled: true

Add that block to ~/.hermes/config.yaml under the top-level mcp_servers: key (create the key if it does not yet exist), save the file, then run /reload-mcp inside Hermes to pick up the new server without restarting.

For a custom data directory:

aimx --data-dir /custom/path agents setup

The printed YAML’s args line will become args: [--data-dir, /custom/path, mcp].

See agents/hermes/README.md for the schema reference.

NanoClaw

NanoClaw is forked per-user from qwibitai/nanoclaw and run from the clone, so there is no global ~/.nanoclaw/. The installer resolves the fork path from $NANOCLAW_HOME (default ~/nanoclaw); set the env var before running setup if your fork lives elsewhere.

NanoClaw exposes no mcp add CLI and ships MCP servers as JSON5 in <fork>/.mcp.json. The installer reads the file (if present), merges an mcpServers.aimx entry, and writes back via temp-file + atomic rename. Other servers in the file are preserved.

Install: run aimx agents setup and select NanoClaw from the picker (or, with a non-default fork path, set NANOCLAW_HOME first):

NANOCLAW_HOME=/opt/my-nanoclaw aimx agents setup

The installer requires the fork directory to exist (no auto-mkdir) so it never creates a stub a later git clone would refuse. Restart NanoClaw after install so it loads the new .mcp.json entry and discovers the skill.

For a custom data directory:

aimx --data-dir /custom/path agents setup

The merged .mcp.json entry’s args array will include --data-dir /custom/path.

Re-running. Skill files are idempotent (re-run with --force to overwrite). On .mcp.json, an existing mcpServers.aimx entry is left in place unless --force is passed; the installer warns and exits cleanly rather than silently shadowing what was there.

Channel triggers and hooks. NanoClaw is a long-running Node.js daemon listening on messaging channels inside a container; it does not expose a one-shot CLI suitable for an on_receive hook. The natural integration is the other direction — NanoClaw pulls unread mail via MCP on its own scheduled-job cadence. For sub-second reactions to inbound mail, wire a different agent (Claude Code, Codex, or Hermes) as the on_receive hook and let NanoClaw consume the resulting state on its next tick.

See agents/nanoclaw/README.md for the schema reference.

Troubleshooting

Agent binary not found at runtime

When the agent later fires as a hook, the daemon execs the absolute path you wrote into the hook’s cmd[0]. If that path doesn’t exist, the hook log shows exit_code = -1 with spawn-failed. Run which <agent> on the host as the mailbox owner to confirm the binary’s path, then re-create the hook with the corrected cmd[0] (aimx hooks delete <name> followed by aimx hooks create --cmd ...).

The agent does not see AIMX after agents setup runs

  • Confirm the destination was written: aimx agents setup --list shows the destination path; check that it exists and contains the expected files.
  • Restart the agent. Most agents only scan their plugin directory at startup.
  • If the agent requires an explicit install step, re-read the installer output. The activation hint tells you exactly which command to run.

“destination files already exist” error

Re-run with --force to overwrite when you want to replace the plugin files on disk.

agents setup refuses to run as root

It is intentional. Per-user agent configuration lives under $HOME; if you run the installer as root, it would drop files into root’s home (or fail in surprising ways with sudo -u). Run it as the user whose agent you are configuring.

MCP tools appear but calls fail with “Failed to load config”

The plugin’s MCP command defaults to /var/lib/aimx/ for the AIMX data directory. If you set up AIMX with a different path, re-run with aimx --data-dir <path> agents setup --force and re-select the affected agent in the picker.

OpenCode: skill loads but MCP tools do not appear

OpenCode loads skills from ~/.config/opencode/skills/ but MCP servers only activate when declared in opencode.json. Re-run aimx agents setup and re-select OpenCode, copy the printed JSONC block into the mcp object in your opencode.json, and restart OpenCode.

Gemini: “unknown MCP server aimx”

Gemini CLI requires the mcpServers.aimx block in ~/.gemini/settings.json. Re-run aimx agents setup, re-select Gemini CLI, and merge the printed JSON block into settings.json. If the file did not exist before you ran the installer, create it with just the printed object as its contents.

Goose: goose run --recipe aimx says “recipe not found”

Goose resolves --recipe <name> to <name>.yaml under ~/.config/goose/recipes/. Confirm the file is there:

ls ~/.config/goose/recipes/aimx.yaml

If it is missing, re-run aimx agents setup and re-select Goose. If XDG_CONFIG_HOME is set to a non-default value, Goose and AIMX both honour it — check under $XDG_CONFIG_HOME/goose/recipes/ instead.

OpenClaw: openclaw mcp set says “command not found”

The activation step needs the openclaw CLI on your PATH. If OpenClaw is installed in a non-standard location, run the printed JSON through your own OpenClaw binary:

/path/to/openclaw mcp set aimx '...'

Alternatively, hand-edit ~/.openclaw/openclaw.json and add the printed object under mcpServers.aimx. The JSON5 format accepts comments and trailing commas but vanilla JSON works too.

Hermes: AIMX tools missing after editing config.yaml

Hermes does not auto-reload MCP servers when ~/.hermes/config.yaml changes. You must run the in-app /reload-mcp slash command (or restart Hermes) after pasting the snippet. Confirm the snippet sits under the top-level mcp_servers: key (not nested inside another section) and that YAML indentation uses spaces, not tabs. If you have no other MCP servers configured, the block can be the entire mcp_servers: section.

CLI Reference

Every aimx subcommand and its flags. aimx <command> --help is authoritative; this page summarises.

Global flags

Accepted on every subcommand.

FlagEnv varDescription
--data-dir <path>AIMX_DATA_DIROverride the mailbox data directory (default /var/lib/aimx). The flag wins when both are set.

For the full set of environment variables (AIMX_DATA_DIR, AIMX_CONFIG_DIR, AIMX_TEST_MAIL_DROP, NO_COLOR), see Configuration: Environment variables.

Daemon and setup

aimx serve

Start the embedded SMTP listener daemon. Managed by systemd / OpenRC in normal operation.

FlagDefaultDescription
--bind <addr>0.0.0.0:25Bind address for the SMTP listener.
--tls-cert <path>(from setup)PEM file for the STARTTLS certificate.
--tls-key <path>(from setup)PEM file for the STARTTLS private key.

See Setup for service installation and Configuration for config file details.

aimx setup [domain]

Interactive setup wizard. Requires root. Generates STARTTLS cert and DKIM keys, writes /etc/aimx/config.toml, installs a systemd (or OpenRC) unit for aimx serve, and drives DNS verification. Re-entrant: running it on an existing install skips install and jumps to DNS verification.

FlagDescription
<domain> (positional)Domain to configure (e.g. agent.yourdomain.com). Prompted if omitted.
--verify-host <url>Override the verifier service host for this invocation.

See Setup for the full walkthrough.

aimx uninstall

Stop the daemon, remove the init-system service file, and delete the installed aimx binary itself so a subsequent install.sh run starts from a clean slate. Leaves /etc/aimx/ and /var/lib/aimx/ intact — wipe them manually with sudo rm -rf /etc/aimx /var/lib/aimx if you want a full purge.

FlagDescription
-y, --yesSkip the confirmation prompt.

aimx portcheck

Check port 25 connectivity (outbound EHLO + inbound EHLO probe). Requires root.

FlagDescription
--verify-host <url>Override the verifier service host for this invocation.

See Setup: End-to-end verification.

Diagnostics

aimx doctor

Print server health: config path, per-mailbox totals and unread counts, ownership status, DKIM key presence, SMTP service state, DNS record verification, and a pointer to aimx logs. Exits non-zero when any mailbox has an unresolvable owner so monitoring can detect orphans.

The Service section also prints Client version: (the CLI binary you just invoked) and Server version: (probed from the running daemon). When they differ, the on-disk binary is newer than the daemon — restart the service so it picks up the new build. See Troubleshooting: Version drift.

No flags.

aimx logs

Tail or follow the aimx serve service log. Wraps journalctl -u aimx on systemd and /var/log/aimx/*.log / /var/log/messages on OpenRC.

FlagDefaultDescription
-n, --lines <N>50Number of trailing lines to show.
-f, --followoffStream new lines as they arrive (like journalctl -f).

Mail operations

aimx send

Compose a message and submit it to aimx serve via /run/aimx/aimx.sock. Refuses root. The daemon handles Markdown rendering, DKIM signing, and MX delivery.

--body is interpreted as Markdown (CommonMark + GFM extensions: tables, strikethrough, autolinks, task lists, footnotes). The daemon renders it to HTML with an inlined stylesheet and ships a multipart/alternative message — the recipient sees rich text on Gmail / Outlook / Apple Mail and the Markdown source on text-only clients. Two escape hatches are available:

  • --text-only — ship the body verbatim as text/plain. No rendering. Use for OTPs, transactional one-liners, and existing scripts that must not change shape.
  • --html-body <html> — supply a custom HTML template for the text/html part. AIMX uses your HTML verbatim and uses --body as the text/plain fallback. Mutually exclusive with --text-only.

For an in-depth tour of the rendering pipeline and the inlined stylesheet, see Markdown Email.

The caller’s euid must own the mailbox resolved from the From: local part; sends from another owner’s mailbox are rejected with not authorized: <local_part>@<domain>. The catchall (*@domain) is inbound-only and is never accepted as an outbound sender. See Security: Per-action authorization.

FlagDescription
--from <addr>Sender address. Must resolve to an explicitly configured (non-wildcard) mailbox owned by the caller.
--to <addr>Recipient address.
--subject <text>Subject line.
--body <text>Body content. Interpreted as Markdown by default. With --text-only, shipped verbatim as text/plain. With --html-body, used as the text/plain fallback.
--text-onlyShip --body verbatim as text/plain. Skips Markdown rendering and the HTML alternative part. Mutually exclusive with --html-body.
--html-body <html>Custom HTML for the text/html part. Operator-supplied; bypasses the renderer. Use the shell pattern --html-body "$(cat template.html)" for templates that don’t fit on one command line. Mutually exclusive with --text-only.
--reply-to <msg-id>Sets the In-Reply-To header for threading.
--references <chain>Sets the full References header. Needed only for multi-step threads where In-Reply-To alone is insufficient.
--attachment <path>Attach a file. Repeatable. With Markdown / --html-body, attachments wrap the alternative part in a multipart/mixed.

Examples:

# Default: Markdown body → recipient sees rendered HTML inline.
aimx send --from alice@example.com --to bob@example.com \
  --subject "Daily briefing" \
  --body "# Daily briefing\n\n- item one\n- item two"

# Plain-text only (e.g. OTPs, scripts that must not change shape).
aimx send --from alice@example.com --to bob@example.com \
  --subject "Verification code" --body "Your code: 184293" --text-only

# Custom branded HTML layout — operator owns the rendering.
aimx send --from alice@example.com --to bob@example.com \
  --subject "Newsletter" --body "Plain-text fallback for text-only clients." \
  --html-body "$(cat newsletter.html)"

# Markdown body + attachment.
aimx send --from alice@example.com --to bob@example.com \
  --subject "Q4 report" --body "See attached PDF." --attachment ./report.pdf

If both --text-only and --html-body are supplied, clap rejects the invocation before any UDS round-trip. The same canonical error fires server-side on the MCP email_send / email_reply tools so operators see one consistent message regardless of the surface.

The sent record under sent/<mailbox>/ always stores the Markdown source (or, for --text-only, the literal text — or for --html-body, the --body text part). The recipient’s HTML view is recoverable by re-running the same renderer; AIMX does not duplicate the rendered HTML alongside the source. See Markdown Email: Sent storage.

See Mailboxes: Sending email.

aimx ingest <rcpt>

Read a raw .eml message from stdin, parse it, and write the Markdown frontmatter file to the mailbox that routes for <rcpt>. Called in-process by aimx serve; available as a CLI for manual ingestion and testing.

aimx ingest catchall@agent.yourdomain.com < message.eml

Mailbox management

Alias: aimx mailbox works identically to aimx mailboxes.

aimx mailboxes create <name>

Register <name>@<domain> and create inbox/<name>/ and sent/<name>/ chowned <owner>:<owner> 0700. Owner-gated, not root-gated: non-root callers create mailboxes owned by their own uid, root may pass --owner <user> to create one owned by another uid. The reserved literals catchall and aimx-catchall are rejected.

When aimx serve is running, the change hot-reloads with no restart. When the daemon is stopped, root falls back to a direct config.toml edit; non-root exits with code 2. See Troubleshooting: daemon must be running.

FlagDescription
--owner <user>Linux user that should own the mailbox’s storage and run hooks. Honored only when run as root. Non-root callers passing --owner <other> get a soft warning to stderr (--owner ignored for non-root callers; mailbox will be owned by <caller>) and the daemon synthesizes the correct owner from SO_PEERCRED. Under root, the CLI prompts when omitted (default <name> if such a user exists). The user must resolve via getpwnam(3) on this host.

aimx mailboxes list

List mailboxes you own. Prints addresses, total count, and unread count. Non-root callers see only mailboxes whose owner uid matches their euid; the catchall is filtered out unless the caller is root or aimx-catchall.

FlagDescription
--allRoot only. List every mailbox regardless of owner. Non-root callers passing --all get --all requires root.

aimx mailboxes show <name>

Print a mailbox’s address, owner, effective trust policy, trusted_senders, configured hooks grouped by event, and inbox / sent / unread counts. Non-root callers may only inspect mailboxes they own.

aimx mailboxes delete <name>

Delete a mailbox. Owner-gated: non-root callers may only delete mailboxes they own. Refuses non-empty mailboxes with ERR NONEMPTY unless --force is passed. catchall cannot be deleted with or without --force.

FlagDescription
-y, --yesSkip the confirmation prompt.
--forceRecursively wipe inbox/<name>/ and sent/<name>/ before deleting. Daemon-side wipe runs under per-mailbox lock + CONFIG_WRITE_LOCK, so the wipe and the config rewrite are atomic together. Prompts before wiping unless paired with --yes. Refuses catchall.

See Mailboxes: Managing mailboxes.

Hook management

Alias: aimx hook works identically to aimx hooks. Authorization: caller must own the target mailbox, or be root. When aimx serve is running, hook CRUD hot-swaps into the live config with no restart. See Security: Per-action authorization.

aimx hooks list

List hooks. Non-root callers see only hooks on mailboxes they own. Prints a table of NAME, MAILBOX, EVENT, CMD. Anonymous hooks (those without an explicit name =) appear under their derived 12-char hex name.

FlagDescription
--mailbox <name>Filter to one mailbox you own.
--allRoot only. List hooks on every mailbox.

aimx hooks create

Create a hook. --cmd takes the argv as a JSON array string; cmd[0] must be an absolute path.

FlagDescription
--mailbox <name>Owning mailbox. Must already exist and be owned by the caller (or caller is root).
--event <event>on_receive or after_send.
--cmd <json-array>Argv exec’d directly when the hook fires. Required. JSON array string with cmd[0] as an absolute path. No shell wrapping — wrap in ["/bin/sh", "-c", "..."] explicitly when you need shell expansion.
--timeout-secs <N>Hard subprocess timeout. Default 60, range [1, 600]. SIGTERM at the limit, SIGKILL 5s later.
--fire-on-untrustedFire even when trusted != "true". Only valid on --event on_receive. Rejected on --event after_send.
--name <name>Optional. Matches ^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$. Must be globally unique across all mailboxes. When omitted, a derived 12-char hex name is used.

The raw .md (frontmatter + body) is always piped to the hook’s stdin and the same path is also exposed as $AIMX_FILEPATH. If your hook only needs the subject or sender, read $AIMX_SUBJECT / $AIMX_FROM and ignore stdin — the daemon writes the full email but does not require the child to consume it.

Example (as the mailbox owner — no sudo needed when the daemon is running):

aimx hooks create \
  --mailbox accounts \
  --event on_receive \
  --cmd '["/usr/local/bin/claude", "-p", "Read the piped email and act on it.", "--dangerously-skip-permissions"]' \
  --name accounts_claude

See Hook Recipes for verified per-agent invocations.

aimx hooks delete <name>

Delete a hook by name. Works for both explicit and derived names (as shown in aimx hooks list). Authorization is the same as create: caller must own the hook’s mailbox, or be root.

FlagDescription
-y, --yesSkip the confirmation prompt.

See Hooks & Trust.

Agent integration

aimx mcp

Start the MCP server in stdio mode. Launched on-demand by MCP clients, not a background service.

No flags. See MCP Server.

aimx agents setup [agent]

Install the AIMX skill for a supported agent into the current user’s config directory and (for Claude Code and Codex CLI) auto-register the AIMX MCP server via claude mcp add / codex mcp add. Refuses to run as root. Run with no arguments to launch the interactive checkbox TUI; pass --list (or call aimx agents list) to print the supported-agent registry and exit without installing.

The skill bundle teaches the agent how to call AIMX’s MCP tools and includes a “Wiring yourself up as a mailbox hook” section with the verified cmd argv to use with aimx hooks create.

When installing for claude-code, the installer also removes any pre-existing ~/.claude/plugins/aimx/ from the older plugin layout so the new skills install isn’t shadowed.

FlagDescription
<agent> (positional)Short name (e.g. claude-code, codex, opencode, gemini, goose, openclaw, hermes). Omit for the interactive TUI.
--listPrint the registry: agent name, destination path, activation hint. Same output as aimx agents list.
--no-interactiveSkip the TUI when no agent is named; print the same plain registry dump. Intended for scripting.
--dangerously-allow-rootBypass the root-refusal check and wire AIMX into /root’s home. Prefer sudo -u <user> aimx agents setup on any machine with a regular user.
--forceOverwrite existing destination files without prompting.
--printPrint the skill contents and activation hint to stdout instead of writing to disk or invoking any MCP CLI.

See Agent Integration for per-agent activation steps.

aimx agents list

Print the supported-agent registry as a plain table (agent name, destination path, activation hint).

aimx agents remove <agent>

Inverse of aimx agents setup. Removes the skill files under $HOME and prints an agent-specific cleanup hint pointing at any external command you still need to run (for example claude mcp remove aimx). Refuses to run as root.

FlagDescription
<agent> (positional)Short name; must match the agent previously passed to aimx agents setup.
--dangerously-allow-rootBypass the root-refusal check.

Utilities

aimx dkim-keygen

Generate a 2048-bit RSA DKIM keypair under /etc/aimx/dkim/ (private 0600, public 0644). Normally run automatically by aimx setup; use directly for key rotation.

FlagDefaultDescription
--selector <name>aimxDKIM selector name (controls the DNS record <selector>._domainkey.<domain>).
--forceoffOverwrite existing keys.

UDS protocol verbs

aimx serve exposes a small AIMX/1 request set on /run/aimx/aimx.sock (mode 0666). The CLI subcommands above and the aimx mcp tools all submit these verbs; the daemon resolves the caller’s uid via SO_PEERCRED and runs auth::authorize server-side. Operators do not normally speak the wire format directly.

VerbDirectionAuthorizationUsed by
SENDrequest + body (RFC 5322 message)caller uid must own the mailbox resolved from From:aimx send, email_send / email_reply MCP tools
MARK-READ / MARK-UNREADheader (mailbox + path)caller uid must own the mailboxemail_mark_read / email_mark_unread MCP tools
MAILBOX-CREATEheader + JSON bodycaller uid synthesized as owner from SO_PEERCRED (root may pass Owner: for cross-uid creates)aimx mailboxes create, mailbox_create MCP tool
MAILBOX-DELETEheader (mailbox)caller uid must own the mailboxaimx mailboxes delete (incl. --force), mailbox_delete MCP tool
MAILBOX-LISTrequest onlynone server-side; the response is filtered to caller-owned rows (root sees all)aimx mailboxes list, mailbox_list MCP tool, every other MCP tool’s resolution pre-flight
HOOK-CREATEheader + JSON bodycaller uid must own the hook’s mailboxaimx hooks create (UDS path), hook_create MCP tool
HOOK-DELETEheader (hook name)caller uid must own the hook’s mailbox; operator-origin hooks are CLI-onlyaimx hooks delete (UDS path), hook_delete MCP tool
HOOK-LISTrequest onlynone server-side; the response is filtered to hooks on caller-owned mailboxes (root sees all)hook_list MCP tool
VERSIONrequest onlynone — payload is daemon build metadata onlyaimx doctor’s Server version: line

Troubleshooting

Diagnostic commands and fixes for common failure modes.

Diagnostic commands

# Check port 25 connectivity (outbound + inbound EHLO handshake)
# Requires root
sudo aimx portcheck

# Show server health, configuration, mailbox counts, and a tail of the service log
aimx doctor

# Stream the service log on its own (last 50 lines by default)
aimx logs
aimx logs --lines 200
aimx logs --follow

# Test against a self-hosted verify service instead of the default
sudo aimx portcheck --verify-host https://verify.yourdomain.com

The --verify-host flag is also accepted by aimx setup, and overrides the verify_host value from config.toml for the current invocation.

Common issues

ProblemCauseFix
Verify: outbound port 25 blockedVPS provider blocks SMTPSwitch providers or request unblock with your provider
Verify: inbound port 25 not reachableFirewall or VPS blocks inboundsudo ufw allow 25/tcp, check VPS firewall settings
DNS records not resolvingPropagation delayWait (up to 48h), re-check with dig (see verifying DNS)
sudo aimx portcheck times outDNS not propagated or verify service downRun again later; check curl https://check.aimx.email/health
Emails landing in spamMissing DNS records, bad reverse DNS, or receiver spam filterAdd all DNS records, configure a PTR record at your VPS provider, use a Gmail filter
aimx serve not runningService crashed or not startedCheck status and logs (see below)
Emails not delivered to mailboxaimx serve not running or misconfiguredCheck service status with systemctl status aimx
Hooks not firingTrust gateon_receive hooks fire iff trusted == "true" OR the hook sets fire_on_untrusted = true. Check trust / trusted_senders and the email’s DKIM result. See trust gate.
DKIM verification failingDNS record mismatch or key regeneratedEnsure DKIM DNS record matches current public key

Reset

aimx setup is idempotent: re-running it preserves the prior trust policy and skips STARTTLS / install steps once in place. The wizard generates the DKIM keypair early (step 2), so a hard reset means clearing more than just config.toml.

To wipe a partially-installed host and start clean:

# Stop the daemon if it's running.
sudo systemctl stop aimx 2>/dev/null || sudo rc-service aimx stop 2>/dev/null || true

# Remove config + DKIM keys + (optionally) the self-signed STARTTLS cert.
sudo rm -rf /etc/aimx/config.toml /etc/aimx/dkim/
sudo rm -rf /etc/ssl/aimx/   # only if you want a fresh STARTTLS cert

# Re-run the wizard.
sudo aimx setup

Mailbox data under /var/lib/aimx/ is preserved across re-runs by design — delete it explicitly if you want to start with empty inbox/ and sent/ trees as well. Aborting the wizard at the trust prompt leaves the DKIM keypair on disk; the next sudo aimx setup picks up where you left off, so wiping is only needed when you actually want a fresh DKIM key (e.g. after publishing the wrong public key to DNS).

aimx serve diagnostics

# Check if aimx serve is running
sudo systemctl status aimx

# View recent aimx serve logs
journalctl -u aimx -e

# Restart the service
sudo systemctl restart aimx

# Clear a rate-limited service after repeated crashes
# (the unit caps restarts at StartLimitBurst=5 within StartLimitIntervalSec=60s)
sudo systemctl reset-failed aimx

If systemctl status aimx reports start-limit-hit, the service has restarted too often in a short window. Run sudo systemctl reset-failed aimx to clear the counter, then sudo systemctl start aimx to try again. Investigate the underlying crash in journalctl -u aimx -e before restarting.

On Alpine Linux (OpenRC):

# Check service status
rc-service aimx status

# View recent logs (OpenRC logs to the supervise-daemon log output)
less /var/log/messages

# Restart
rc-service aimx restart

Logs

AIMX does not write its own log files. Output from aimx serve goes to stdout/stderr and is captured by the init system. aimx logs wraps the right tool for the running init:

# Tail the last 50 lines (default)
aimx logs

# Tail a custom number of lines
aimx logs --lines 200

# Follow new lines as they arrive (Ctrl-C to stop)
aimx logs --follow

aimx doctor prints a Logs pointer section at the bottom of its output that reminds you to run aimx logs (or aimx logs --follow) rather than dumping log lines itself.

Version drift between client and daemon

aimx doctor renders two version lines under the Service section:

Client version:   v1.2.4 (a1b2c3d4)
Server version:   v1.2.3 (9e8f7d6c)

The Client line reports the build of the aimx binary you just invoked. The Server line reports what the running aimx serve daemon advertises over the UDS VERSION verb. They drift apart when an upgrade replaces the on-disk binary but does not restart the long-running daemon — typically a curl | sh re-install on a host where systemd is present-but-inactive, or a manually-launched aimx serve outside the service manager.

The lines are informational only — aimx doctor does not flag a finding and does not change its exit code. To resolve drift, restart the service so the daemon picks up the new binary:

sudo systemctl restart aimx
# or, on OpenRC:
sudo rc-service aimx restart

If the Server line renders (daemon not running) the daemon is offline; start it with sudo systemctl start aimx. A (<reason>) placeholder means the socket exists but the probe failed within the 500 ms budget — check aimx logs for the daemon-side error.

systemd (Ubuntu, Fedora, Debian, etc.)

The systemd unit declares StandardOutput=journal and StandardError=journal, so all daemon output is routed to journald. aimx logs shells out to journalctl -u aimx -n <N> (and journalctl -f -u aimx with --follow). You can also call journalctl directly:

# Follow logs in real time
journalctl -u aimx -f

# Show today's logs
journalctl -u aimx --since today

# Show last 200 lines
journalctl -u aimx -n 200

OpenRC (Alpine)

The generated OpenRC init script uses supervise-daemon, which routes daemon output to the system logger (typically /var/log/messages or syslog). Check your OpenRC logging configuration for the exact destination. aimx logs makes a best-effort read of /var/log/aimx/*.log and falls back to /var/log/messages. aimx logs --follow is unsupported on OpenRC and will direct you to tail your syslog file directly.

DKIM/SPF debugging

Check DKIM DNS record

dig +short TXT aimx._domainkey.agent.yourdomain.com

The output should contain v=DKIM1; k=rsa; p=... matching your public key.

To see the current public key:

cat /etc/aimx/dkim/public.key

Check SPF record

dig +short TXT agent.yourdomain.com

Should include v=spf1 ip4:YOUR_SERVER_IP -all.

Check DMARC record

dig +short TXT _dmarc.agent.yourdomain.com

Should include v=DMARC1; p=reject.

Verify email authentication results

Read an email’s frontmatter to check inbound verification results:

head -20 /var/lib/aimx/inbox/catchall/*.md

Look at the dkim and spf fields. They should show pass for properly authenticated senders.

Hooks and ownership

Mailbox owner does not exist on the host

Symptom: aimx doctor Ownership section flags a mailbox with [FAIL] user not found for its owner, and hook fires for that mailbox are soft-skipped with a WARN carrying reason = "owner-not-found".

Fix: the mailbox’s owner = value points at a Linux user that does not resolve via getpwnam(3) on this host (typo in config.toml, or the user was removed via userdel). Either create the missing user (sudo useradd --system --no-create-home --shell /usr/sbin/nologin <name>) and fix up mailbox directory ownership manually:

sudo chown -R <owner>:<owner> /var/lib/aimx/inbox/<mailbox> /var/lib/aimx/sent/<mailbox>
sudo chmod -R u+rwX,go-rwx /var/lib/aimx/inbox/<mailbox> /var/lib/aimx/sent/<mailbox>

Or re-assign the mailbox to a user that does exist by hand-editing [mailboxes.<name>] in /etc/aimx/config.toml and sudo systemctl reload aimx. Doctor’s overall exit code is non-zero whenever any mailbox has an unresolvable owner so monitoring can detect orphans.

Hook on catchall is forbidden

Symptom: Config::load fails on daemon startup with catchall does not support hooks, or aimx hooks create --mailbox catchall returns EACCES catchall does not support hooks.

Fix: aimx-catchall has no shell and no resolvable login uid that setuid can drop into, so hooks on the catchall mailbox have no safe owner to execute as. Move the hook to a non-catchall mailbox owned by a regular user, or — if the goal is “notify on every inbound mail” — create a separate non-catchall mailbox (sudo aimx mailboxes create notify --owner ubuntu) and attach the hook there.

fire_on_untrusted rejected on after_send

Symptom: Config::load fails on daemon startup with fire_on_untrusted is on_receive only, or aimx hooks create --event after_send --fire-on-untrusted is rejected.

Fix: fire_on_untrusted is the trust-gate escape hatch for on_receive hooks (which fire only on trusted mail by default). It has no meaning on after_send because there is no trust gate on outbound delivery. Remove the flag from any after_send hook entry.

aimx mailboxes create / delete exits with daemon must be running for non-root mailbox CRUD

Symptom: a non-root call to aimx mailboxes create or aimx mailboxes delete exits with code 2 and the message “daemon must be running for non-root mailbox CRUD; start aimx serve or run with sudo to fall back to direct config edit.”

Fix: mailbox CRUD is owner-gated, not root-gated, but the non-root path requires the daemon. The CLI cannot fall back to a direct config.toml edit when run as a regular user — /etc/aimx/config.toml is 0640 root:root and the rename would fail with a confusing perm error, so the CLI fails fast instead. Pick one of the two remediations the error names:

  • Start the daemon: sudo systemctl start aimx (or sudo rc-service aimx start on OpenRC). Then re-run aimx mailboxes create <name> as yourself.
  • Run with sudo: sudo aimx mailboxes create <name> keeps the existing direct-write fallback path.

MAILBOX-CREATE / MAILBOX-DELETE rejected with EACCES not authorized

Symptom: a MAILBOX-CREATE or MAILBOX-DELETE UDS request returns EACCES not authorized, or aimx mailboxes delete <name> fails with a not-authorized error from the daemon.

Fix: the caller’s uid does not own the target mailbox. Mailbox CRUD is owner-gated — for non-root callers, the daemon enforces that the caller’s uid (resolved via SO_PEERCRED) matches the mailbox’s owner field on MAILBOX-DELETE, and synthesizes the new mailbox’s owner from SO_PEERCRED on MAILBOX-CREATE (any client-supplied Owner: header from a non-root caller is ignored). To delete a mailbox owned by another uid, run the command as that user (sudo -u <owner> aimx mailboxes delete <name>) or as root. To create a mailbox owned by a different user, run as root and pass --owner <user>. The mailbox_create / mailbox_delete MCP tools follow the same rules — agents can only CRUD mailboxes owned by the uid the MCP server runs under.

aimx send returns not authorized: <local_part>@<domain>

Symptom: aimx send --from alice@agent.yourdomain.com ... exits 1 with not authorized: alice@agent.yourdomain.com even though the mailbox exists.

Fix: aimx send validates the From: local part against the caller’s owned mailboxes. The mailbox owner (the Linux user named in [mailboxes.<name>] owner =) is the only non-root caller authorized to send as that address. Run aimx send as the mailbox’s owner (sudo -u <owner> aimx send ... if you’re already root), or re-assign ownership in config.toml. aimx send refuses uid 0 — root cannot run it.

Hook reads “Permission denied” on stdin

Symptom: hook logs show exit_code != 0 and stderr tail like cat: '/var/lib/aimx/inbox/...': Permission denied.

Fix: the running subprocess is not the mailbox owner. Each mailbox directory is <owner>:<owner> 0700, so only the owner (and root) can read the piped email content. The daemon setuids to mailbox.owner_uid() before exec, so a fresh hook should always run with the right uid; mismatches usually mean someone hand-edited config.toml and the on-disk perms drifted. Re-chown to match:

sudo chown -R <owner>:<owner> /var/lib/aimx/inbox/<mailbox> /var/lib/aimx/sent/<mailbox>
sudo chmod -R u+rwX,go-rwx /var/lib/aimx/inbox/<mailbox> /var/lib/aimx/sent/<mailbox>

SIGHUP reload failed

Symptom: editing config.toml and sudo systemctl reload aimx reports success but the new hook never fires. journalctl shows a config reloaded with error warn line.

Fix: the new config.toml failed validation. Common culprits: a stdin line on a hook (the field was removed; Config::load now refuses any value with hook '<name>' carries removed field 'stdin' — remove this line and restart aimx serve; the email is always piped to hooks); legacy template, params, run_as, origin, or dangerously_support_untrusted fields on a hook (all rejected at config load with a pointer to book/hooks.md); duplicate hook name across mailboxes; cmd[0] not an absolute path; fire_on_untrusted = true on an after_send hook. Check the log:

journalctl -u aimx --since="5 minutes ago" | grep -i reload

Fix the offending field in /etc/aimx/config.toml, then sudo systemctl reload aimx.

Hook’s cmd[0] binary not found

Symptom: hook fires log exit_code = -1 with spawn-failed kind.

Fix: the absolute path written into the hook’s cmd[0] does not exist on the host. Run which <agent> as the mailbox owner to confirm the right path, then delete and re-create the hook with the corrected cmd[0]:

aimx hooks delete <name> --yes
aimx hooks create --mailbox <m> --event on_receive --cmd '["/correct/path/to/agent", "..."]' --name <name>

Spam prevention

If outbound emails land in spam:

  1. Check all DNS records. DKIM, SPF, and DMARC must all be set correctly. See DNS configuration.
  2. Configure reverse DNS (PTR) at your VPS provider’s control panel so the PTR for your server IP points to your mail domain. This is the operator’s responsibility and is out of scope for AIMX, but is critical for deliverability with Gmail/Outlook.
  3. Gmail filter workaround. In Gmail: Settings > Filters > Create filter for *@agent.yourdomain.com > Never send to Spam.
  4. Reply trick. Reply to one email from the domain. Gmail learns it’s not spam.

File permissions

Verify the DKIM private key has correct permissions:

ls -la /etc/aimx/dkim/private.key
# Should show: -rw------- (mode 0600)

If permissions are wrong:

sudo chmod 600 /etc/aimx/dkim/private.key

How portcheck works

aimx portcheck requires root and auto-detects what is listening on port 25:

ScenarioWhat happens
aimx serve runningOutbound EHLO + inbound EHLO probe
Other process on port 25 (Postfix, Exim, etc.)Fails. Advises to stop the conflicting process
Nothing on port 25 (fresh VPS)Spawns temporary SMTP listener, then runs outbound + inbound EHLO checks

If portcheck fails with EHLO probe after setup, the issue is likely in the aimx serve configuration rather than firewall/port access. Run sudo systemctl status aimx to check.

Before installing AIMX, you can run the same connectivity probe pre-install (no install side effects):

curl -fsSL https://aimx.email/portcheck.sh | sudo sh

portcheck.sh is a thin alias for install.sh --port-check-only; both URLs run the same checks. See Getting Started: Pre-install check.

Useful commands

CommandPurpose
sudo aimx portcheckCheck port 25 connectivity (root)
aimx doctorServer health, mailbox counts, DNS verification
aimx logs [--lines N] [--follow]Tail or follow the service log
aimx mailboxes listList mailboxes
aimx dkim-keygen [--force]Generate or rotate DKIM keypair
sudo systemctl status aimxCheck the daemon’s service state
dig +short TXT agent.yourdomain.comInspect DNS records
cat /etc/aimx/dkim/public.keyShow the DKIM public key

See CLI Reference for every subcommand and flag.

FAQ

Common questions

Why does AIMX need port 25 open?

AIMX is your mail server. SMTP runs on port 25. It has been defined and set in RFC 821 since 1982. AIMX speaks SMTP directly to other mail servers, no third-party relays involved. Your mail stays truly private and secure.

Can I run AIMX on my home server?

Usually not. Home ISPs typically block port 25. To check if port 25 is open without installing AIMX, run curl -fsSL https://aimx.email/portcheck.sh | sh.

Can I switch AIMX to operate on another port?

No. SMTP is strictly port 25 as defined and set in RFC 821 since 1982. Other mail servers will only deliver to you on 25.

What AI models does AIMX support?

All of them. AIMX does not call AI models directly. It is a mail server with a stdio Model Context Protocol (MCP) server built-in, so your AI harnesses and agents can connect to it easily and effectively.

How do I set up email accounts on AIMX?

Once AIMX is set up, mailboxes can be created with the aimx mailboxes CLI, or via the mailbox_create MCP tool by simply instructing your AI harness in plain natural language, such as Create a receipt@ mailbox and file receipts for me when you receive them.

Can I run AIMX without owning a domain name?

No. You need a domain to define how emails are delivered to you (MX record) and verified (DKIM TXT record). Email specifications (RFC 5321 §5.1) require the MX record to point to a domain name, not an IP.

Why do I need AIMX when I can just use Gmail + MCP?

You can, if you do not mind your emails being stored and accessible on both Gmail servers AND third-party MCP servers. If you are on a free Gmail account, you might also be violating Gmail’s ToS. Besides, it is a lot of work to create multiple mailboxes for separate agentic use.

Why do I need AIMX when I can just use MCP-enabled AgentMail or LobsterMail?

You can, if you do not mind paying and do not mind your emails being stored and accessible on third-party servers.

How are emails stored on AIMX?

AIMX stores all incoming and outgoing emails as Markdown files with TOML frontmatter. This makes them trivially easy for AI agents, RAG pipelines, and LLMs to read and parse, no MIME decoding required. Attachments are extracted and stored on disk in native format.

Does AIMX have any automation? How does AIMX prevent prompt injection from incoming emails?

Yes, AIMX supports hooks that fire on incoming mail, but only from senders you trust. You define the trusted sender list. AIMX verifies every incoming message with DKIM and records the result in the frontmatter, so your agent always knows whether a message is authenticated. Mail that fails DKIM, or arrives from an unverified sender, will not trigger any hooks.

Deployment

What about ports 465 and 587?

Ports 465 and 587 are submission ports — used by mail clients to hand a message to a relay. AIMX is the MTA, not a client of one, so submission ports do not apply. Mail goes straight from AIMX to the recipient’s MX on port 25.

Can I run AIMX in Docker or behind NAT?

Docker works if you map port 25 and persist /etc/aimx, /var/lib/aimx, and /run/aimx on the host. Behind NAT you must port-forward 25/tcp both ways and the MX record must resolve to the public IP. AIMX learns the sender IP from the TCP peer, so any proxy in front of port 25 has to be transparent (PROXY protocol is not supported).

Can I run two AIMX instances on one host?

Only if each binds a different IP on port 25. Two listeners cannot share the same ip:25. Point each instance at its own AIMX_CONFIG_DIR and AIMX_DATA_DIR, run each from its own systemd unit, and give each its own UDS path (the default /run/aimx/aimx.sock is hard-coded today. A second instance needs a source patch).

How do I upgrade the binary without losing mail or breaking in-flight SMTP sessions?

Replace /usr/local/bin/aimx and systemctl restart aimx. aimx serve handles SIGTERM by draining both the SMTP and UDS accept loops, so in-flight sessions finish before the process exits. Mail on disk is format-stable; no migration step is required between patch releases.

How do I migrate to a new server or change the domain?

Same domain, new server: rsync -a /etc/aimx/ /var/lib/aimx/ to the new host, install the binary, sudo aimx setup <domain> (re-entrant, it reuses the existing DKIM key), then flip the A/MX record. Different domain: run a fresh aimx setup. The DKIM selector, SPF, and DMARC records all reference the domain and must be regenerated.

DNS and deliverability

What is PTR record? Do I actually need it?

PTR (Pointer Record) is a reverse-DNS record. It maps an IP back to a hostname, the opposite of an A/AAAA record. Setting one improves outbound deliverability and is usually configured at your hosting provider’s control panel rather than at your normal DNS registrar. Because AIMX is not meant for bulk sending, a PTR is optional. If you are only mailing a handful of targeted recipients (often yourself), having DKIM/SPF/DMARC pass, and if needed whitelisting the sender in your mail client, is usually enough.

How do I rotate the DKIM key without a delivery gap?

AIMX today supports one active selector at a time. To rotate without bounces:

  1. sudo aimx dkim-keygen --selector aimx2 (generates a new keypair under a second selector).
  2. Publish the new TXT record at aimx2._domainkey.<domain>, wait for propagation.
  3. Flip dkim_selector = "aimx2" in config.toml and systemctl restart aimx.
  4. Leave the old DNS record up for a few days so in-flight mail still verifies, then remove it.

Enabling enable_ipv6: what exactly changes?

Outbound delivery starts preferring AAAA records when the recipient publishes them. You need to (a) add an AAAA record for your MX hostname and (b) extend SPF with ip6:<your /64 or full v6>. If you leave SPF at the default ip4:YOUR_IP -all, every v6-delivered message will SPF-fail.

Sending

Can I send from *@domain (the catchall)?

No. The catchall is inbound-only. Outbound From must resolve to a concrete, non-wildcard mailbox in config.toml. The daemon parses the submitted From: header itself and rejects catchalls.

What happens on a deferred or failed MX delivery?

AIMX does not run a retry queue. A transient (4xx) failure returns Deferred to the client and is not persisted. The client (e.g. aimx send, an agent) is expected to retry. A permanent (5xx) failure is persisted to sent/<mailbox>/ with delivery_status = "failed" and the SMTP reason in delivery_details. AIMX does not generate DSNs. This keeps the delivery result visible to the calling agent in real time. No send-and-pray.

Can I send with attachments, a custom Reply-To, or a custom Message-Id?

Attachments: yes, repeat --attachment <path>. Custom Reply-To: header: not exposed on the CLI (the --reply-to flag sets In-Reply-To for threading, not the Reply-To header). Custom Message-Id: not exposed. The daemon generates one per send.

Storage

Is the mailbox tree safe to rsync or snapshot while aimx serve is running?

Yes for reads. rsync -a or a filesystem snapshot of /var/lib/aimx/ will produce a consistent per-file copy. Inbound ingest writes each .md atomically (temp file + rename) and mark-read rewrites are serialised under a per-mailbox lock. A snapshot taken mid-ingest may miss the newest message, never a half-written one.

How is thread_id computed, and will threading agree with Gmail?

thread_id is sha256(root)[..8] in hex, where root is the first Message-Id in In-Reply-To, else the first in References, else the email’s own Message-Id. This walks the same header chain Gmail uses, so replies thread correctly in both. Subject-based collapsing (Gmail’s fallback) is not replicated. If a conversation loses its References chain, the two systems can disagree.

Hooks

My on_receive hook didn’t fire. How do I tell why?

Check in this order:

  1. journalctl -u aimx | grep hook_name=<name>. Every fire emits one structured line. No line means the hook was gated.
  2. The target email’s frontmatter: trusted = "false" plus fire_on_untrusted unset is the most common cause. See the trust gate.
  3. If the line is there with a non-zero exit_code, it’s your cmd argv. Test the argv manually: sudo -u <owner> /path/to/cmd[0] cmd[1] ... against the saved .md.

What does mailbox ownership mean for security?

Every mailbox declares a single Linux owner. Storage is chowned <owner>:<owner> 0700. Hooks always exec as the owner uid (the daemon setuids before exec). CRUD over the UDS is gated on SO_PEERCRED matching the mailbox’s owner_uid, or root. A hook can do anything the owner could already do as that user — no more, no less. To run a hook as root, set mailbox.owner = "root" in /etc/aimx/config.toml (which already requires root). See Security: Per-action authorization.

Env var expansion: how does it work?

Hook cmd is exec’d directly — there is no shell. argv elements pass through verbatim. To get shell expansion of $AIMX_* env vars, wrap your cmd in ["/bin/sh", "-c", "..."] explicitly:

cmd = ["/bin/sh", "-c", 'echo "$AIMX_SUBJECT" >> /tmp/log']

Always expand env vars inside double quotes. Sender-controlled header values can contain $(), backticks, quotes, or newlines; the double-quoted form passes them through as literal bytes. The literal token $AIMX_FILEPATH (no shell wrapping) reaches argv unchanged — useful when the agent itself reads env vars (OpenCode, Hermes do this in inline-prompt mode).

Can an after_send hook distinguish a deferral from a permanent failure?

Yes. AIMX_SEND_STATUS is "delivered", "deferred", or "failed". Deferrals do not persist a sent file, so AIMX_FILEPATH is empty for them.

Trust

What does trust = "verified" actually check?

Two conditions: the sender address matches a glob in the effective trusted_senders list, AND the inbound DKIM result is pass. SPF and DMARC are recorded in frontmatter but are not part of the gate. Missing either of those two conditions yields trusted = "false".

Per-mailbox trusted_senders: does it merge with the global list?

It replaces. Setting trusted_senders under a mailbox fully overrides the top-level list for that mailbox. There is no merge, and an empty per-mailbox list means “nobody” for that mailbox.

When is fire_on_untrusted actually appropriate?

When the hook’s side effect is safe regardless of sender. A logger, a metric counter, a push notification with no email content in the payload. Never use it on a hook that hands the email body to an agent or to any shell command that quotes the body. Mailbox isolation (uid-scoped exec + uid-scoped storage) makes the flag a per-owner choice with bounded blast radius — even an adversarial fire on untrusted mail can do no more than what the mailbox owner could already do — but the trust gate is still the primary defense for irreversible side effects. The flag is illegal on after_send hooks and rejected at config load.

Security model

The full write-up lives at Security. The entries below cover the common questions.

Can I use AIMX in place of Postfix or Stalwart?

No. AIMX is a single-domain server for AI agents on a host you own, not a general-purpose MTA. It has no IMAP/POP3, no webmail, no SMTP AUTH, no LMTP, no virtual alias tables, and no submission port on 587. Each mailbox has exactly one Linux owner, and hooks always run as that owner — the boundary is per-mailbox, not per-server.

aimx.sock is mode 0666, why is that fine?

Any local user can connect, but the daemon authorizes every verb server-side via SO_PEERCRED (kernel-supplied peer uid). The DKIM private key never leaves the daemon. The socket is a signing oracle scoped to the caller’s owned mailboxes — never a free pass to forge mail under another mailbox.

The mailbox tree is per-owner, what does that buy me?

On a multi-user host, alice cannot read bob’s mail — inbox/<bob>/ is bob:bob 0700 and she cannot traverse the directory. Hooks on alice’s mailbox run as alice, so a prompt-injected agent stays scoped to alice’s filesystem perms.

Who can read the DKIM private key, and what happens if it leaks?

Only root, via /etc/aimx/dkim/private.key (mode 0600). A leak lets anyone sign mail as your domain until you rotate. Rotate with the selector swap above.

MCP

Can two agents share one aimx mcp process?

No. aimx mcp uses stdio transport. Each MCP client spawns and owns its own process. The filesystem is the shared resource. Concurrent MCP processes coordinate through the daemon (mark-read, mailbox CRUD) or through atomic file writes (ingest, send).

How do I scope an agent to a single mailbox?

Every MCP tool call is scoped to mailboxes the calling uid owns: mailbox_list filters; email_* and hook_* reject with EACCES not authorized for foreign mailboxes. To pin a single agent to a single mailbox, run that agent under a Linux user that owns only the one mailbox you want (sudo aimx mailboxes create <name> --owner <user>). The agent’s MCP server inherits the caller’s uid via stdio transport, so authorization derives entirely from “which Linux user is running aimx mcp.”

How do I update the installed agent plugin after upgrading AIMX?

Re-run aimx agents setup --force and re-select the agents you want to update from the picker. The plugin bundle is embedded in the binary at compile time, so the installed plugin is always in sync with the binary version. --force overwrites whatever is at the destination.

Operations

systemctl status aimx says start-limit-hit. What is it?

The unit caps restarts at StartLimitBurst=5 within StartLimitIntervalSec=60. sudo systemctl reset-failed aimx clears the counter. sudo systemctl start aimx retries. Investigate the crash in journalctl -u aimx -e first. A restart-loop is usually a config error the restart won’t fix.

Where do daemon logs go on OpenRC?

OpenRC does not have journald. AIMX writes nothing of its own. aimx logs tails /var/log/aimx/*.log if the init script redirects there, otherwise falls back to /var/log/messages. On systemd, aimx logs shells out to journalctl -u aimx.

How do I run a dry-run send without touching real MX servers?

Set AIMX_TEST_MAIL_DROP=/path/to/dir before starting aimx serve. Every outbound submission is written to that directory instead of delivered. See Configuration: Environment variables for the full set.

Verifier service

What is services/verifier?

A small companion service that exists purely to answer the question “is port 25 actually reachable from the public internet?”. aimx portcheck and aimx setup call it during setup. Nothing in the mail path depends on it. By default AIMX points at the hosted instance at check.aimx.email, so you do not need to run your own.

When would I self-host services/verifier/?

When you do not want your setup traffic to hit check.aimx.email, or when you are deploying AIMX in an air-gapped / regulated environment. The verifier is a small axum service plus a port-25 listener. See the verifier service README for the Docker Compose deploy. Point AIMX at it with verify_host in config.toml or --verify-host at the command line.

Ready to try AIMX?

One command, one box, one inbox.

Get started