Architecture change: DM-First (v2) replaces @mention-in-stream (v1) as primary agent interaction channel. See ADR-001-dm-topology and ADR-002-dm-routing. Key changes: - New DM-first Zulip adapter (adapter_dm.py) deployed on CT 112 - Registers empty narrow to receive ALL events - Demuxes DMs (type: 'private') vs stream events - Replies to DMs back in private thread, stream replies to #agent-hub - Uses blocking long-poll (dont_block=False) to eliminate rate limiting - Rewritten ARCHITECTURE.md, CONTEXT.md, PRD.md for v2 DM-First - New ADRs: ADR-001 (DM Topology), ADR-002 (DM Routing) - Archived old ADRs: 001, 002, 005 (topic-topology, mention-routing, mention-detection) - Updated ADRs: 003 (agent-naming), 004 (per-agent-bots), 006 (all-bots-model) - Updated PI extension (index.ts) with DM-aware patterns - Tanko agent confirmed responding in both DM and @all-bots in #agent-hub
13 KiB
Product Requirements Document: Zulip Platform Plugins
Status: Draft v2.0 Date: 2026-06-21 Authors: Agent Zero (via Tabiri infrastructure)
Problem Statement
The Sysloggh agent mesh consists of 6 autonomous agents across 3 distinct frameworks (Hermes Python, Agent Zero, PI TypeScript) deployed on 5 Proxmox CTs. These agents operate in isolation — there is no unified inter-agent communication channel. Each agent processes tasks independently, and cross-agent coordination requires manual intervention.
The existing Zulip infrastructure (Zulip 12.0-1 on CT 117, Docker-based) provides a mature async messaging platform with built-in DM and stream support. All 6 agent bot users exist on the server and #agent-hub stream is created. Only Tanko (v1 Hermes native Zulip) is operational.
The gap: Agents cannot reliably receive user requests or communicate via Zulip DMs.
Solution
A multi-platform Zulip agent communication layer where each agent communicates primarily via Zulip Direct Messages (DMs). Agents also subscribe to #agent-hub stream for @all-bots broadcasts only.
Architecture at a Glance
┌─────────────────────────────────────────────────────────────┐
│ Zulip Server (CT 117) │
│ chat.sysloggh.net:443 │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Each Agent Has Two Channels: │ │
│ │ │ │
│ │ DM (PRIMARY) #agent-hub (BROADCAST) │ │
│ │ ┌──────────────────┐ ┌──────────────────┐ │ │
│ │ │ Direct 1:1 chat │ │ @all-bots only │ │ │
│ │ │ No @mention │ │ Cross-agent msgs │ │ │
│ │ │ No topic parsing │ │ Broadcast only │ │ │
│ │ └──────────────────┘ └──────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ Bot Users: tanko-bot, mumuni-bot, koonimo-bot, koby-bot, │
│ kagentz-bot, abiba-bot │
│ (No dedicated @all-bots bot user — content-based match) │
└─────────────────────────────────────────────────────────────┘
Core Principles
- DM-first: Agent-owner communication uses Zulip DMs exclusively — no @mention parsing, no topic routing, no
mentioned_usersdependency - #agent-hub for broadcast only: The
#agent-hubstream is subscribed solely for detecting@all-botsnotifications - Co-located: Each plugin runs on the same CT as its agent, uses platform-native interfaces
- Owner-gated: Each bot DM is gated by API key — only the bot's credentials grant access
- Version-controlled: All plugin code lives in a Gitea monorepo, pushed to CTs via deploy script
- Graceful degradation: Users always receive feedback, even when an agent is unavailable
User Stories
Agent Communication
-
As a human user, I want to DM an agent's bot directly, so that the agent receives my message and responds in-thread.
-
As a human user, I want to mention @all-bots in #agent-hub, so that all 6 agents across all frameworks receive the broadcast.
-
As an agent, I want to receive DMs addressed to me, so that I can process and respond without parsing @mentions or stream context.
-
As an agent, I want to detect @all-bots broadcasts in #agent-hub, so that I can participate in multi-agent coordination.
Deployment & Operations
-
As a system administrator, I want to version-control all plugin code in a single Gitea repository, so that updates are tracked, auditable, and pushable across all CTs.
-
As a system administrator, I want a deploy script that pulls the latest plugin code and restarts services on all CTs, so that updates are consistent and repeatable.
Reliability
-
As a human user, I want to see a friendly error message if an agent is unavailable, rather than a silent timeout.
-
As a system administrator, I want health check endpoints on each plugin, so that I can monitor plugin status from the harness or monitoring infra.
Architecture Decisions
DM-First Communication
Each agent communicates via Zulip Direct Messages. When a user sends a DM to an agent's bot:
User DMs @tanko-bot
→ Zulip pushes event ONLY to tanko-bot's event queue
→ Event has: {type: 'message', message: {type: 'private', ...}}
→ Bot detects message.type === 'private'
→ No @mention parsing, no topic/stream capture needed
→ Bot processes message.content
→ Bot replies to same DM via:
{type: 'private', to: [user_id], content: 'response'}
Advantages over stream-based @mention:
- Only the recipient bot receives the DM — no broadcast noise to other 5 agents
- No
mentioned_usersfield dependency (unreliable in Zulip 12.0) - No topic routing — DMs are inherently 1:1 threading
message.type: 'private'is trivially detectable
@all-bots Broadcast
@all-bots in #agent-hub uses content-based detection (regex) — no dedicated bot user needed:
User posts in #agent-hub > topic:
"@**all-bots** status report please"
→ Each bot receives event via #agent-hub subscription
→ Each bot checks: /@\*\*(?:all-bots|All Bots)\*\*/i.test(content)
→ If match: bot forwards to its agent
→ 6 independent responses appear in the topic
Plugin Architecture
| Framework | Plugin Type | Interface | DM Detection | @all-bots Detection |
|---|---|---|---|---|
| Hermes Python (4 agents) | Gateway PlatformAdapter | on_event(message) |
message.type === 'private' |
Regex on stream events |
| PI TypeScript (1 agent) | PI Extension | Extension API | event.type === 'private' |
Regex on stream events |
| Agent Zero (1 agent) | A0 Plugin | A0 plugin system | message.type === 'private' |
Regex on stream events |
Config Schema (per-agent config.yaml)
# config.yaml (one per bot, stored alongside plugin)
agent:
name: "Tanko"
bot_username: "tanko-bot"
bot_email: "tanko-bot@chat.sysloggh.net"
bot_api_key: "<api_key>"
owner_email: "jerome@sysloggh.net"
zulip:
server_url: "https://chat.sysloggh.net"
stream: "agent-hub" # Only used for @all-bots broadcasts
client:
type: "sdk" # "sdk" for zulip-js/zulip-python, "rest" for raw API
error_handling:
timeout_seconds: 30
retry_count: 3
graceful_message: "{{agent_name}} is processing your request — please wait..."
monitoring:
health_endpoint_enabled: true
health_port: 9200
Implementation Decisions
Platform Plugin Contracts
Each of the 3 frameworks uses its native plugin system — not a unified bridge or A2A overlay for intra-agent communication. Cross-framework @all-bots messages flow through Zulip: all 6 bots are subscribed to #agent-hub stream. Each bot checks for @all-bots via content-based regex match, then processes.
DM Event Processing
# Simplified DM processing logic (all platforms)
def on_event(event):
message = event["message"]
# DM case (primary)
if message["type"] == "private":
user_id = message["sender_id"]
content = message["content"]
agent.process(content, reply_to=lambda resp: send_dm(user_id, resp))
return
# Stream case — only process if @all-bots
if message["type"] == "stream":
if re.search(r'@\*\*(?:all-bots|All Bots)\*\*', message["content"]):
agent.process_broadcast(message)
# Ignore all other stream messages
Error Handling Strategy
- Agent timeout: plugin sends graceful degradation message to DM thread (ADR-009)
- Zulip server down: SDK auto-reconnects with exponential backoff
- Invalid message format: plugin logs error, does not crash
- Health endpoint (
/healthon port 9200): returns status, uptime, last_message_time
Naming Convention
- Bot users:
{name}-bot(lowercase, dash separator) —tanko-bot,mumuni-bot, etc. (ADR-003) - DM address:
@**tanko-bot**(same format as @mention) - Display names: capitalize properly — "Tanko Bot", "Mumuni Bot"
Testing Decisions
Unit Tests
- DM detection:
message.type === 'private'correctly triggers processing - @all-bots detection: regex matches
@**all-bots**and@**All Bots**in stream content - Config parsing: all fields present, graceful defaults
- Error response: timeout triggers correct grace message
Integration Tests
- Each plugin receives DM and sends reply via Zulip API
- @all-bots message in #agent-hub reaches all 6 bots
- Non-@all-bots messages in #agent-hub are correctly ignored
- Health endpoint returns 200 with valid JSON
- Plugin survives container restart (Zulip reconnection)
Deployment Tests
deploy.shsuccessfully pulls latest and restarts services on all CTs- Config YAML validation before deployment
Out of Scope (v1.0)
- Multi-realm Zulip: All agents operate within a single Zulip realm
- Message attachments: v1 sends and receives text only (no file uploads)
- Rich formatting: v1 uses plain markdown responses from agents
- A2A as primary transport: Cross-platform communication routes through Zulip, not direct A2A
- CI/CD pipeline: Deployment is script-based (
deploy.sh), not CI/CD - Rate limiting: Zulip's built-in rate limiting is sufficient for initial use
- Dashboard/monitoring UI: Health endpoint data consumed by existing harness infra
- Message history search: Zulip's native search handles this
Design Decisions Summary
| # | Decision | Rationale |
|---|---|---|
| ADR-001 | DM-first topology | DMs are simpler, more reliable, and require no @mention parsing |
| ADR-002 | DM-based routing (not @mention) | message.type === 'private' is trivially detectable, no mentioned_users dependency |
| ADR-003 | Bot naming: {name}-bot |
Consistent, no space issues in Zulip API |
| ADR-004 | One bot per agent, co-located on CT | Isolated failures, platform-native |
| ADR-005 | [Archived] @mention via SDK — no longer needed | DM-first eliminates @mention dependency entirely |
| ADR-006 | @all-bots via content-based regex | No dedicated bot user needed, works across Zulip versions |
| ADR-007 | Platform-native plugin contracts | Reliability, platform handles state/retries |
| ADR-008 | Native message formats per platform | No translation layer needed |
| ADR-009 | Graceful degradation + user-visible errors | No silent failures |
| ADR-010 | Gitea monorepo + deploy.sh | Single source of truth, versioned |
| ADR-011 | Owner in per-agent config.yaml | Explicit, version-controllable |
| ADR-012 | @all-bots spans all frameworks | Single @all-bots reaches every agent |
Prerequisites
- Zulip server operational at
https://chat.sysloggh.net - Zulip bot users created (6 total: tanko-bot, mumuni-bot, koonimo-bot, koby-bot, kagentz-bot, abiba-bot)
#agent-hubstream created and all bots subscribed- Gitea repo
zulip-platform-pluginscreated and monorepo pushed - Hermes gateway framework verified on Tanko/Mumuni/Koonimo/Koby CTs
- PI extension API verified on Abiba CT
- Agent Zero plugin system verified on Kagentz CT
Deployment Workflow
1. Developer pushes plugin changes to Gitea repo
2. Run: ./scripts/deploy.sh
3. deploy.sh SSHes into each CT:
a. git pull
b. Install/update dependencies
c. Restart plugin service (systemd or supervisor)
4. Verify health endpoint on each CT
Risk Mitigation
- Template bug in Zulip 12.0-1: Portico-header renders 500 on configs without
REGISTRATION_OPENor with Authentik SSO — resolved via template patch{% if settings.REGISTRATION_OPEN %}guard - Docker container restarts: Plugin auto-reconnects via Zulip SDK's built-in reconnect
- API key rotation: Keys stored in plugin config, updated via Gitea push