# Assistant Experience

The Oshun assistant is the conversational surface that sits on top of every V1
domain: a member talks to it from the shell home, a customer launches it from a
story or a sky chart, and an operator invokes it from the admin cockpit. This
page covers the **shell** half of that experience: how the assistant is invoked,
what travels with it from the screen, the interaction modes it runs in, how
personas hand off, how it degrades safely when a subsystem is down, and how it
bridges into the two substrates that own the hard policy — Iris (durable memory)
and Psyche (real-time runtime). It is the feature-side companion to the two
substrate pages it depends on:
[Iris Memory and Identity](./iris-memory-identity.md) and
[Psyche Real-Time Runtime](./psyche-realtime-runtime.md). Persona roles and tone
come from [Lilith Persona Policy](./lilith-persona-policy.md); the hub for the
whole set is [../features.md](../features.md). Backlog for this surface lives at
`§2.5` in [`../TODOS.md`](../TODOS.md).

## What ships, honestly

The assistant shell is **real, non-stub, domain-specific code**, and it lives in
one library: `libs/oshun/shell-assistant` (package `@oshun/shell-assistant`,
version `0.1.0`). The package is a pure-ESM **source library** — its
`package.json` points `main` and `types` straight at `./src/index.ts`, with no
build step — and it describes itself as a "Voice-first cross-domain assistant
for the OSHUN shell home — intent classification, domain action routing, and
conversational AI orchestration across Tara, Veritas, Nyx, Arete, Nisaba, and
Metis." The six customer-facing domains it routes across are exactly the
`OshunDomainId` union:
`'tara' | 'veritas' | 'nyx' | 'arete' | 'nisaba' | 'metis'` (`src/types.ts`),
re-exported as `ALL_DOMAIN_IDS`.

The orchestrator is the `AssistantEngine` (`src/assistant-engine.ts`), created
via `createAssistantEngine({ adapters })`. It opens sessions with
`engine.createSession(userId, domains, { inputMode, locale })` and drives turns
with `engine.processMessage(sessionId, { text, inputMode })`, returning an
`AssistantResponse` whose shape — `text`, `cards`, `navigateTo`,
`suggestedActions` — lets the shell render a spoken reply, attach domain result
cards, and optionally deep-link the member to the surface that answers their
question. The engine composes four named collaborators, each independently
exported and tested:

| Collaborator       | Symbol                                                                            | Responsibility                                                             |
| ------------------ | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------- |
| Intent resolver    | `IntentResolver` / `createIntentResolver`                                         | classifies the utterance into an `AssistantIntentCategory` and binds slots |
| Domain intents     | `TARA_INTENTS` … `METIS_INTENTS`, `CROSS_DOMAIN_INTENTS`, `ALL_ASSISTANT_INTENTS` | per-domain intent definitions + `inferDomainFromText`                      |
| Action router      | `ActionRouter` / `createActionRouter`                                             | turns a `ResolvedIntent` into a `DomainAction` against the right adapter   |
| Response formatter | `ResponseFormatter` / `createResponseFormatter`                                   | renders the `AssistantResponse` (text, cards, navigation, disclosure)      |

Routing is genuinely cross-domain: the engine accepts an
`AssistantDomainAdapters` bundle with one adapter interface per domain
(`AssistantTaraAdapter`, `AssistantVeritasAdapter`, `AssistantNyxAdapter`,
`AssistantAreteAdapter`, `AssistantNisabaAdapter`, `AssistantMetisAdapter`), so
a single utterance like "what's in the sky tonight?" is classified to a Nyx
intent, routed to the Nyx adapter, and rendered as a Nyx event card with a
`navigateTo` of `/nightly-highlights`.

## Universal invocation points

Every place the assistant can be launched from is a typed entry in the
invocation registry (`src/invocation-points.ts`), which the docstring calls "the
single source of truth for invocation wiring." Each `AssistantInvocationPoint`
carries `id`, the `platformShell` it belongs to, a `surface` kind, an
`accessibilityLabel`, a `minViewportPx`, an optional keyboard `shortcut`, and an
`entrySource` string such as `customer-web.global-launcher`. The surface kinds
enumerate every real launch affordance:

