# Bindu Inbox
A Gmail-shaped operator console for the A2A network. Every JSON-RPC message between two DIDs lands here as a row, threads group by `context_id`, and signatures are verified inline so you can see whether a peer is actually who it claims to be.
This README walks you from `git clone` to a signed message landing in the right rail, with auth on the whole way through. **No prior bindu knowledge required** — just follow it top to bottom.
## TL;DR
If you've done this before, here's the whole flow:
```bash
git clone https://github.com/getbindu/Bindu.git && cd Bindu
uv sync
export OPENROUTER_API_KEY=
cd inbox && npm install && npm run dev # → http://127.0.0.1:3775
# In another shell, once the UI is up:
./scripts/spawn-demo-peers.sh # boots joke + poet agents
# In the UI: gear icon (top-left) → paste OPENROUTER_API_KEY into Settings
# "+ Create your agent" (bottom-left) → fill persona → Save
# Start (same card) → spawns your personal agent + Hydra OAuth
# Contacts → + → paste http://127.0.0.1:5773 then 5776
# Compose → pick joke_agent → type a message → ⌘↵
```
If you've never done this before, keep reading — every step below explains what it does and what you should see.
## What ends up running
By the time you finish this guide, five things will be alive on your machine:
| Port | Process | What it does |
| --- | --- | --- |
| `3775` | Vite dev server | The React UI you open in your browser |
| `3787` | Hono API (`server/index.ts`) | SQLite event store, webhook intake, outbound A2A composer |
| `` (e.g. `5xxxx`) | Your personal agent (Python, spawned on demand) | Your DID + Hydra OAuth client. Listens for inbound A2A and signs everything you send. |
| `5773` + `5776` | Demo peers (joke + poet) | Two example agents to talk to. Optional — bring your own if you have them. |
| `3774` | Gateway (optional) | The planner. Only spawns when you message ≥2 agents at once. |
`3775` is hard-pinned (Vite refuses to drift) and `3787` is hard-coded; both have to be free before you start. If something else is on those ports, the inbox won't boot — see [Troubleshooting](#troubleshooting).
## Prerequisites
You need four things on your machine before this works:
1. **Node 20+** and **npm**. Check with `node --version`. If you need it: .
2. **Python 3.12+** and [**uv**](https://github.com/astral-sh/uv). Check with `python3 --version` and `uv --version`. To install uv:
```bash
curl -LsSf https://astral.sh/uv/install.sh | sh
```
3. **A bindu checkout with dependencies synced.** From scratch:
```bash
git clone https://github.com/getbindu/Bindu.git
cd Bindu
uv sync # installs python deps under .venv/
```
The inbox lives at `Bindu/inbox` and locates the repo via `..` from its own folder, so running it from inside the checkout Just Works. If you moved the inbox out, set `BINDU_REPO_DIR=/path/to/Bindu`.
4. **An OpenRouter API key.** The personal agent and the demo peers all use OpenRouter for LLM calls.
- Get one (free trial credits, no card required for testing) at .
- Then either export it in your shell:
```bash
export OPENROUTER_API_KEY=sk-or-v1-...
```
- …or paste it into the inbox's Settings tab after boot (gear icon, top-left). Settings-tab values win over env vars.
That's it. No Docker, no Postgres, no Hydra of your own — the inbox uses the public Bindu Hydra at `hydra.getbindu.com` for OAuth.
## Start it
From the repo root:
```bash
cd inbox
npm install # ~30s first time, cached after
npm run dev
```
You should see two concurrent processes logging side by side:
```
[web] VITE v6.x ready in 400 ms
[web] ➜ Local: http://127.0.0.1:3775/
[api] [inbox] api on http://127.0.0.1:3787
```
If you see `[inbox] GATEWAY_API_KEY not set` it's a warning, not an error — you only need the gateway key for multi-agent compose, which isn't part of this guide.
Open . You'll see:
- **Left sidebar**: folders (Inbox, Sent), an empty Contacts section, a **gear icon** at the top (Settings — API keys), and at the bottom a dashed-border **➕ Create your agent** button.
- **Middle pane**: empty thread list.
- **Right pane**: nothing selected.
That's the cold-start state. The next four steps fill it in.
> 💡 **Recommended first**: click the **gear icon** at the top of the sidebar and paste your OpenRouter API key into Settings. The personal agent reads it from the database on every spawn, so doing it before Step 2 saves you a retry.
### 1 · Create your persona (opens the wizard)
At the bottom of the sidebar, click **➕ Create your agent**. This opens the persona wizard.
Fill in at least:
- **Name** — anything. "Sheldon Cooper" works fine.
- **Occupation** — a short title + organization.
- **Personality traits** — a few comma-separated traits.
Click **Save**. This writes `~/.bindu/personal/persona.json` and the bottom-of-sidebar card changes: it now shows your persona name, a gray dot, status **down**, and a **Start** button. **Nothing is signed yet** — you're just a row in the inbox DB.
### 2 · Spawn your personal agent (this is what turns auth on)
Click **Start** on the personal-agent card (where the wizard button used to be).
Here's what happens behind the scenes, in order — this takes ~10s the first time, ~3s on subsequent spawns:
1. **Port picked.** A free local port (typically in the 50000–65000 range) is assigned.
2. **Files written to `~/.bindu/personal/`.**
- `agent.py` — rendered from the template in `server/personal-agent.ts`. Hand-edits get overwritten.
- `.env` — mode `0600` (POSIX), contains `AUTH__ENABLED=true`, `AUTH__PROVIDER=hydra`, `HYDRA__ADMIN_URL`, `HYDRA__PUBLIC_URL`, `OPENROUTER_API_KEY`.
3. **`uv run python agent.py` is spawned** from the bindu repo root. Bindufy then:
- **Registers an OAuth client with Hydra** (`https://hydra-admin.getbindu.com` by default). `client_id` = your DID. Public key goes into the client metadata. Idempotent — re-runs reuse the same client.
- **Generates an Ed25519 keypair** under `~/.bindu/personal/.bindu/private.pem` + `public.pem`. First spawn only.
- **Starts the A2A server** on the assigned port and the gRPC core on its own.
When the spawn returns, the slot flips to **alive** (green dot) and shows your DID:
```
did:bindu:::
```
That's your cryptographic identity from now on. Every outbound message the inbox sends is **bearer-token-authed against Hydra** *and* **Ed25519-signed with this key** — same envelope a fully-deployed bindufy agent uses.
**If it didn't go alive:** see Troubleshooting → `Personal agent stays "down"`.
### 3 · Add a peer (spawn two demo agents)
You need at least one other agent to talk to. The inbox ships a script that spawns two single-purpose demo agents with auth on:
```bash
# from the inbox/ directory
./scripts/spawn-demo-peers.sh
```
This boots:
- **`joke_agent`** on `http://127.0.0.1:5773` — tells jokes, declines anything else.
- **`poet_agent`** on `http://127.0.0.1:5776` — writes 4-line poems, declines anything else.
Both run with `AUTH__ENABLED=true`, so they reject unauthenticated callers — same as your personal agent. Source: [`examples/gateway_test_fleet/joke_agent.py`](../examples/gateway_test_fleet/joke_agent.py) and [`poet_agent.py`](../examples/gateway_test_fleet/poet_agent.py).
Output looks like:
```
Spawning demo peers for the inbox...
[joke_agent] starting on port 5773...
[joke_agent] ready, pid=12345, log=.../scripts/logs/joke_agent.log
[poet_agent] starting on port 5776...
[poet_agent] ready, pid=12346, log=.../scripts/logs/poet_agent.log
Paste these into the inbox: Contacts → + → Add a peer
joke_agent http://127.0.0.1:5773
poet_agent http://127.0.0.1:5776
```
Now in the UI:
1. Sidebar → **Contacts** section → click the **+** button.
2. Paste `http://127.0.0.1:5773`. The inbox calls `/.well-known/agent.json`, records the DID, and tags the contact `protected` (because Hydra auth is on).
3. Repeat with `http://127.0.0.1:5776`.
Both should now appear under **Agents** in the sidebar.
Stop the demo peers later with `./scripts/stop-demo-peers.sh`. Their logs live under `inbox/scripts/logs/` — both are gitignored.
> ℹ️ **Bringing your own agent works the same way.** Any bindufy URL — local or remote, with or without auth — is a valid peer. The inbox auto-detects the auth shape from the agent card.
### 4 · Send your first message
1. Click the big **Compose** button at the top of the sidebar.
2. In the modal, pick **joke_agent** from the recipient picker.
3. Type something like `tell me a joke about databases`.
4. Press `⌘↵` (or click **Send**).
The inbox does this:
1. Mints (or reuses, if cached) a Hydra client-credentials token with `agent:read agent:write`.
2. Builds the JSON-RPC `message/send` envelope with a fresh `contextId` + `taskId`.
3. **Signs the canonical body** with your personal agent's Ed25519 private key, attaches `X-DID`, `X-DID-Signature`, `X-DID-Timestamp` headers.
4. POSTs to `http://127.0.0.1:5773/` with `Authorization: Bearer `.
Two things should happen within a few seconds:
- A new row appears in the **Sent** folder (your outbound message).
- The agent's webhook fires back to the inbox at `/webhooks/bindu/joke_agent`, and a reply appears in the **Inbox** folder. Both messages are threaded under the same `context_id`.
Click the thread to read the reply. You're done — you've just sent a signed, authed A2A message and received an answer.
## Verify it's actually using auth
Don't trust the UI — prove it from your terminal.
**Find the personal-agent port:**
```bash
curl -s http://127.0.0.1:3787/api/me | python3 -c "import sys,json; print(json.load(sys.stdin)['url'])"
# → http://127.0.0.1:61380
```
**Confirm unauthed callers are rejected:**
```bash
curl -s -X POST http://127.0.0.1:61380/ \
-H 'content-type: application/json' \
-d '{"jsonrpc":"2.0","method":"message/send","id":"x","params":{"message":{"role":"user","kind":"message","parts":[{"kind":"text","text":"hi"}],"messageId":"m","contextId":"c","taskId":"t"}}}'
# → {"jsonrpc":"2.0","error":{"code":-32009,"message":"Authentication is required ..."},"id":null}
```
**Confirm the inbox can:**
```bash
curl -s -X POST http://127.0.0.1:3787/api/compose \
-H 'content-type: application/json' \
-d '{"agentId":"joke_agent","text":"hi"}'
# → {"ok":true,"status":200,"contextId":"...","taskId":"...","response":{"jsonrpc":"2.0",...}}
```
**Inside the UI:** open any thread and click **Verify** in the top-right of the right rail. Each row should report `signed / verified` against the peer's published `publicKeyBase58`. The detail rail's **Verify** tab shows the DID match, the signature, and the timestamp nonce.
## What "auth on" means here
There are three independent auth layers in play. The inbox uses all three:
| Layer | Who enforces it | How to turn it on | Default |
| --- | --- | --- | --- |
| **Peer A2A auth** — outbound calls must carry a JWT + DID signature | The peer's bindufy middleware (`-32009` if missing) | Set `AUTH__ENABLED=true` on the peer. Done automatically when the inbox spawns your personal agent or the demo peers. | **On** |
| **Operator gate** — `/api/*` on the inbox requires a bearer token | `inbox/server/index.ts` | `export BINDU_COMMS_TOKEN=$(openssl rand -hex 32)` before `npm run dev`. UI needs `?token=` in the URL (SSE can't send headers). | Off (single-user dev) |
| **Webhook gate** — agents POSTing to `/webhooks/bindu/:agentId` must carry a bearer token | `inbox/server/index.ts` | `export BINDU_WEBHOOK_TOKEN=` and configure the same value as `global_webhook_token` on the bindufy side. | Off |
The first is non-negotiable once your personal agent is alive. The other two are belt-and-suspenders for multi-user or exposed deployments — leave them off until you actually share the URL.
## Using the inbox
### Layout
| Pane | What it shows |
| --- | --- |
| **Left** | Folders (Inbox / Sent / Drafts / Archive), Contacts grouped into **Gateways** (orchestrators for multi-agent plans) and **Agents** (single-purpose peers). Your operator identity sits at the bottom. |
| **Middle** | Thread list — newest first, one row per `context_id`. The badge is the message count. |
| **Right** | Selected thread, oldest message at top. State pills + DID per row; bodies render inline. Reply composer pinned at the bottom. |
### Sending
- **One agent** → direct A2A `message/send` to that peer.
- **Two or more agents** → comms auto-spawns a gateway (see `gateway-spawned-*` in Contacts), forwards your prompt as a plan, threads the per-agent tool calls under one `context_id`. The gateway's `/plan` is bearer-gated; the inbox auto-loads `GATEWAY_API_KEY` from `gateway/.env.local` if it's there.
### Reading a thread
Each row carries:
- **State pill** — `submitted` · `working` · `input-required` · `payment-required` · `auth-required` · `completed` · `failed`. Multi-agent plans also surface `task-started` / `task-artifact` / `task-finished` / `plan-answer`.
- **Trust pill** — `first-contact` (new peer) · `known` · `self` (your own agents / planner).
- **DID** of the counterparty — click for the full agent card.
- Body text rendered inline; the `` wrapper is stripped for readability.
**Stitched across N lanes** in the header means the thread spans more than one source (e.g. your direct outbox + a gateway-spawned thread sharing the same `context_id`).
### Replying
Type in the box at the bottom of the open thread. `⌘↵` sends. Replies inherit the thread's `context_id` and resume the existing task if it's still open, refine it (with `referenceTaskIds`) if the task ended terminal, or start fresh otherwise.
## Files & directories
| Path | What's there |
| --- | --- |
| `inbox/data/events.db` | SQLite event log. Delete to start fresh; the schema rebuilds on boot. Gitignored. |
| `inbox/.env.local` | Per-developer env. Gitignored. Same shell-env vars below also work. |
| `inbox/scripts/logs/` | Demo-peer stdout/stderr. Gitignored. |
| `inbox/scripts/pids/` | Demo-peer pidfiles. Gitignored. |
| `~/.bindu/personal/persona.json` | Your operator persona. Hand-editable. |
| `~/.bindu/personal/.env` | Mode-`0600` env file: OpenRouter key, Pipedream tokens, Hydra URLs, `AUTH__ENABLED=true`. Regenerated on every spawn. |
| `~/.bindu/personal/agent.py` | Auto-generated bindufy agent. Hand-edits are overwritten — edit `persona.json` or the template in `server/personal-agent.ts`. |
| `~/.bindu/personal/.bindu/oauth_credentials.json` | OAuth client_id + secret from Hydra. Required for outbound token minting. |
| `~/.bindu/personal/.bindu/private.pem` | Ed25519 private key. Required for DID signatures. |
| `~/.bindu/personal/logs/agent.log` | Personal agent stdout/stderr. Tail this when spawn fails. |
| `examples/.env` | Shared env for the demo agents (`OPENROUTER_API_KEY=...`). The spawn script falls back to your shell env if this file isn't there. |
## Environment knobs
All optional unless noted:
| Var | Default | Why |
| --- | --- | --- |
| `BINDU_COMMS_TOKEN` | unset | Bearer token gate on `/api/*`. See "auth on" above. |
| `BINDU_WEBHOOK_TOKEN` | unset | Bearer token gate on `/webhooks/bindu/*`. Must match agent-side `global_webhook_token`. |
| `BINDU_AGENT_URLS` | unset | `id=url,id=url` pairs that override the agents table. Ops escape hatch. |
| `BINDU_REPO_DIR` | `..` | Where bindu lives. Needed if you moved the inbox out of the repo. |
| `BINDU_PERSONAL_DIR` | `~/.bindu/personal` | Personal agent files. Useful for sandboxing tests. |
| `BINDU_GATEWAY_DIR` | `../gateway` | Where the gateway lives. Needed if you split repos. |
| `GATEWAY_API_KEY` | read from `gateway/.env.local` | Bearer the inbox sends to the gateway's `/plan`. Without it multi-agent compose 401s. |
| `HYDRA__ADMIN_URL` | `https://hydra-admin.getbindu.com` | Hydra admin API. Used for OAuth client registration. |
| `HYDRA__PUBLIC_URL` | `https://hydra.getbindu.com` | Token endpoint for outbound Hydra minting. |
| `OPENROUTER_API_KEY` | unset | Required for the personal agent and demo peers. UI Settings tab is the recommended path. |
| `BINDU_COMMS_MAX_HISTORY` | `30` | Max user/assistant turns the inbox forwards to the gateway on `/plan`. |
| `BINDU_PERSONAL_USE_VENV` | unset | Skip `uv` and run the personal agent under `/.venv/bin/python` instead. |
## Troubleshooting
**`npm install` fails on `better-sqlite3`.** Native build needs Xcode CLI tools on macOS / `build-essential` on Linux. `xcode-select --install` or `apt install build-essential python3-dev`, then `rm -rf node_modules && npm install`.
**Port 3775 or 3787 already in use.** Something else is bound — usually an old inbox you forgot to stop. Find it: `lsof -ti:3775 -ti:3787 | xargs ps -p` to confirm it's stale, then `lsof -ti:3775 -ti:3787 | xargs kill`.
**UI loads but everything fails with "Failed to fetch".** The API on `3787` died. Check the `[api]` lines in your `npm run dev` output. Common: `better-sqlite3` ABI mismatch (`npm rebuild better-sqlite3`).
**Personal agent stays "down" after clicking Start.** Open `~/.bindu/personal/logs/agent.log` and read the last 30 lines:
- `OPENROUTER_API_KEY` not set → paste it in Settings or export it before `npm run dev`.
- Hydra unreachable (network/firewall blocking `hydra-admin.getbindu.com`) → set `HYDRA__ADMIN_URL` + `HYDRA__PUBLIC_URL` to a Hydra you can reach. If you don't have one, the public Bindu Hydra is the easiest path; check connectivity with `curl -sI https://hydra.getbindu.com/.well-known/openid-configuration`.
- `uv: command not found` → install uv (Prerequisites) or set `BINDU_PERSONAL_USE_VENV=1`.
**`-32009: Authentication is required` on every send.** Personal agent isn't running, or its Hydra OAuth client isn't registered. Check `~/.bindu/personal/.bindu/oauth_credentials.json` exists. Stop and re-spawn from the UI; watch the log.
**`spawn-demo-peers.sh` says `OPENROUTER_API_KEY not set and examples/.env missing`.** Either export the key in your current shell or create `examples/.env`:
```bash
echo "OPENROUTER_API_KEY=sk-or-v1-..." > examples/.env
```
**Add-a-peer says `agent-not-reachable` or returns no card.** The agent isn't up on that URL, or it's blocking `/.well-known/agent.json`. Confirm with `curl http://127.0.0.1:5773/.well-known/agent.json` — should return JSON.
**`pipedream-not-configured` when connecting Gmail/Notion.** Personal agent works fine without Pipedream — only the optional MCP tools need it. Either ignore or set `PIPEDREAM_PROJECT_ID` + `PIPEDREAM_CLIENT_ID` + `PIPEDREAM_CLIENT_SECRET` in Settings.
**`unauthorized` on `/api/plan`** (multi-agent compose). Either `GATEWAY_API_KEY` isn't loaded (inbox prints a warning at boot) or it doesn't match the gateway's value. The inbox auto-reads `gateway/.env.local` — make sure it's not stale.
**`SyntaxError` on Python startup.** You're on Python below 3.12. Install 3.12+ and re-run `uv sync` so `.venv` picks up the right interpreter.
**I want to nuke everything and start over.**
```bash
./scripts/stop-demo-peers.sh # stop demo peers
# In the UI: click Stop on the personal-agent card, then close the tab.
rm -rf ~/.bindu/personal/ # forget your DID + Hydra client
rm -f inbox/data/events.db inbox/data/events.db-{shm,wal} # wipe inbox history
# Now restart from the Start it section.
```
## Build
```bash
npm run build # TypeScript + Vite production build
npm run preview # Serve the build locally
npm run typecheck # tsc -b --noEmit, no emit
```
There's no production runtime story yet — the inbox is single-operator, single-machine. Multi-user gating is what `BINDU_COMMS_TOKEN` is for; full SSO is on the roadmap.
## Stack
React 19 · React Router v7 (SPA) · Vite 6 · Tailwind v4 · TanStack Query · Zustand · Phosphor icons · SQLite (events) via `better-sqlite3` · Hono on the API side.
## Attribution
Visual aesthetic inspired by [cloudflare/agentic-inbox](https://github.com/cloudflare/agentic-inbox) (Apache 2.0). See [NOTICE](./NOTICE).