Deterministic approval layer between AI agents and external actions.
Agents propose. Humans approve. Scripts execute. No AI in the execution path.
AI agents with email and API access are a prompt injection timebomb. Current mitigations:
- "Trust the system prompt" — behavioral, not structural. Fails under injection.
- "Restrict the agent's tools" — if it can draft AND confirm, it can still send anything.
- "Remove access entirely" — then what's the point of having an agent?
agent-gate enforces a structural control plane that no amount of prompt injection can bypass.
┌─────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ AI Agent │────▶│ Draft Queue │────▶│ Telegram Bot │────▶│ Executor │
│ (any stack) │ │ (JSON files) │ │ (approval UX) │ │ (providers) │
└─────────────┘ └──────────────┘ └──────────────┘ └──────────────┘
writes watches human reviews sends/executes
draft files for new files approve / deny approved drafts
- Your agent writes a JSON draft file to an inbox directory.
- agent-gate picks it up and sends a preview to your Telegram.
- You tap Approve or Deny.
- If approved, a deterministic script executes exactly what you reviewed — no AI involved.
This isn't "we told the AI to be careful." It's structural:
- Process isolation — agent-gate runs as a separate OS user. The AI agent cannot read, modify, or delete drafts after submission.
- Write-only inbox — the agent can drop files in but cannot read or list the directory (Unix dropbox permissions:
1730). - Hash-verified approvals — SHA-256 hash is computed at preview time and embedded in the approve button. If the draft is modified between preview and approval, the approval is rejected.
- From-address enforcement — the
fromfield in drafts is ignored. The configured sender address is always used. Prevents spoofing. - No AI in execution — the executor reads the approved file directly. No LLM processes, summarizes, or touches the content.
- Out-of-band approval — the Telegram bot has its own token and runs independently. The AI agent has no access to it.
- Schema validation — Zod schemas enforce bounds on all fields (subject: 500 chars, body: 256KB, tags: 20 max).
- Symlink rejection — only regular files are accepted via
lstat(). Symlinks, devices, sockets, and directories are rejected. - Sanitized errors — no raw API responses or stack traces leak to Telegram or audit logs.
git clone https://github.com/404Dealer/agent-gate.git
cd agent-gate
npm install
cp config.example.yaml config.yaml
# Edit config.yaml with your bot token and provider credentials
npm run build
npm start- Message @BotFather on Telegram
- Send
/newbotand follow the prompts - Copy the bot token into your
config.yaml - Get your Telegram user ID (message @userinfobot) and add it to
allowedUsers - Send
/startto your new bot
npm run dev # Runs with tsx, no build step neededIf your agent can write a JSON file, it can use agent-gate. No SDK, no API client, no runtime dependency.
Drop a .json file into the configured inbox directory:
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "email",
"status": "pending",
"createdAt": "2026-01-15T10:30:00Z",
"updatedAt": "2026-01-15T10:30:00Z",
"source": "my-agent",
"provider": "zoho",
"payload": {
"from": "ignored@example.com",
"to": "recipient@example.com",
"subject": "Follow-up on our conversation",
"body": "<p>Hi, just following up on our meeting yesterday.</p>"
},
"metadata": {
"context": "User asked me to follow up after yesterday's call",
"priority": "normal",
"tags": ["follow-up"]
}
}That's it. agent-gate handles the rest.
| Framework | Integration |
|---|---|
| OpenClaw | Write draft file from agent session |
| LangChain | Custom tool that writes JSON |
| AutoGen | Function call that drops a file |
| Claude Code / Codex | Shell command to write JSON |
| Bash scripts | cat > drafts/inbox/draft.json << 'EOF' |
| Anything else | If it can write a file, it works |
| Field | Type | Required | Notes |
|---|---|---|---|
id |
UUID string | ✅ | Unique draft identifier |
type |
"email" | "webhook" |
✅ | Action type |
status |
"pending" |
✅ | Must be "pending" for new drafts |
createdAt |
ISO 8601 | ✅ | |
updatedAt |
ISO 8601 | ✅ | |
source |
string | ✅ | Identifies which agent/system created this |
provider |
string | ✅ | Provider key from your config |
payload |
object | ✅ | Provider-specific (see below) |
metadata.context |
string | Why the agent drafted this (shown in preview) | |
metadata.priority |
string | "normal", "high", etc. |
|
metadata.tags |
string[] | Up to 20 tags |
| Field | Type | Required | Limits |
|---|---|---|---|
to |
string or string[] | ✅ | Valid email(s) |
subject |
string | ✅ | Max 500 chars |
body |
string | ✅ | Max 256KB, HTML supported |
cc |
string[] | ||
bcc |
string[] | ||
replyTo |
string |
Note: The
fromfield is accepted but ignored — the configuredfromAddresson the provider is always used.
Status flow: pending → approved / denied → sent / failed
telegram:
botToken: "${AGENT_GATE_BOT_TOKEN}"
allowedUsers: [123456789] # Your Telegram user ID(s)
watch:
directory: "./drafts/inbox"
pollIntervalMs: 2000
providers:
zoho:
type: "email-zoho"
clientId: "${ZOHO_CLIENT_ID}"
clientSecret: "${ZOHO_CLIENT_SECRET}"
refreshToken: "${ZOHO_REFRESH_TOKEN}"
accountId: "${ZOHO_ACCOUNT_ID}"
fromAddress: "you@yourdomain.com"
log:
type: "log-only"
defaults:
provider: "log"
timezone: "UTC"
autoDeleteAfterDays: 30
audit:
enabled: true
logFile: "./audit.log"Config placeholders support two resolvers:
| Syntax | Source | Example |
|---|---|---|
${VAR_NAME} |
Environment variable | ${AGENT_GATE_BOT_TOKEN} |
${PASS:path} |
pass (Unix password manager) | ${PASS:agent-gate/bot-token} |
Unresolved placeholders cause a hard failure at startup. No silent empty strings.
Dry-run provider. Logs the payload to stdout, sends nothing. Use for testing and development.
Sends email via the Zoho Mail API using OAuth refresh token flow.
| Config Key | Description |
|---|---|
clientId |
Zoho API Console client ID |
clientSecret |
Zoho API Console client secret |
refreshToken |
Permanent refresh token (ZohoMail.messages.CREATE scope) |
accountId |
Zoho account ID |
fromAddress |
Enforced sender address (overrides draft from) |
Implement the Provider interface and register it:
// src/providers/my-provider.ts
import type { Draft } from '../schema.js';
import type { Provider, ProviderResult } from './index.js';
export class MyProvider implements Provider {
async send(draft: Draft): Promise<ProviderResult> {
// Send the draft however you want
return { details: 'Sent via my service' };
}
}Register in src/providers/index.ts. PRs for new providers welcome.
When a draft arrives:
📧 New Email Draft
From: you@yourdomain.com
To: recipient@example.com
Subject: Follow-up on our conversation
─────────────
Hi, just following up on our meeting yesterday.
─────────────
Source: my-agent
Context: User asked me to follow up
Priority: normal
[✅ Approve] [❌ Deny]
- Approve → hash verified → executed →
✅ APPROVED at 3:45 PM - Deny → draft archived →
❌ DENIED at 3:45 PM
If the draft was modified after the preview was sent, approval is rejected with a warning.
For maximum isolation, run agent-gate as a dedicated system user:
- Create a service user — no login shell, locked home directory
- Set up credentials — dedicated
passstore for the service user - Inbox as dropbox — sticky bit + group write, no read (
1730) - systemd service — hardened with
NoNewPrivileges,ProtectSystem=strict, restricted address families - Audit log — read-only ACL for your main user
See docs/deployment.md for the complete production hardening guide with copy-paste commands.
agent-gate/
├── src/
│ ├── index.ts # Entry point
│ ├── config.ts # Config loader + secret resolution
│ ├── watcher.ts # File watcher (inbox → pending)
│ ├── bot.ts # Telegram bot (previews + callbacks)
│ ├── executor.ts # Reads approved drafts, dispatches to providers
│ ├── schema.ts # Zod schemas + validation
│ └── providers/
│ ├── index.ts # Provider registry
│ ├── email-zoho.ts # Zoho Mail API
│ └── log-only.ts # Dry-run logger
├── drafts/ # Draft queue directories
│ ├── inbox/ # Public dropbox (agents write here)
│ ├── pending/ # Internal (watcher moves files here)
│ ├── approved/ # Human-approved
│ ├── sent/ # Successfully executed
│ ├── denied/ # Human-denied
│ └── failed/ # Validation or send errors
├── docs/
│ └── deployment.md # Production hardening guide
├── config.example.yaml
├── package.json
├── tsconfig.json
└── LICENSE
- ✅ File-based draft queue (inbox → pending → approved → sent)
- ✅ Telegram bot with inline approve/deny buttons
- ✅ SHA-256 hash-verified approvals
- ✅ Zoho Mail email provider
- ✅ Log-only dry-run provider
- ✅
${PASS:key}and${ENV}secret resolvers - ✅ Schema validation with size/count bounds
- ✅ JSON audit logging
- ✅ Symlink/device file rejection
- ✅ From-address enforcement
- ✅ Sanitized error handling
- Edit flow — modify drafts in Telegram before approving
- Gmail provider
- Generic SMTP provider
- Webhook provider (for non-email actions: Slack, Discord, APIs)
- Bulk approve/deny
- Draft expiry (auto-deny after configurable timeout)
- Rate limiting / cooldowns
- Web dashboard for audit trail
"Agents propose, humans approve via out-of-band channel, deterministic scripts execute."
The agent security space is full of behavioral guardrails — system prompts, content filters, output classifiers. These are valuable but fundamentally brittle: they depend on model compliance, which prompt injection can subvert.
agent-gate takes a different approach: structural security. The approval gate is a separate process, running as a separate user, with its own credentials. No amount of prompt injection can make the AI agent approve its own drafts, because the approval channel is physically separated from the agent's runtime.
This is the same principle behind air-gapped networks, hardware security modules, and two-person integrity controls — applied to AI agents.
The skill/ directory contains an agent skill (compatible with OpenClaw, NanoClaw, and any framework that uses SKILL.md-based skill loading):
skill/
├── SKILL.md # Instructions for the AI agent
└── scripts/
└── draft-email.sh # Helper script for drafting emails
The skill teaches an AI agent how to write properly-formatted draft files. The agent learns the schema, constraints, and workflow — then uses sg agentgate-inbox (or the helper script) to drop drafts into the inbox.
Install the skill in your agent framework, point it at your agent-gate inbox, and your agent can propose emails that you approve via Telegram.
See CONTRIBUTING.md for guidelines.
MIT — see LICENSE