# 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:

```mermaid
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:

1. **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.

2. **Live trigger registration** — When a kind 31123 or 31124 event arrives with `trigger=nostr-subscription` and a valid `filter` tag, `register_trigger_from_self_skill_event()` immediately calls `trigger_manager_add()` to create a persistent trigger subscription. This means skills published from any client are automatically activated without restart.

3. **Deferred bulk load** — After EOSE, the `on_self_skill_eose` callback fires `trigger_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()` parses `startup_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_create` tool 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

```mermaid
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 |

## Related Documentation

- [Skills](SKILLS.md) — Skill event format and trigger tags
- [Tools](TOOLS.md) — `skill_create` tool with trigger parameters
- [API](API.md) — `trigger_list` and `trigger_status` endpoints