> `global-launcher | header-button | command-palette | keyboard-shortcut | empty-state-cta | inline-help | deep-link | tab-bar | context-menu | push-notification`

The registry is sliced per shell by helper functions —
`listCustomerWebInvocationPoints()`, `listCustomerMobileInvocationPoints()`,
`listAdminWebInvocationPoints()`, and `listStudioWebInvocationPoints()`. This is
how the V1 promise of "universal invocation points across customer web, customer
mobile, and admin web" is satisfied without each shell re-implementing its own
launcher wiring; the same code also covers a fourth surface, Studio web, which
the prose list omits.

### Guarding an invocation

A launch is not unconditional. `evaluateAssistantInvocationGuard(input)` returns
an `AssistantInvocationGuardDecision` that either allows the launch or refuses
it with a precise, machine-readable reason:

> `allowed | unknown-invocation-point | unauthenticated | viewport-too-small | context-path-mismatch | missing-scope | missing-entitlement | missing-policy-grant`

The guard checks an `AssistantInvocationGuardRequirements` record —
`authenticatedSession`, `scopesAny`, `entitlementsAny`, `policyGrantsAny`,
`allowedPathPrefixes`, and `minViewportPx` — and, when it refuses, returns the
specific `AssistantInvocationGuardMissingRequirements` it failed on so the shell
can render the right upgrade or sign-in prompt instead of a dead button. This is
the "consistent entitlement, policy, and context guards" line from the backlog,
implemented as one reusable function rather than per-shell `if` ladders.

## Context handoff from the current screen

When the member launches the assistant from a page, the screen's context travels
with them. `buildAssistantLaunchIntent` produces an `AssistantLaunchIntent`
carrying the `invocationPointId`, `entrySource`, the `activePath` the user was
on, any `selection` (highlighted text or artifact), an optional `seedMessage`,
and an `invokedAtMs` timestamp. That seeds the session; the richer payload is
the `AssistantContextHandoff` (`src/context-handoff.ts`), a typed envelope that
sanitizes and summarizes the surrounding context before it crosses into the
conversation.

The handoff is deliberately privacy-aware: `sanitizeAssistantContextHandoff`
strips disallowed fields and emits `AssistantContextHandoffSanitizationNote`s,
and `summarizeAssistantContextHandoff` produces a short summary for the prompt.
The envelope models the full screen context the backlog enumerates — "current
domain, artifact, selection, evidence state, memory scope, persona identity,
disclosure state, and permitted tool grants travel in a typed envelope":

| Handoff field family | Type                                                                                                                    | Purpose                                                                 |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| Entities / artifacts | `AssistantContextEntity`, `AssistantContextArtifact`                                                                    | the things on screen the user might ask about                           |
| Evidence state       | `AssistantContextEvidenceState` (`AssistantContextEvidenceStatus`)                                                      | whether the screen's claims are grounded, unverified, blocked, or none  |
| Memory scope         | `AssistantContextMemoryScope` = `off \| session \| profile`                                                             | which memory tier this surface runs in                                  |
| Persona identity     | `AssistantContextPersonaIdentity` (`role`, `displayName`, `visible`)                                                    | which persona is presenting                                             |
| Disclosure state     | `AssistantContextDisclosureState`                                                                                       | per-flag AI-generated / synthetic-voice / safety / grounding disclosure |
| Tool grants          | `AssistantContextToolGrant` (`read \| write \| execute`)                                                                | the permissions the assistant is allowed to exercise                    |
| Recent actions       | `AssistantContextRecentAction` (`opened \| completed \| saved \| shared \| edited \| assigned \| decided \| escalated`) | what the user just did, so follow-ups make sense                        |

The `AssistantContextDisclosureState` is worth calling out because it is the
backbone of "synchronized disclosure": it carries independent booleans for
`aiGenerated`, `memoryScopeVisible`, `personaIdentityVisible`,
`groundingStateVisible`, `syntheticVoiceVisible`, and `safetyDisclosureVisible`,
so the shell can show exactly the disclosures that apply to the current state
rather than a single blanket banner.

