navi speaks to the GNEXUS Synapse notification hub in both directions:
The gnexus-synapse package (vendored dependency) owns the wire format: verify_webhook / ack / make_signature on the receiving side, AsyncSynapseClient on the emitting side.
| Env var | Purpose |
|---|---|
SYNAPSE_SOURCE_URL |
Synapse base URL for the outgoing source API |
SYNAPSE_SOURCE_API_KEY |
syn_* key registered in the Synapse admin panel |
SYNAPSE_SOURCE_NAME |
source name shown in Synapse (default navi) |
Without a source key nothing outgoing is sent, and Synapse-linked UI options are disabled (source_ready: false in settings responses).
Inbound deliveries need a per-user target: a token_ref + shared secret created in the Synapse admin panel and registered by the user in navi (POST /synapse-targets).
POST /webhooks/synapse (navi/api/routes/synapse.py) — verifies the signature against every active target secret (a mismatch never tells which target failed → 401), dedupes by event_id (Synapse retries until 2xx; replays are re-acked but never acted on twice), logs synapse.delivery_accepted, and hands the envelope to schedule_reaction() fire-and-forget, so the ack never waits for agent work.
navi/synapse/reactions.py — the point of the feature. Pipeline:
reactions_enabled: true; otherwise the event is accepted and discarded (synapse.reaction_disabled).dispatcher profile (navi/profiles/dispatcher/, temperature 0.2, one shot, no tools). Input: the user's dispatcher instructions (routing — which kind of event goes to which profile, and what counts as one conversation, i.e. how to derive the conversation key), the user's reaction instructions (what to do), and the event envelope. Output: strict JSON {profile_id, task, understood, session_key} or {skip: true}. session_key is null when the event opens a new thread; unparseable output or a backend failure = skip (logged, never a crash).is_admin_only (a reaction run carries role user, so landing on one would hand that user a wider tool surface than their own profile list offers), fall through to the first user-visible profile. The configured fallback secretary is admin-only, so for a regular user it is never the answer. The check runs on every event, continuation included.session_key and a positive continuation window (reaction_session_ttl_minutes), the runner looks for the newest special=True session of that user whose session_metadata["synapse"]["session_key"] matches and whose last_active is inside the window (SessionStore.find_reaction_session; postgres serves it from a partial index). A found session is reused: the event is appended to session_metadata["synapse"]["events"] (last 20 kept), the name is left alone, and the profile is switched through the narrow PgSessionStore.set_profile() — save() never rewrites name/profile_id. A busy session (a run in flight — create_run would overwrite state.run and orphan the first run's subscribers) is not reused; neither is an expired one. Otherwise a fresh session is created, named Synapse: {event_type}, and session_metadata["synapse"] starts with the event id/type/dispatcher's understanding/key. Find-then-claim is serialised by a module-level lock, so two events of one conversation never race into two sessions. What lands in the transcript is a system record — role="system", metadata["source"] == "synapse_event", is_display=True, holding exactly the dispatcher's task text (no preamble, no envelope, no instructions) — plus a hidden role="user" turn carrying the framing and the envelope (is_display=False). Both are persisted before the run, so a crash mid-run still leaves a visible trail, and a session_sync broadcast shows the record to a user watching the session live.create_run + run_agent(hidden=True) with the target user's tool context and the reaction instructions in the run-scoped current_reaction_instructions ContextVar. From there they reach the system prompt ([Reaction session] in ContextBuilder.build()) and never the transcript. The background framing is written only when the session is fresh — a continued thread does not repeat it. No live subscriber, but the run registry means the user watching the session in the UI sees the stream live.completion_notify (always / important = only when the run had errors / never) and a low-level reaction.finished / reaction.failed event to Synapse (best effort).navi/synapse/outbound.py — emit_low_level(subject, action, payload, priority, dedup_key): silently no-ops when the source is not configured, otherwise emits through AsyncSynapseClient and closes it per call.
Users of it: the reaction runner (reaction, finished/failed) and the notify tool (navi-notification, action = push level).
navi/tools/notify.py — the agent's signal channel. Parameters: message (required) + level (info | warning | intervention). Reads the user's push_target and delivers through both legs, then answers honestly: Delivered: app. Skipped: synapse (source key not configured) — the agent can see what actually happened and never retries into a wall. Priorities map info→normal, warning→high, intervention→critical.
navi/synapse/settings_store.py (postgres synapse_settings, synapse_instruction_versions):
reactions_enabled (default false),push_target: app | app_synapse | synapse (default app),completion_notify: always | important | never (default important),reaction_session_ttl_minutes (default 1440): how long a conversation may sit idle and still be continued; 0 = never continue, every event starts a session,instructions — reaction instructions: what to do with an event. Editable by both the user and navi (synthesize_instructions tool edits with edited_by="navi").dispatcher_instructions — dispatcher (routing) instructions: which kind of event goes to which profile and what one conversation is. They never reach the session transcript or the task; only the dispatcher reads them.Both instruction documents keep a version history (last 100 per document and per user, with author + reason). They live in one table with a doc discriminator (reaction | dispatcher), so pre-existing rows read as reaction and the old history survives verbatim.
REST: GET/PUT /synapse-settings (PUT records a version per document that actually changed; dispatcher_instructions and reaction_session_ttl_minutes are optional in the body — an omitted field means "leave as is", never "clear", so a client that predates them cannot wipe them), GET /synapse-settings/versions?limit=&doc=.
special=True sessions are excluded from the regular session list at the REST layer (GET /sessions?special=false|true; omitting the param shows everything — admin/API callers see all). The webclient sidebar has a service-sessions toggle that fetches special=true; special items render muted with a robot icon.
Inside a service session the event itself is a system record, not a user bubble: the webclient turns a role="system" message with metadata["source"] == "synapse_event" into SynapseEventNotice.vue (an icon
GET /sessions/{id} still exposes the hidden turn with the envelope in its context list, for callers that need the raw event.A Telegram conversation delivered as a stream of events is one thread: the dispatcher derives one session_key from the payload (e.g. the chat id) and the runner keeps landing the events in the same navi session, so the agent remembers the previous reply. The user writes the rule in the dispatcher instructions; the window in which a thread may be revived is reaction_session_ttl_minutes. Everything is per event and per user: an event whose key is null, one that arrives after the window, or one whose thread is still running starts a new session rather than waiting.