Didactyl — Nostr Subscriptions
Didactyl — Nostr Subscriptions
Overview
Didactyl maintains persistent websocket subscriptions to Nostr relays for the lifetime of the process. Subscriptions are opened during startup and are never closed — the relay pool keeps them alive, automatically reconnecting and resubscribing when relays drop.
All subscriptions are created via nostr_relay_pool_subscribe() from nostr_core_lib and are sent to every relay in the configured relay list.
Startup Sequence
The subscriptions are opened in a specific order during main() startup. The diagram below shows the full sequence:
flowchart TD
A[nostr_handler_init] --> B[Connect to all relays]
B --> C[nostr_handler_reconcile_startup_events]
C --> D[Publish startup events to relays]
D --> E[trigger_manager_init]
E --> F[trigger_manager_load_from_startup_events]
F --> G["Subscribe: Admin Context"]
G --> H["Subscribe: Self Skills"]
H --> I[Send startup DM to admin]
I --> J["Subscribe: DMs"]
J --> K[Enter main poll loop]
K --> L["Poll: nostr_handler_poll + trigger_manager_poll + http_api_poll"]
style F fill:#2a7,stroke:#333,color:#fff
style G fill:#27a,stroke:#333,color:#fff
style H fill:#27a,stroke:#333,color:#fff
style J fill:#27a,stroke:#333,color:#fff
Subscription Categories
1. Admin Context Subscription
Function: nostr_handler_subscribe_admin_context() in src/nostr_handler.c
When: During startup, before self-skill subscription
Condition: Only if admin_context.enabled is true in config
This creates up to two persistent subscriptions for the admin’s pubkey:
Profile Subscription
| Field | Value |
|---|---|
| Kinds | 0 (profile), 3 (contacts), 10002 (relay list) — each configurable |
| Authors | Admin pubkey |
| Limit | 32 |
| Callback | on_admin_context_event |
| Dedup | Enabled |
| Close on EOSE | No |
Tracks the admin’s profile metadata, contact list (WoT), and relay preferences. Used to build agent context about who the admin is.
Kind 1 Notes Subscription
| Field | Value |
|---|---|
| Kinds | 1 |
| Authors | Admin pubkey |
| Limit | Configurable via kind_1_limit, default 10, max 256 |
| Callback | on_admin_context_event |
| Dedup | Enabled |
| Close on EOSE | No |
| Condition | Only if admin_context.track_kind_1 is true |
Tracks the admin’s recent public notes. Used for agent context and as the event source for triggered skills that watch admin posts.
2. Self-Skill Subscription
Function: nostr_handler_subscribe_self_skills() in src/nostr_handler.c
When: During startup, after admin context subscription
| Field | Value |
|---|---|
| Kinds | 31123 (public skill), 31124 (private skill), 10123 (adoption list) |
| Authors | Agent’s own pubkey |
| Limit | 300 |
| Callback | on_self_skill_event |
| EOSE Callback | on_self_skill_eose |
| Dedup | Disabled (handles dedup internally via cache upsert) |
| Close on EOSE | No |
This is the core skill awareness subscription. It serves three purposes:
-
Cache population — Every arriving event is stored in the in-memory self-skill cache via
self_skill_cache_upsert_event_locked(), making skills available for LLM tool calls and context building. -
Live trigger registration — When a kind 31123 or 31124 event arrives with
trigger=nostr-subscriptionand a validfiltertag,register_trigger_from_self_skill_event()immediately callstrigger_manager_add()to create a persistent trigger subscription. This means skills published from any client are automatically activated without restart. -
Deferred bulk load — After EOSE, the
on_self_skill_eosecallback firestrigger_manager_load_from_skills()as a one-time bulk scan of the adoption list. This catches any skills that were already cached before the per-event path was wired up.
3. DM Subscriptions
Function: nostr_handler_subscribe_dms() in src/nostr_handler.c
When: During startup, after self-skill subscription and startup DM
Required: Yes — startup fails if DM subscription cannot be created
Creates one or two subscriptions depending on the configured DM protocol:
NIP-04 DM Subscription
| Field | Value |
|---|---|
| Kinds | 4 |
| #p | Agent’s own pubkey |
| Since | Process start time |
| Limit | 100 |
| Callback | on_event (routes to agent_on_message) |
| Dedup | Disabled (handled by dm_id_seen_or_remember) |
| Close on EOSE | No |
| Condition | dm_protocol is nip04 or both |
NIP-17 DM Subscription
| Field | Value |
|---|---|
| Kinds | 1059 (gift wrap) |
| #p | Agent’s own pubkey |
| Since | Process start time |
| Limit | 400 |
| Callback | on_event (unwraps gift wrap, routes to agent_on_message) |
| Dedup | Disabled (handled by dm_id_seen_or_remember) |
| Close on EOSE | No |
| Condition | dm_protocol is nip17 or both |
4. Trigger Subscriptions
Function: register_trigger_subscription_locked() in src/trigger_manager.c
When: Dynamically, whenever a trigger is registered via trigger_manager_add()
Created by: nostr_handler_subscribe_with_filter() wrapper
Each active trigger gets its own persistent subscription based on the skill’s filter tag:
| Field | Value |
|---|---|
| Filter | Parsed from the skill’s filter tag JSON |
| Since | From filter, or defaults to now - 30s |
| Limit | From filter, or defaults to 200 |
| Callback | on_trigger_subscription_event |
| Dedup | Enabled |
| Close on EOSE | No |
When an event matches the filter, maybe_fire_trigger_locked() checks cooldown and dedup, then executes the trigger action (LLM or template).
Trigger subscriptions are created at three points:
- Startup config scan —
trigger_manager_load_from_startup_events()parsesstartup_events[]from config for skills with trigger tags - Live self-skill event —
register_trigger_from_self_skill_event()in the self-skill subscription callback - EOSE bulk load —
trigger_manager_load_from_skills()after self-skill EOSE - Runtime tool call —
skill_createtool with trigger parameters
Subscription Parameters
All subscriptions share these common pool parameters:
| Parameter | Value | Meaning |
|---|---|---|
close_on_eose |
0 | Subscription stays open after initial EOSE |
result_mode |
NOSTR_POOL_EOSE_FULL_SET |
EOSE fires after all relays respond or timeout |
relay_timeout_seconds |
30 | Per-relay timeout for initial response |
eose_timeout_seconds |
120 | Overall EOSE timeout across all relays |
Subscription Lifecycle
flowchart LR
INIT["Process Start"] --> CONNECT["Connect Relays"]
CONNECT --> SUB["Open Subscriptions"]
SUB --> LIVE["Live Event Stream"]
LIVE --> |"Relay disconnects"| RECON["Auto-Reconnect"]
RECON --> |"Relay reconnects"| RESUB["Auto-Resubscribe"]
RESUB --> LIVE
LIVE --> |"SIGINT/SIGTERM"| SHUT["Shutdown"]
SHUT --> CLOSE["Close All + Cleanup"]
Subscriptions are never manually closed during normal operation. The relay pool handles reconnection and resubscription transparently. On shutdown, trigger_manager_cleanup() closes trigger subscriptions and nostr_handler_cleanup() destroys the pool.
Summary Table
| Subscription | Kinds | Target | Persistent | Created At |
|---|---|---|---|---|
| Admin Profile | 0, 3, 10002 | Admin pubkey | Yes | Startup |
| Admin Notes | 1 | Admin pubkey | Yes | Startup |
| Self Skills | 31123, 31124, 10123 | Own pubkey | Yes | Startup |
| DMs NIP-04 | 4 | Own pubkey (#p) | Yes | Startup |
| DMs NIP-17 | 1059 | Own pubkey (#p) | Yes | Startup |
| Trigger N | Per skill filter | Varies | Yes | Dynamic |
Write a comment