## Indicators: memory, grounding, and persona posture

Above the transcript, three indicator chips always tell the member where they
stand. The indicator models (`src/indicators.ts`) share one tone palette —
`success | info | notice | warning | critical` — and each carries a short
`label` (rendered on the chip) plus a longer `disclosure` (rendered in the sheet
expansion).

- **Memory-state** — `buildAssistantMemoryIndicator(memory, shell)` maps the
  active `AssistantMemoryContext.scope` (`off | session | profile`) to a chip.
  "Memory off" reads differently for the admin shell ("Admin cockpit sessions do
  not persist memory by default") than for the customer shell ("nothing from
  this conversation is saved between sessions"); a `profile` scope without
  granted consent downgrades to a `warning`-tone "Profile memory · consent
  needed" chip and the disclosure explicitly states the assistant is "behaving
  as if memory were session-scoped." When consent is granted, the chip counts
  the member's enabled categories.
- **Grounding-state** — `buildAssistantGroundingIndicator` surfaces whether the
  current answer is grounded, unverified, or blocked, so a member never mistakes
  a conversational reply for a cited one. (See
  [Sophia Grounding](./sophia-grounding.md) for the gate behind it.)
- **Persona identity** — `buildAssistantPersonaIdentityIndicator` shows which
  persona is speaking, keeping the "persona identity indicator" promise honest.

## Transcript and turn history

The transcript surface (`src/transcript.ts`) is paged, exportable, and
disclosure-aware. `buildAssistantTranscript` assembles
`AssistantTranscriptEntry` records (each a typed
`AssistantTranscriptEntryKind`), `buildAssistantTurnCursor` /
`stepAssistantTurnCursor` walk turn history with an `AssistantTurnCursor`, and
`exportAssistantTranscript` produces an `AssistantTranscriptExport`. The
transcript is the durable, user-visible record that the Iris bridge (below)
mirrors into the redaction-ready conversation log for export, deletion, and
audit.

## Interaction modes

Interaction modes (`src/interaction-modes.ts`) are pure data descriptors the
shells translate into native UI primitives — the docstring is explicit that "the
semantics are authored here so behavior does not drift across shells" (HTML
`contenteditable` vs React Native `TextInput` vs hardware-token sheets). There
are four canonical modes (`ASSISTANT_INTERACTION_MODE_IDS`), and the default is
`text-first` (`DEFAULT_ASSISTANT_INTERACTION_MODE`):

| Mode              | Primary input | Streaming | Interruption | Proactive | Avatar | Entitlement           | Shells          | Fallback      |
| ----------------- | ------------- | --------- | ------------ | --------- | ------ | --------------------- | --------------- | ------------- |
| `text-first`      | text          | yes       | yes          | no        | no     | free                  | customer, admin | —             |
| `voice-first`     | voice         | yes       | yes          | no        | no     | free (mic permission) | customer        | `text-first`  |
| `streaming-text`  | text          | yes       | yes          | yes       | no     | free                  | customer, admin | `text-first`  |
| `avatar-embodied` | voice         | yes       | yes          | yes       | yes    | premium               | customer        | `voice-first` |

The seven user-visible "mode features" the backlog names — text-first,
voice-first, streaming, interruption-aware, proactive-follow-up,
persona-switching, cross-domain-carry-over — are enumerated as
`ASSISTANT_MODE_FEATURE_IDS`, each with an `AssistantModeFeatureStatus` of
`active | available | inactive | fallback`. Every mode carries its own
`disclosure` string, e.g., voice mode discloses that "Responses are spoken back
with a synthetic voice and always disclose that it is synthesized," and avatar
mode that "Both the voice and the avatar are synthesized; disclosure is always
visible during the session."

### Mode selection and downgrade

