I wanted my OpenClaw agent to handle email — read it, triage it, draft replies. The obvious path is to hand it a Gmail OAuth token and let it call the API directly. I didn't do that.
Not because I don't trust the agent, but because that framing is wrong. The question isn't do I trust the agent — it's what rules should govern what it's allowed to do? Reading my inbox? Fine, always. Sending an email to an investor on my behalf? That needs my explicit sign-off. Permanently deleting messages? Never, under any circumstances.
When you frame it that way, a raw API token is clearly the wrong tool. A token is binary: either the agent can do everything Gmail allows, or nothing. What I wanted was a proxy — something that sits between the agent and Gmail, enforces a clear permission model, and makes certain actions simply impossible without going through the right checks.
That's what I built. I call it Postmaster.
Postmaster is a compiled Swift binary that acts as the sole gateway between OpenClaw and Gmail. The agent never touches Gmail credentials directly — it talks to the proxy over a Unix socket, and the proxy decides what happens.
OpenClaw (Python) ──→ [proxy] ──→ Gmail API
Credentials live in macOS Keychain, accessible only to the signed Swift binary. The agent side has no tokens, no secrets.
The proxy enforces a three-tier permission model, compiled into the binary:
| Tier | Examples | Behavior |
|---|---|---|
| Always permitted | Read, search, create draft | Executes immediately |
| Permitted with proof | Send, archive, forward, label | Requires cryptographic human approval |
| Always denied | Permanent delete | Hard reject, no override |
This isn't configuration — it's compiled in. A JSON policy file can be edited by any process running as the same user. A compiled binary requires a code change, a rebuild, and a redeploy.
One of the most immediately useful things Postmaster enables is automatic inbox triage. When an email arrives, it gets classified in real-time and labeled in Gmail — before you ever open it.
The classification system runs in two layers:
Layer 1 — Deterministic rules. Fast, cheap, no LLM involved. Domain-based rules (@github.com → dev), header signals (List-Unsubscribe → bulk), VIP sender lists. High-confidence matches are applied immediately.
Layer 2 — Isolated LLM call. When rules aren't enough, a one-shot API call handles classification. More on this below.
The result: Gmail labels like k/urgent, k/important, k/travel, k/bulk are applied automatically. You open your inbox to find it already sorted.
Classification is just metadata. The interesting part is what happens after a label is applied — a set of workers that watch for specific labels and take automatic action.
A couple of examples from my setup:
Travel booking extraction. When an email receives the k/travel label and contains a booking confirmation (flight, hotel, car), a worker extracts the confirmation number, dates, and carrier automatically and logs it to my travel tracker. I never manually enter a booking.
Noise reduction. When an email is classified as k/bulk, a worker checks whether the sender has a List-Unsubscribe header. If so, it surfaces an unsubscribe card in Slack with a single button. One tap and it's done — and a hard rule is added so future emails from that sender are handled automatically.
The pattern generalizes well. Any email type that recurs becomes a candidate for a worker: invoice processing, contact intake from email signatures, follow-up detection. You define what a label means, and the worker handles the rest.
When the agent wants to send an email on your behalf, the flow starts with a draft — not a send. The proxy creates a native Gmail draft (stored in Gmail, nothing sent), and the agent surfaces it as an interactive card in Slack:
📧 Email Draft
From: [email protected] → [email protected]
Subject: Re: Proposal
Hi James, thanks for sending this over. I've had a chance to
review and have a few questions before we proceed...
[✅ Send] [🗑️ Discard]
This card isn't a dead-end notification. Reply in the thread — "make it shorter", "add that we need a response by Friday" — and the agent revises the Gmail draft in-place and refreshes the card with the new version. Same buttons, updated content.
The loop is: agent writes a first pass → you refine it through chat → you approve when it's right. Gmail is the source of truth; Slack is the interface.
Here's how the full flow works from "agent wants to send" to "email is sent":
1. Agent calls proxy: "create a draft with this content"
→ Proxy creates a Gmail draft (nothing sent yet)
2. Agent posts the draft card to Slack with Approve/Discard buttons
(the Gmail draft ID is encoded in the button's action_id)
3. You click Approve
4. Slack POSTs the interaction to the agent's server:
raw payload + HMAC signature + timestamp
5. Agent forwards this — payload and headers intact, untouched —
to the proxy over the socket
6. Proxy independently verifies:
• HMAC signature (recomputed using Slack signing secret from Keychain)
• Timestamp is within 5 minutes (replay attack protection)
• Your user ID is in the compiled-in authorized list
7. Verification passes → proxy calls Gmail: drafts.send()
8. Proxy returns the sent message ID → agent updates the card to ✅ Sent
The agent never tells the proxy "the user approved this." It forwards raw cryptographic evidence and lets the proxy decide.
The agent has access to the proxy socket — that's how it does everything. So what prevents it from calling the send endpoint directly, or modifying the proxy's source code and recompiling a version without the checks?
Not software conventions. A closed cryptographic loop.
All credentials — the Gmail OAuth tokens, the Slack signing secret, everything the proxy needs to operate — are stored in macOS Keychain at setup time by a human running an interactive CLI. Keychain ACLs are bound to the code signing identity of the binary that stored them, enforced by macOS's Security framework at the OS level. No other process can read those items — not the agent, not a shell script, not even a recompiled version of the proxy itself.
That last point is the key one. Suppose a malicious actor had full access to the proxy's source code and a working Swift compiler. They could modify the policy, remove the HMAC check, recompile. The resulting binary would have a different code signature. macOS would refuse it access to the Keychain items. It would have no Gmail tokens, no signing secret — it couldn't do anything. The binary would be inert.
To forge a valid send request and have it succeed, you'd need to simultaneously:
- Produce a valid
HMAC-SHA256signature using the Slack signing secret (stored in Keychain, inaccessible) - Use a user ID from the compiled-in authorized list
- Use a timestamp within the last 5 minutes (no pre-computing)
- Have the proxy binary be the original signed one — because only that binary can read the Gmail tokens needed to actually call the send API
There is no attack path that satisfies all four. Tamper with the binary → lose Keychain access. Forge the HMAC → need a secret you can't read. Replay an old signature → fails the timestamp check.
The proxy doesn't trust the agent, even though it was built by the same person. The security guarantee doesn't rely on the agent being well-behaved. It relies on the math and the OS.
Email is an adversarial surface. A sufficiently crafted message could attempt to manipulate an agent into taking actions on the sender's behalf — forwarding emails, exposing contents, or changing behavior. This is called prompt injection, and it's a real concern when email content flows directly into an agent's context.
Postmaster handles this by keeping email content out of the agent's main session entirely.
Classification happens through an isolated, one-shot LLM call — a completely separate API call with its own minimal system prompt, zero tool access, and a single job: return a structured JSON label object. The email body is passed as explicitly delimited untrusted input, with explicit instructions to ignore any directives it contains. Only the structured output (importance, category, action flag) flows back. Raw email content never enters the agent's main context.
The same principle applies to draft composition: when the agent needs thread context to write a reply, email content is passed as delimited, explicitly-labeled untrusted input — not mixed freely into the conversation.
For genuinely low-risk recipients — known hotel addresses for folio requests, your own aliases — there's a send whitelist that allows the proxy to skip Slack approval.
The whitelist is itself HMAC-signed by a secret compiled into the binary. Editing the JSON file directly invalidates the signature; the proxy detects the mismatch and falls back to requiring approval for everyone. Modifying the whitelist requires running a TTY-gated CLI command — something the agent can't do without terminal access.
-
Think in tiers, not binary access. Most APIs have a mix of safe reads and writes that range from fine-to-automate to needs-human-sign-off. Design your access layer to reflect that.
-
Proof beats trust. Don't build systems where the agent asserts "the user approved this." Build systems where it presents cryptographic evidence of approval that an independent component verifies.
-
The approval UX is part of the security design. Frictionless approval means you actually use it. Conversational draft editing + one-tap Slack approve is faster than doing it manually.
-
Treat external content as adversarial. Use isolated, single-purpose LLM calls for processing untrusted inputs. Keep your main agent session clean.
-
Compile your policy, don't configure it. Files can be changed at runtime by any process. Source code requires a human to commit, build, and deploy.
Credentials are stored in macOS Keychain using Security.framework directly — no subprocess, no shell calls. The Keychain ACL is bound to the binary's code signing identity, so only the correctly signed proxy binary can read them. Credentials are loaded once at startup into memory and never written to disk.
Each proxy instance exposes two Unix sockets:
- API socket — the agent calls this to request Gmail operations
- Events socket — the proxy pushes inbound email notifications to the agent on this socket
This means the proxy is fully event-driven on inbound mail (no polling) and request-driven on operations (no persistent connections needed).
Rather than polling on a cron, Gmail push notifications are delivered via Google Cloud Pub/Sub through a Tailscale Funnel endpoint. New mail arrives in seconds. No open firewall ports required — Tailscale handles the tunneling.
The full classification pipeline:
-
Rules engine — domain matches, header signals (
List-Unsubscribe,no-reply@,Precedence: bulk), VIP lists. Marked as high-confidence when matched; LLM is skipped entirely. -
LLM classifier — isolated API call, haiku-class model, no tools, no memory. Email body is HTML-stripped, truncated to ~300 words, and passed as delimited untrusted input. Output is a strict JSON schema:
{importance, category, action_needed, one_line_summary, confidence}. Low-confidence results are discarded — no label applied.
Results are written back to Gmail as hierarchical labels (k/urgent, k/finance, k/bulk, etc.), which serve as both the inbox UI and the signal that triggers workers.
Certain email types — auth codes, 2FA, password resets — are filtered at the proxy level before the agent receives them. The proxy fetches these messages (so Gmail's history cursor advances correctly) but returns only {id, filtered: true}. The agent records them as seen and moves on. No subject, no body, no sender reaches the agent's context.
Account configuration (permitted send-as aliases, relay filter patterns, authorized Slack users) is compiled into the binary via an SPM build plugin. At build time, the plugin reads JSON config files and generates a Swift source file that's linked into the binary. Changing config requires a rebuild — intentional friction against runtime tampering.
Built with OpenClaw + Claude + Swift + macOS Keychain.