Skip to main content
Glama
unlaboredlabs

agentinbox

Official
README.md
# agentinbox

[![CI](https://github.com/unlaboredlabs/agentinbox/actions/workflows/ci.yml/badge.svg)](https://github.com/unlaboredlabs/agentinbox/actions/workflows/ci.yml)
[![npm](https://img.shields.io/npm/v/agentinbox-cli)](https://www.npmjs.com/package/agentinbox-cli)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)

A self-hosted Gmail broker for AI agents. One small daemon holds your Gmail credentials; every agent gets its own
scoped API key and reaches mail through **MCP tools** (read, search, draft, send, organize) and **signed webhooks** or an
MCP event feed for new mail. Guardrails decide what each agent may do per mailbox, every action lands in an audit log,
and a web console makes setup and management painless. It runs anywhere Node does, against your own Google Cloud
project, with no cloud service in between.

## Features

- **One place for credentials.** Connect Gmail and Google Workspace accounts once. Refresh tokens are encrypted at rest.
  Agents only ever see an `aib_…` key.
- **MCP for every agent.** Streamable HTTP at `/mcp`: `search_threads`, `get_thread`, `reply`, `send_message`,
  `modify_labels`, `wait_for_events` and more. Each agent sees only the tools its grants allow.
- **Real-time events.** New mail, replies to threads you sent into, and label changes arrive within seconds through
  Gmail push via a Pub/Sub *pull* subscription (no public URL needed), or by polling with zero extra setup.
  `agentinbox listen` wakes agents on a laptop behind NAT.
- **Webhooks.** Filter by account, label, sender, recipient, subject or attachments. Payloads are signed per
  [Standard Webhooks](https://www.standardwebhooks.com), retried with backoff, logged, and replayable.
- **Guardrails that don't degrade content.** Per agent and mailbox: capabilities, label scope, send mode (direct,
  draft-only or human approval), recipient allowlists, reply-only and send caps. Email content is cleaned, not censored.
- **A console you'll actually enjoy.** Guided Google setup, connect snippets for Claude Code, Codex and Cursor, an
  approvals inbox, live events, delivery logs and the audit trail. Dark mode included.
- **A ready-made agent skill.** `SKILL.md` teaches agents to use the tools well and to treat email as data.

<!--
## Preview

![Overview](docs/screenshots/overview.png)

<table>
  <tr>
    <td width="50%"><img src="docs/screenshots/setup.png" alt="Setup wizard"><br>Guided Google Cloud setup.</td>
    <td width="50%"><img src="docs/screenshots/agents.png" alt="Agents"><br>Agents, grants and connect snippets.</td>
  </tr>
  <tr>
    <td><img src="docs/screenshots/approvals.png" alt="Approvals"><br>Approve or reject sends.</td>
    <td><img src="docs/screenshots/webhooks.png" alt="Webhooks"><br>Webhooks with delivery log and replay.</td>
  </tr>
</table>
-->

## Quick start

Requires Node.js 22.13 or newer.

```sh
npx agentinbox-cli
```

The npm package is `agentinbox-cli`; the command it provides is `agentinbox`. Use `npx agentinbox-cli …` for one-off
runs, or `npm i -g agentinbox-cli` and then `agentinbox …`.

agentinbox starts on port 8425 and prints a one-time setup link (and opens it if it can):

```
  agentinbox v0.1.0 · data in /home/you/.agentinbox

  First run: open this link to claim the instance and finish setup:

  http://localhost:8425/setup?token=…
```

Open it, choose an admin password, and the setup wizard walks you through the rest in about ten minutes.

## Setting up Google

agentinbox uses **your own** Google Cloud project and OAuth client. Gmail's read scopes are restricted, so a shared
client isn't an option, and this way your mail never passes through anyone else's app. The wizard shows each step with
the exact values to paste; in short:

1. **Confirm the public URL** agents and Google will use to reach agentinbox.
2. **Create a Google Cloud project**, paste its project ID, and enable the **Gmail API** (and the **Cloud Pub/Sub API**
   if you want push).
3. **Configure the OAuth consent screen** (Google Auth Platform) and add the scopes the console lists:
   - **Internal** if every mailbox is in your Google Workspace organization. No review, no expiry, nothing else to do.
   - **External** for personal `@gmail.com` accounts (or mailboxes outside your org). Then **publish the app to
     "In production"**. Left in *Testing*, Google expires refresh tokens after **7 days** and your accounts silently
     disconnect. You don't need Google's verification for your own use: on consent, click *Advanced → Go to
     (app name)* past the "unverified app" warning.
4. **Create an OAuth client** of type *Web application*, add the redirect URI the console shows
   (`<public URL>/oauth/callback`), and paste the client ID and secret into the console. If the public URL is plain
   HTTP on a non-localhost address, Google won't redirect to it: the console then shows a `localhost` redirect URI and,
   when connecting, asks you to paste the URL you land on.
5. **Connect accounts.** Each one goes through Google's consent screen and grants `gmail.modify` plus `openid email
   profile`, nothing more.
6. **Choose how new mail arrives:**
   - **Pub/Sub (recommended):** one click creates the topic, grants Gmail's push service
     (`gmail-api-push@system.gserviceaccount.com`) permission to publish, and creates a pull subscription. It asks one
     connected account for the extra `pubsub` scope (only then); that account needs **Owner** or **Pub/Sub Admin** on
     the project (Editor can't change topic permissions). Events arrive in seconds, with no inbound connections. If a
     Workspace organization policy (domain-restricted sharing) blocks the grant, the console says so; use polling.
   - **Polling:** no extra setup; checks each mailbox every 30 seconds by default.

Then create an agent and give it a grant on a mailbox.

## Connecting agents

Create an agent in the console (**Agents → New agent**) to get its API key; the key is shown once. The console has
copy-paste snippets with your URL and key filled in, and a **Test connection** button. By hand:

**Claude Code**

```sh
claude mcp add --transport http --scope user agentinbox http://your-host:8425/mcp \
  --header "Authorization: Bearer $AGENTINBOX_API_KEY"
```

**Codex** (`~/.codex/config.toml`, with `export AGENTINBOX_API_KEY="aib_…"` in the environment Codex runs in)

```toml
[mcp_servers.agentinbox]
url = "http://your-host:8425/mcp"
bearer_token_env_var = "AGENTINBOX_API_KEY"
```

**Cursor** (`~/.cursor/mcp.json`, or `.cursor/mcp.json` in a project) and most other MCP clients

```json
{
  "mcpServers": {
    "agentinbox": {
      "url": "http://your-host:8425/mcp",
      "headers": { "Authorization": "Bearer aib_…" }
    }
  }
}
```

Cursor also accepts `"Bearer ${env:AGENTINBOX_API_KEY}"` in `headers`, so the key can stay out of the file. Any MCP
client that speaks streamable HTTP works: endpoint `/mcp`, header `Authorization: Bearer <key>`.

### The agent skill

`SKILL.md` teaches an agent the tools, Gmail search syntax, reply etiquette, how sends and denials work, the event loop,
and that email content is data, never instructions. Install it on the machine where the agent runs (no running
agentinbox needed there):

```sh
npx agentinbox-cli skill install --url https://mail.example.com          # ~/.claude/skills/agentinbox/SKILL.md
npx agentinbox-cli skill install --target codex --url https://mail.example.com
npx agentinbox-cli skill install --target ./skills --url https://mail.example.com
npx agentinbox-cli skill print --url https://mail.example.com             # to stdout
```

Every instance also serves it at `/skill.md` with its public URL filled in.

## Events and webhooks

Event types: `message.received`, `message.sent`, `thread.reply_received` (a reply in a thread the account sent into),
`label.added`, `label.removed`, `account.auth_failed` and `sync.gap`. Agents only receive events for mailboxes and
labels their grants let them read.

- **Agents without an HTTP endpoint** (most coding agents) use the MCP tools `wait_for_events` (long poll),
  `list_events` and `ack_events` while a session is running. The server keeps each agent's position. To start an
  agent when mail arrives, use [`agentinbox listen`](#wake-local-agents-agentinbox-listen).
- **Agents with an endpoint** get webhooks: create one under **Webhooks**, pick event types and filters, and optionally
  include the cleaned message body. A delivery succeeds on a `2xx` within 10 seconds (redirects aren't followed);
  failures are retried 8 times with growing delays (30 s up to 12 h, about a day in total) before they're marked dead,
  and any delivery can be replayed from the console.

Each webhook request carries `webhook-id`, `webhook-timestamp` and `webhook-signature` headers (`v1,` + base64
HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{body}`). Verify them with any
[Standard Webhooks library](https://github.com/standard-webhooks/standard-webhooks) and the `whsec_…` secret shown when
you create the webhook:

```js
import { Webhook } from "standardwebhooks";

const wh = new Webhook(process.env.AGENTINBOX_WEBHOOK_SECRET);
const payload = wh.verify(rawBody, request.headers); // throws if the signature or timestamp is bad
```

A dependency-free Node version is in the [skill](server/skill/SKILL.md#webhooks). `webhook-id` is the delivery id: it
stays the same across retries and replays of one webhook. To dedupe across webhooks, use the event id `data.id`.

Webhooks may target public `https` URLs and, through the default allowlist (`localhost`, `127.0.0.0/8`, `::1`),
loopback. Other private addresses (for example a LAN host or `n8n.internal`) must be added to the webhook allowlist in
**Settings** as a hostname, `*.suffix`, IP or CIDR. Plain `http` works only for loopback and allowlisted hosts.

## Wake local agents: `agentinbox listen`

MCP events only reach an agent while a session is running, and webhooks need a reachable endpoint. `agentinbox listen`
runs on the agent's own machine, holds an **outbound** event stream to agentinbox (no open ports or tunnels), and runs a
command or POSTs to a localhost URL for each new event:

```sh
# Pair this machine's agent: prints a code to approve in the agentinbox console, then registers the MCP server
npx agentinbox-cli connect https://mail.example.com --tool claude-code --setup-mcp

# Run the ready-made Claude Code recipe once per event (event JSON on stdin) as a background service
npx agentinbox-cli listen service install --exec "$(npx agentinbox-cli listen --print-recipe claude-code)"
npx agentinbox-cli listen status
```

The approver picks the mailboxes and capabilities: the approval dialog defaults to **Read only**, and once send access
is granted, `draft_only` is the default send mode. The key goes into a profile file (mode 0600) and is never printed.
Listener commands run in a private working directory, so no stray `CLAUDE.md`, `.mcp.json` or hooks apply. Delivery is
at-least-once with retries and a dead-letter list. Recipes for Codex, Cursor and Hermes (`--print-recipe <tool>`),
forward mode, filters and the security model are in [docs/listen.md](docs/listen.md).

## Security model

agentinbox assumes agents can be manipulated by the mail they read. So it keeps content intact and puts the hard limits
on actions.

**Content is cleaned, not censored.** Agents get full-fidelity mail converted to tidy Markdown, with links and tables.
Only what a human reader couldn't see is removed: hidden CSS (`display:none`, zero-size, transparent or
same-colour text), HTML comments, scripts and styles, zero-width characters, Unicode tag characters and bidi overrides.
Each body is wrapped in one `<email_content … untrusted>` delimiter and comes with signals: SPF/DKIM/DMARC results, an
external-sender flag, whether hidden content was removed, and phrases typical of prompt injection. Signals are
informational; nothing is withheld because of them. Very long bodies are cut at 20,000 characters by default (Settings),
with a note saying so.

**Actions are strictly controlled.** A new agent can do nothing. Each grant (agent × mailbox) sets:

| Control | |
|---|---|
| Capabilities | `read` (search, read, attachments, events), `draft`, `send`, `organize` (labels, archive, read state), `trash` |
| Label scope | Confine the agent to threads carrying certain labels. Searches are rewritten and fetches by id are checked; anything outside looks "not found". |
| Send mode | `draft_only` (default: a Gmail draft for a human to send), `approval` (queued in the console; approving sends it) or `direct` |
| Allowed recipients | Exact addresses or globs such as `*@example.com` |
| Reply only | No new messages or forwards; replies only, inside threads the agent can see, to people already in the conversation. The reply goes to the message's Reply-To or From, which the sender chooses: use Allowed recipients to limit who can be written to. |
| Send caps | Per rolling hour and 24 hours, counting sends that became drafts or approvals too |

**Everything is audited.** Every tool call and every console change is recorded with its outcome, and denials name the
guardrail that blocked them.

**The console** is protected by an admin password (set through the one-time setup link), HttpOnly session cookies
that expire after 30 days of inactivity (90 at most), a CSRF header on every API change, request size limits, a
Content Security Policy that allows only the console's own scripts, and a 15-minute lockout after five wrong passwords
(or 30 attempts) from one address. When
failures pile up across many addresses, every sign-in attempt is slowed down instead. It never allows being framed.
The data directory and database are readable only by their owner.

Recommendations: split agents that read untrusted mail from agents that can send; prefer `draft_only` or `approval`;
use allowlists for anything that sends directly.

## Running it

### As a service

```sh
npx agentinbox-cli service install
```

Installs a background service that starts at boot: systemd on Linux (a user service with lingering enabled, or a system
service when run as root) and launchd on macOS. The service runs its own copy of agentinbox (the latest npm release),
installed in the data directory, so it never depends on the npx cache; if that install fails, the running service is
left as it was. Re-run `service install` to update; `service status` and `service uninstall` do what you'd expect, and
uninstalling keeps your data. `--port`, `--host` and `--data` aren't remembered: pass the same ones each time.
`AGENTINBOX_MASTER_KEY`, `TRUST_PROXY`, `TRUST_PROXY_HOPS`, `TRUST_PROXY_FROM` and `AGENTINBOX_LOG_LEVEL` are taken from
your shell and remembered in `<data>/service.env`, so later installs keep them (set one to an empty value to remove it).

Only one agentinbox runs per data directory; a second one refuses to start.

When a newer version is published, the console shows **Update available** with the exact command to run, flags
included.

The unit runs the Node.js binary you installed it with. If you remove or replace that Node, run `service install` again.
If the service doesn't come up, `service install` rolls back and prints where to look (`systemctl --user status
agentinbox`, `journalctl --user -u agentinbox`). Over `su` or `sudo -u` there is often no systemd user session: log
in as that user directly, or enable lingering and set `XDG_RUNTIME_DIR=/run/user/$(id -u)` first.

A service that hasn't been claimed yet doesn't print its setup token to the journal. Get the link with
`npx agentinbox-cli reset-password --data <dir>`, or read the token from `<data>/setup-token`.

### With your own supervisor

On other systems, or to manage it yourself (Docker, runit, supervisord, pm2), install the command and run it in the
foreground:

```sh
npm i -g agentinbox-cli
agentinbox start --no-open --data /var/lib/agentinbox
```

Use the installed `agentinbox` binary rather than `npx`: npx doesn't pass stop signals on, so agentinbox wouldn't shut
down cleanly.

### Behind a reverse proxy

agentinbox serves plain HTTP. To expose it beyond your network, put it behind a TLS-terminating proxy (Caddy, nginx,
Tailscale Funnel) and:

- bind it to localhost: `--host 127.0.0.1`
- set `TRUST_PROXY=1`, so lockouts apply per client and cookies are marked `Secure` on https. The client address is
  taken from the right end of `X-Forwarded-For`; with more than one proxy in front, set `TRUST_PROXY_HOPS` to their
  number. Forwarding headers are believed only from a proxy on the same host (loopback); if the proxy runs on another
  machine, list its address in `TRUST_PROXY_FROM` (for example `TRUST_PROXY_FROM=10.0.0.5` or a CIDR like
  `10.0.0.0/24`; `/0` is refused). The proxy must append to (or overwrite) `X-Forwarded-For`; a tunnel or proxy that
  passes the header through unchanged lets clients choose their own address
- set **Settings → Public URL** to the https address, so MCP snippets, the skill and the OAuth redirect use it

`GET /health` (or `/api/health`) needs no auth and suits uptime monitors: `200` when healthy, `503` when degraded, for
example when an account needs to be reconnected or ingest is failing. It reports only a status, the version and
whether setup is complete.

### Options

With a global install (`npm i -g agentinbox-cli`); otherwise prefix `npx agentinbox-cli` instead of `agentinbox`.

```
agentinbox [start] [--port 8425] [--host 0.0.0.0] [--data ~/.agentinbox] [--no-open] [--dev]
agentinbox service install [--port …] [--host …] [--data …]
agentinbox service uninstall [--data …]
agentinbox service status
agentinbox skill install [--target claude|codex|agents|<dir>] [--url <public url>]
agentinbox skill print [--url <public url>]
agentinbox reset-password [--data …]
agentinbox connect <url> … / listen … / listen service install …   # agent machines, see docs/listen.md
```

| Flag | Env | Default | |
|---|---|---|---|
| `--port` | `PORT` | `8425` | Port to listen on |
| `--host` | `HOST` | `0.0.0.0` | Bind address. Use `127.0.0.1` behind a reverse proxy or SSH tunnel |
| `--data` | `AGENTINBOX_DATA` | `~/.agentinbox` | Data directory |
| | `AGENTINBOX_MASTER_KEY` | | Base64 32-byte key for encrypting secrets, instead of `<data>/master.key` |
| | `TRUST_PROXY` | | `1` behind a reverse proxy: client IPs from `X-Forwarded-For`, https from `X-Forwarded-Proto` |
| | `TRUST_PROXY_HOPS` | `1` | Number of proxies in front of agentinbox (which `X-Forwarded-For` entry, from the right, is the client) |
| | `TRUST_PROXY_FROM` | loopback | Other proxy addresses whose forwarding headers are believed: comma-separated IPs or CIDRs (IPv4 or IPv6) |
| | `AGENTINBOX_LOG_LEVEL` | `info` | `debug`, `info`, `warn` or `error` |
| `--url` | `AGENTINBOX_URL` | `http://localhost:8425` | `skill` commands only: the server URL written into SKILL.md |

Forgot the admin password? Run `npx agentinbox-cli reset-password` on the server, as the user agentinbox runs as (`sudo` for a
system service) and with the same `--data`. It signs out all sessions and prints a new setup link; a running instance
picks it up without a restart.

## Data and backups

Everything lives in the data directory (`~/.agentinbox` by default):

| File | |
|---|---|
| `agentinbox.db` | SQLite database (plus `-wal` and `-shm` files): accounts, encrypted tokens, agents, grants, events, deliveries, approvals, audit log |
| `master.key` | Encrypts Google tokens, webhook secrets and the OAuth client secret (mode 0600) |
| `setup-token` | The one-time setup token, until the instance is claimed |
| `agentinbox.lock` | Marks the running instance (pid and port) |
| `service.env` | Environment the service was installed with, possibly including `AGENTINBOX_MASTER_KEY` (mode 0600; `service install` only) |
| `agentinbox.log` | Service output on macOS (launchd); on Linux use `journalctl --user -u agentinbox` (or without `--user` for a system service) |
| `app/` | The service's own install (`service install` only) |

Back up `agentinbox.db` and `master.key` together: without the key, stored secrets can't be decrypted, and agentinbox
refuses to start rather than create a new one. Keep the key somewhere safer than the database backup, or supply it via
`AGENTINBOX_MASTER_KEY`. The database runs in WAL mode, so don't just copy the file while agentinbox is running: stop
it first (`systemctl --user stop agentinbox`), or take a consistent copy with
`sqlite3 ~/.agentinbox/agentinbox.db ".backup /path/to/backup.db"`.

Received mail bodies are never stored; agentinbox keeps metadata (ids, headers, labels, snippets) and fetches content
from Gmail on demand. The only bodies in the database are those of sends queued for approval. Events, deliveries,
audit entries and decided approvals (with their bodies) are pruned after the retention period set in **Settings** (30
days by default).

## Release channel

Every push to `main` (a quick second merge can supersede the first) is published to npm as
`agentinbox-cli@<version>-nightly.<n>` under the `latest` tag, built by GitHub Actions with npm trusted publishing and
[provenance](https://docs.npmjs.com/generating-provenance-statements).
`npx agentinbox-cli` therefore runs the newest build of `main`, and `service install` always installs `latest`. To
stay on one build, run it pinned in the foreground: `npx agentinbox-cli@<version>`.

## Development

```sh
pnpm install
AGENTINBOX_DATA=/tmp/agentinbox-dev pnpm dev
```

`pnpm dev` runs the server on `:8425` in watch mode (API, MCP and OAuth only) and the console on Vite at `:5173`, which
proxies to it. Open the Vite URL; the printed setup link already points there.

```sh
pnpm typecheck && pnpm lint && pnpm test
pnpm build           # console → dist/web, server → dist/cli.js
node dist/cli.js     # run the build
```

Stack: TypeScript on Node 22+, Hono on `@hono/node-server`, `node:sqlite`, the MCP TypeScript SDK, and thin fetch-based
Google clients (no `googleapis`, so `npx` stays fast). The console is Vite, React 19, TanStack Router / Query / Table /
Form and shadcn/ui on Tailwind v4.

## License

[MIT](LICENSE)