Mode choice is not "set and pray." `selectAssistantInteractionMode(input)`
checks the requested mode against the shell, the member's
`AssistantInteractionEntitlement` kinds
(`free | premium | enterprise | operator-admin | operator-studio`), and concrete
device `capabilities` (`microphone`, `avatarRenderer`, `syntheticVoice`), plus
an optional `reducedMotion` accessibility preference. The outcome is either
`{ ok: true, mode, downgradedFrom, downgradeReason }` — the engine quietly
stepping down a tier when a capability is missing — or `{ ok: false, reason }`
when no fallback exists (`not-allowed-in-shell | unknown-mode | no-fallback`).
An `AssistantModeDowngradeReason` such as `missing-entitlement` or
`missing-microphone` is recorded so the shell can explain _why_ it landed the
member in a lower mode rather than silently doing it.

## Persona handoffs

Persona switching is a first-class, audited transition, not a string swap.
`buildAssistantPersonaHandoffState` resolves a transition between the canonical
handoff personas (`ASSISTANT_PERSONA_HANDOFF_IDS`), each defined in
`PERSONA_DEFINITIONS` (`src/persona-handoffs.ts`):

| Handoff id             | Role      | Allowed shells  | Recommended domains                     |
| ---------------------- | --------- | --------------- | --------------------------------------- |
| `metis-teacher`        | teacher   | customer        | metis, nisaba                           |
| `arete-coach`          | coach     | customer        | arete, tara, activity                   |
| `stoic-scholar`        | scholar   | customer        | veritas, nisaba, library, metis         |
| `source-lineage-guide` | explainer | customer        | nisaba, veritas, library                |
| `admin-moderator`      | moderator | admin           | moderation, policy, admin, review       |
| `admin-reviewer`       | reviewer  | admin           | review, rights, persona, model, admin   |
| `support-assistant`    | support   | customer, admin | support, profile, privacy, shell, admin |

These map onto the Lilith-governed persona roles described under
[Lilith Persona Policy](./lilith-persona-policy.md). The V1 prose names the
seven canonical roles `teacher`, `coach`, `explainer`, `steward`, `comparative`,
`narrator`, `assistant`; the shell's concrete handoff catalog realizes the
customer-facing set (teacher, coach, scholar, explainer) and adds the operator
personas (moderator, reviewer, support) that the admin cockpit needs. The
underlying `AssistantContextPersonaRole` union additionally includes `navigator`
and `operator-copilot`.

Crucially, every handoff carries a **memory boundary**. An
`AssistantPersonaMemoryBoundary` declares its `kind` —
`memory-off | session-only | profile-visible | admin-session-only` — its
`scope`, whether it `canPersist`, and the `restrictedToShell` it is pinned to.
Switching to an admin copilot, for instance, drops into an `admin-session-only`
boundary that cannot write profile memory. Each transition also emits an
`AssistantPersonaHandoffAuditEvent` of type `assistant.persona_handoff`
recording `fromPersonaId`/`toPersonaId` and `fromRole`/`toRole`, with an
`AssistantPersonaHandoffAuditOutcome` of `applied` or `fallback`. If a requested
persona is not allowed in the current shell, `buildAssistantPersonaHandoffState`
returns an `AssistantPersonaHandoffFallbackState` instead of failing the turn.

## Safe fallback

When a dependency degrades, the conversation must keep going while honestly
disclosing what broke. `buildAssistantSafeFallbackState(input)`
(`src/safe-fallbacks.ts`) computes one `AssistantSafeFallbackSubsystemState` per
dependency across the six canonical subsystems
(`ASSISTANT_SAFE_FALLBACK_SUBSYSTEM_IDS`): `grounding`, `memory`, `persona`,
`real-time`, `avatar`, `downstream`. Each subsystem reports a status of
`available | degraded | unavailable | fallback`, an `active` flag, a human
`reason`, and a `fallbackBehavior` string. For example, when required grounding
is blocked or missing, the grounding subsystem returns a `fallback` status with
the behavior "The assistant keeps the transcript live but treats the next answer
as conversational until evidence returns."

