# Synapse integration

navi speaks to the [GNEXUS Synapse](https://handbook.gnexus) 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), the event envelope, and the list of profiles the run may reach, each as
   `id (name) — description`. The names are not decoration: a routing document says
   "the sysadmin profile", and an id alone gives the model nothing to match that
   against. Output: strict JSON
   `{profile_id, task, understood, session_key}` or `{skip: true}`.
   `session_key` is folded to one spelling (whitespace collapsed, lowercased) so a
   chat cannot fork on `TG:42` vs `tg:42`; it is `null` when the event opens a new
   thread. Unparseable output or a backend failure = skip (logged, never a crash).
3. **Profile resolution** — the profile that hosts the reaction is the dispatcher's
   choice, subject to the one predicate the whole app uses, `admin_only_blocked`,
   asked with **the triggering account's role** read from `navi_users.role` — the same
   value the WebSocket, `POST /sessions`, `switch_profile` and `list_profiles` ask it
   with. An admin's event may therefore reach an admin-only profile and its full tool
   set (`tgclient`, `ssh_exec`): the reaction was fired by that one account, not by an
   anonymous caller. A regular user's event may not. Hidden profiles are out for
   everyone. A refused id is never swapped in silence — it falls to the fallback for
   that role (`secretary` for an admin, `assistant` for a regular user, instead of
   "whichever `all()` yields first") and the substitution is logged as
   `synapse.reaction_profile_downgraded` with both ids. 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.