Newer
Older
navi-1 / docs / synapse.md

Synapse integration

navi speaks to the GNEXUS Synapse notification hub in both directions:

  • Inbound — Synapse delivers platform events to navi's webhook; each event can trigger an agent reaction (a background agent run).
  • Outbound — navi emits low-level lifecycle events (reaction finished / failed, notify-tool pushes) into Synapse's ingest, where they are simply logged.

The gnexus-synapse package (vendored dependency) owns the wire format: verify_webhook / ack / make_signature on the receiving side, AsyncSynapseClient on the emitting side.

Configuration

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).

Inbound: delivery gateway

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.

Inbound: reaction runner

navi/synapse/reactions.py — the point of the feature. Pipeline:

  1. Gate — the matched target's user must have reactions_enabled: true; otherwise the event is accepted and discarded (synapse.reaction_disabled).
  2. Dispatcher meta-pass — a single LLM call to the hidden 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).
  3. Profile resolution — only a profile the triggering user may actually use can host a reaction. Hidden profiles, and profiles marked 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.
  4. Session — fresh or continued — with a non-empty 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.
  5. Headless run — orchestrator 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.
  6. Finalise — app push per completion_notify (always / important = only when the run had errors / never) and a low-level reaction.finished / reaction.failed event to Synapse (best effort).

Outbound: source side

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).

notify tool

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.

Per-user settings

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=.

Service sessions in the UI

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

  • caption, the request in markdown) and drops every other stored system message. GET /sessions/{id} still exposes the hidden turn with the envelope in its context list, for callers that need the raw event.

Reacting to a conversation, not to an 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.