Implement a self-hosted approval gate for agent actions with side effects. Humans approve or deny with a single reply via Telegram or Email. The gate returns approved/denied and optional note/override for the agent to continue.
Constraints:
- No frontend; Telegram/Email only.
- Single reply (no multi-turn dialog unless reply is invalid).
- Support session allow and permanent allow rules (TTL not required).
- Default self-host, no public service.
Lifecycle: pending -> approved | denied | expired
Fields:
approval_id: string likeappr_...created_at,expires_atstatus:pending | approved | denied | expiredsession_id: agent run/session identifieraction_type: coarse action typetitle: short titlepreview: human-facing previewchoices: fixed menu (see below)decision_code: final decision codenote: optional human noteoverride: optional replacement textchannel:telegram | emailtarget: telegram chat id or email addressclient_id: derived from API keyallow_rule_applied: rule_id if auto-approved via allow rule
Simple rule: permanently allow client_id + action_type.
Fields:
rule_idclient_idaction_typeenabledcreated_at
Match logic:
- On
POST /v1/approvals, if a matching enabled rule exists, returnapprovedand do not send a message.
Lightweight allowlist for client_id + session_id + action_type.
Fields:
client_idsession_idaction_typecreated_at
Match logic:
- On
POST /v1/approvals, if a matching session allow exists, returnapprovedand do not send a message. - When human selects menu option 2, insert a session allow.
Suggested coarse types:
exec_cmdhttp_requestwrite_filesend_messagecustom:<tool_or_skill_name>
Fixed menu (do not change per request):
- Allow once
- Allow for this session
- Deny
- Allow once + add note (reply: 4 )
- Modify then allow (reply: 5 )
- Always allow this action type (until revoked)
Parsing:
- Trim whitespace.
- First token is the code (1..6).
- Remainder is payload_text.
- Codes 4/5 require payload_text; otherwise invalid.
Semantics:
1: allow once2: allow for this session (server stores session allow)3: deny4: allow once + note (note stored)5: allow once with override (override stored)6: create allow rule for client_id + action_type
Override semantics: gate returns the raw override string to the agent; it does not interpret it.
- Send approval message with: title, preview, menu, approval_id, expires_at.
- Inline buttons cover 1/2/3/6 (no text input needed).
- Options 4/5 are provided via replying with text (e.g.,
4 ...). - Bot message includes approval_id and asks user to reply to the message.
- Send approval email: subject includes
[appr_xxx], body includes preview, menu, approval_id, expires_at. - Users reply with a single line like
1or4 add logs. - Parse only the first text block of the reply (truncate quoted text and signature).
- Approval id is extracted from subject or body.
Authentication:
Authorization: Bearer <APPROVAL_API_KEY>client_id = sha256(api_key)[:12]
Create approval.
Request:
{
"session_id": "sess_123",
"action_type": "exec_cmd",
"title": "Run command",
"preview": "rm -rf ./build && npm run build",
"channel": "telegram",
"target": { "tg_chat_id": "123456789" },
"expires_in_sec": 600
}Email:
{
"session_id": "sess_123",
"action_type": "http_request",
"title": "POST request",
"preview": "POST https://api.example.com/pay ...",
"channel": "email",
"target": { "email_to": "you@domain.com" },
"expires_in_sec": 600
}Response (auto-approved via allow rule or session allow):
{
"approval_id": "appr_xxx",
"status": "approved",
"auto": true,
"decision": { "code": "6" }
}Response (pending):
{
"approval_id": "appr_xxx",
"status": "pending",
"auto": false,
"expires_at": 1730000000
}Query approval status.
Pending:
{ "status": "pending", "expires_at": 1730000000 }Approved:
{
"status": "approved",
"decision": { "code": "2", "note": null, "override": null },
"session_id": "sess_123",
"action_type": "exec_cmd"
}Approved with note:
{ "status": "approved", "decision": { "code": "4", "note": "add logs", "override": null } }Approved with override:
{ "status": "approved", "decision": { "code": "5", "note": null, "override": "npm test" } }Accept email replies from a forwarding service.
Request:
{ "subject": "Run command [appr_xxx]", "body": "4 add logs\n\nOn Tue..." }Revoke a permanent allow rule.
Default storage: SQLite (data.db) with SQLAlchemy. Postgres-compatible by swapping the URL.
Tables:
approvalsallow_rulessession_allows
- Unit: menu parsing, email truncation, allow rule matching, session allow matching.
- Integration: create approval -> simulate reply -> get status.
- Email adapter: send via local SMTP debug server.
- E2E:
scripts/e2e_demo.py(create approval -> simulate reply -> query status).