The aggregate `AssistantSafeFallbackState` always sets
`continuityPreserved: true`, counts the `activeFallbackCount`, and emits a
`transcriptContinuityMessage` such as "Transcript continuity preserved across N
transcript turns; degraded systems are disclosed in-line." This is exactly the
backlog requirement that "UI must preserve transcript continuity and expose what
degraded" — the member never loses their conversation, and they always see which
capability went dark.

## Avatar assistant mode

On supported customer surfaces, the assistant can present as a stylized
synthetic avatar. `buildAssistantAvatarModeState(input)` (`src/avatar-mode.ts`)
computes a gated, disclosure-synchronized state. Avatar mode is the only mode
requiring a `premium` entitlement, and the gate also checks `microphone`,
`avatarRenderer`, `syntheticVoice`, the `reducedMotion` accessibility
preference, and that the shell is `customer` (operator cockpits cannot run
avatars). Each prerequisite is reported as an `AssistantAvatarGateState`
(`available | missing | not-applicable`).

When the gate fails, the state goes into `fallbackActive` and steps down to
`voice-first`, recording the `fallbackReason`. The state also carries an
`AssistantAvatarModeProvenance` block — `personaLabel`, `voiceAssetLabel`,
`avatarAssetLabel`, `rendererLabel`, `policyLabel`, and the preserved
`memoryScope` — and an `AssistantAvatarModeDisclosureState` that keeps
`syntheticVoiceVisible` and `syntheticAvatarVisible` in lock-step, satisfying
the "synchronized disclosure, provenance, and memory state" promise. The
provenance defaults are honest placeholders ("Synthetic voice pack", "OSHUN
avatar renderer", "Premium synthetic avatar entitlement") that real
persona/voice/avatar packs override — see
[Persona, Avatar, and Voice Packs](./persona-avatar-voice-packs.md).

## Bridges to the substrates

The shell deliberately owns _none_ of the durable-memory or real-time policy —
it bridges to the two substrates that do. The brief calls this out, and the code
backs it: the bridges are "intentionally small," stitching the canonical
surfaces together "without reimplementing either."

### Iris memory bridge

`createIrisMemoryBridge` / `IrisMemoryBridge` (`src/iris-memory-bridge.ts`,
backlog `V1-IRIS-008`) wires the engine into the canonical `@oshun/memory-iris`
substrate. Per its docstring, it hydrates the runtime `AssistantMemoryContext`
from Iris's `IrisContinuityState`, maintains a per-session append-only
`IrisConversationHistory` and an ephemeral `IrisSessionMemory`, routes every
durable write through `IrisMemoryAdapter.remember` so the canonical
consent/opt-out policy is enforced (when consent is missing the write is
suppressed and the caller gets a warning-bearing outcome, never a silent drop),
and at session close promotes only items marked `promotionCandidate=true` into
profile memory. It runs in two modes via `createAdminIrisMemoryBridge`: customer
(`consumer='assistant'`, promotes profile candidates) and admin
(`consumer='admin'`, suppresses promotion, keeps the operator trail in
`conversation` scope). All of the durable policy — scopes, retention, conflict
resolution, the `MemoryEntry` contract — lives in Iris and is documented in
[Iris Memory and Identity](./iris-memory-identity.md). The recall budgets that
govern how many memories surface per turn are real constants in
`libs/oshun/memory-iris/src/recall/pipeline.ts` (`DEFAULT_BUDGETS`): **assistant
12, shell 3, notebook 999, admin 100** — so the assistant transcript draws on a
deliberately small working set while a notebook context can pull far more.

### Psyche session bridge

`createPsycheSessionBridge` / `PsycheSessionBridge`
(`src/psyche-session-bridge.ts`, backlog `V1-PSY-003`) wires the engine into the
canonical Psyche real-time envelope and event stream. It is transport-agnostic:
it "doesn't own the socket, the WebRTC negotiation, or the LiveKit client,"
instead minting a valid `PsycheSessionEnvelope` (via
`buildPsycheSessionEnvelope`) that callers hand to the transport, and keeping a
per-session `PsycheSessionEventStream` so audit, telemetry, and UI indicators
stay synchronized. It exports `PSYCHE_SESSION_PROTOCOL_VERSION` and has a
customer mode (consumer `assistant`, accepts synthetic-avatar envelopes) and an
admin mode (`createAdminPsycheSessionBridge`, consumer `admin`, rejects
synthetic avatars). See [Psyche Real-Time Runtime](./psyche-realtime-runtime.md)
for the envelope and event-stream contracts.

## Platform shell split

Customer and admin behaviors are not branched inline everywhere — they are
factored into a platform shell descriptor (`src/platform-shell.ts`). The
canonical shells are enumerated in `ASSISTANT_PLATFORM_SHELL_IDS`, and helpers
such as `resolveAssistantPlatformShell`, `buildAssistantPlatformPromptFrame`,
`filterAssistantActionsForPlatform`, `isAssistantActionAllowedInShell`, and
`getAssistantPlatformSessionDefaults` let one engine serve both the customer web
shell and the admin cockpit while filtering which actions are permitted and
which session defaults apply. This is the seam (backlog `V1-AST-001`) that keeps
a single assistant codebase serving multiple product surfaces without leaking
admin-only actions into the customer shell or vice versa.

## Tests and evaluations

The backlog asks for "tests and evaluations for routing, memory, grounding,
persona state, invocation, source inspection, voice/text, avatar entry/exit,
disclosure, and accessibility." The library ships a `__tests__` directory
alongside the source, and the substrate it bridges into carries its own
extensive suites — Iris ships memory-leakage, forget-completeness,
conflict-resolution, crisis-suppression, and drift-detection evaluation suites
(`memory-evaluation-suites.test.ts`, `memory-safety-eval.test.ts` in
`libs/oshun/memory-iris/src/`), documented in
[Iris Memory and Identity](./iris-memory-identity.md). Because the shell's
interaction modes, indicators, fallbacks, persona handoffs, and avatar gates are
all **pure functions over typed inputs**, they are directly unit-testable
without standing up a live model — selection downgrades, guard refusals, and
disclosure flags are asserted against expected outcomes rather than against a
non-deterministic LLM call.

## Honest scope notes

- **The LLM itself is provider-gated.** The shell owns intent classification,
  routing, formatting, disclosure, and bridging; the actual conversational model
  call sits behind the engine's adapters and is configured per deployment. The
  shell's deterministic pieces (modes, guards, indicators, fallbacks) are what
  is exercised by the in-repo tests.
- **Avatar rendering is entitlement- and asset-gated.** `avatar-embodied`
  requires a `premium` entitlement plus a working renderer and licensed voice
  pack; absent those, `buildAssistantAvatarModeState` returns a `voice-first`
  fallback with the gate reasons spelled out. The provenance labels default to
  honest placeholders until real packs are bound.
- **Memory and real-time policy live in the substrates, not here.** The shell
  cannot write around the Iris consent/opt-out contract or mint an invalid
  Psyche envelope — those refusals are enforced in `@oshun/memory-iris` and the
  Psyche envelope validator, and the bridges surface the warnings rather than
  papering over them.

## Related

- [Iris Memory and Identity](./iris-memory-identity.md) — the durable-memory
  substrate the assistant bridges into (scopes, consent, retention, recall
  budgets, `MemoryEntry`).
- [Psyche Real-Time Runtime](./psyche-realtime-runtime.md) — the real-time
  session envelope and event stream behind voice and avatar modes.
- [Lilith Persona Policy](./lilith-persona-policy.md) — the persona roles and
  tone bands the handoff catalog realizes.
- [Persona, Avatar, and Voice Packs](./persona-avatar-voice-packs.md) — the
  synthetic avatar and voice assets avatar mode renders.
- [Sophia Grounding](./sophia-grounding.md) — the grounding gate behind the
  grounding-state indicator.
- [Product Surfaces](./product-surfaces.md) — the customer web, customer mobile,
  admin web, and Studio shells the assistant is invoked from.
- [`../features.md`](../features.md) — the hub feature list. Backlog: see `§2.5`
  in [`../TODOS.md`](../TODOS.md); dependency ordering in
  [`../DEPENDENCIES.md`](../DEPENDENCIES.md).
