Skip to content

Wisp Fae Familiar Agent

Overview

Wisp is a personal 1:1 educational companion agent — a "Fae Familiar" — that bonds with individual players to support vocabulary learning. It runs as an aiohttp microservice that receives requests from the Spyder game server and returns pedagogically-informed, personality-driven responses. Wisp tracks per-player mastery through a Neo4j knowledge graph and uses an LLM (Claude) to generate age-appropriate interactions.

Wisp supports two distinct personas selected by the client platform:

Persona Platform Audience Relationship
Grit (default) Evennia game client Ages 8–12 Player mentors Wisp (Wisp learns from the player)
Reader Smithy Reader app Ages 13+ Wisp guides the reader (Wisp is the knowledgeable companion)

These personas are mutually exclusive — the Reader persona never references game mechanics (shimmer, XP, skills) and never asks the player to teach it words. The Grit persona never speaks at an adult level or references story/chapter context.

Architecture

Wisp spans three repositories:

Repository Project ID Role
wisp the-smithy1/agents/wisp Core agent service (API, personality, vocabulary, LLM)
agents-shared the-smithy1/agents/agents-shared Shared Neo4j node schemas (e.g., FamiliarNode, WordNode, LearningEventNode)
spyder the-smithy1/spyder Game server; bridges player interactions to Wisp via handlers/wisp_handlers.py and handlers/familiar/

Key Components (wisp repo)

  • src/server.py — aiohttp application factory; registers all /api/familiar/* routes.
  • src/models.py — Pydantic request/response models for the API contract with Spyder.
  • src/config.pyWispConfig (pydantic-settings) loads from environment variables.
  • src/core/agent_sdk_config.py — Shared helpers for Claude Agent SDK auth dispatch: resolves the auth mode (subscription vs api), builds the subprocess environment, discovers the Claude CLI path, and provides a stderr logging callback. Used by WispLLMService when WISP_AGENT_SDK_AUTH=subscription.
  • src/services/vocabulary_service.py — Vocabulary lookup, related words, mastery tracking, spaced repetition, synonym graph queries, and reading-level text adaptation.
  • src/services/llm_service.py — Wraps LLM calls with a dual-path dispatcher: API mode (anthropic.AsyncAnthropic) or subscription mode (Claude Agent SDK + OAuth credentials). See LLM Auth Dispatch below.
  • src/services/knowledge_service.py — Neo4j knowledge graph client.
  • src/services/spaced_repetition.py — Spaced repetition scheduling logic.
  • src/services/reading_levels.py — Reading level constants, validation, and helpers for the synonym graph (aligned with TheSmithy's assessment-mappings.json 5-level system).
  • src/personality/WispPersonalityEngine (Grit persona), WispLoreLoader for in-character responses, and ReaderCompanionPersona (Reader persona).
  • src/personality/reader_persona.pyReaderCompanionPersona class; builds system prompts for the Smithy Reader app (13+ audience). Wisp acts as a warm, knowledgeable reading companion who guides readers through stories, discusses themes, and enriches vocabulary — as a peer, not a teacher or mentee.
  • src/handlers/interact.pyInteractHandler orchestrates persona selection, personality + LLM + vocabulary for the main interaction endpoint.

LLM Auth Dispatch

WispLLMService supports two authentication paths, selected by the WISP_AGENT_SDK_AUTH environment variable:

Mode Env Value Backend Billing Token Control
API (default) api anthropic.AsyncAnthropic Anthropic API key Native max_tokens parameter
Subscription subscription Claude Agent SDK (claude_agent_sdk.query()) Claude Max Pro subscription via OAuth Soft cap in system prompt + post-hoc char-budget truncation

The internal _call_claude() method dispatches to the appropriate path:

  • API mode (_call_via_anthropic_api) — passes messages directly to messages.create() with the configured model and max_tokens. Returns (text, stop_reason).
  • Subscription mode (_call_via_agent_sdk) — collapses multi-turn messages into a single prompt string (the Agent SDK only accepts prompt=str), injects a soft token cap into the system prompt, and runs claude_agent_sdk.query() with max_turns=1 and no tools. Returns (text, None) since the Agent SDK does not surface a per-message stop reason.

Subscription mode adaptations:

  • Token limits — Since the Agent SDK has no max_tokens option, age-band caps are enforced via a soft instruction in the system prompt ("Keep your response under approximately N tokens") plus a hard character-budget truncation post-hoc (max_tokens × 4 characters).
  • Message collapsing — Multi-turn retry messages (user → assistant → user) are flattened into a single prompt with role markers so the model sees conversation flow. Single-user-turn messages pass through verbatim.
  • Stderr captureagent_sdk_config.build_stderr_callback() logs Claude CLI stderr lines; without it, exit-code-1 crashes only produce a placeholder error.
  • CLI resolution — In subscription mode, the system-installed claude CLI is used (via agent_sdk_config.resolve_cli_path()) because the SDK's bundled CLI does not have claude login credentials. Override with WISP_AGENT_SDK_CLI_PATH if needed.
  • Environmentagent_sdk_config.build_agent_env() sets ANTHROPIC_API_KEY="" so the CLI subprocess falls through to OAuth, and forwards CLAUDE_CONFIG_DIR if set.

Personas

Grit Persona (default)

The original Wisp personality for the Evennia game client. The player mentors Wisp — teaching it words, building trust, and earning shimmer celebrations. Targets ages 8–12 with age-appropriate vocabulary and game mechanic references.

  • Powered by WispPersonalityEngine (src/personality/engine.py)
  • Supports scripted dialogue triggers matched by relationship level
  • Emotional states: curious, happy, concerned, excited, calm

Reader Companion Persona

A separate persona for the Smithy Reader app (client_platform: "reader"). Wisp is a knowledgeable reading companion who lives in the Paramount Theatre — a place where stories come alive. Targets ages 13+ with natural adult-level vocabulary.

  • Powered by ReaderCompanionPersona (src/personality/reader_persona.py)
  • No scripted dialogue — conditional dialogue is handled on the TheSmithy side
  • Emotional states: welcoming (relationship < 20), engaged (20–39), warm (40+)
  • Story-context-aware: uses storyline, chapter, and scene fields from FamiliarContext to ground responses
  • Safety rules enforced: no PII collection, no off-platform communication, no medical/legal/financial advice, content appropriate for teens and adults

Reader persona does NOT: - Ask the player to teach it words - Act as a mentee or student - Use child-oriented vocabulary limits - Reference game mechanics (shimmer, XP, skills)

Persona Selection

The InteractHandler selects the persona based on the client_platform field in FamiliarContext:

client_platform == "reader"  →  ReaderCompanionPersona (age_group: "13+")
anything else                →  WispPersonalityEngine  (age_group from context, default "10-12")

Reading Levels

The reading_levels module defines a five-tier reading level system used by the synonym graph and text adaptation features:

Level Lexile Range
emergent < 300
early 300–499
transitional 500–699
fluent 700–899
advanced 900+

These levels are aligned with TheSmithy's assessment-mappings.json. The module provides helpers for level validation (validate_level), filtering acceptable levels at or below a target (get_acceptable_levels), comparing levels (level_exceeds), and converting Lexile scores to levels (lexile_to_level).

Shared Schemas (agents-shared)

src/agents_shared/schemas/nodes.py defines the Neo4j node models used by Wisp:

  • FamiliarNode — Represents a Wisp instance bonded to a player (trust_score, bonding_level, total_interactions).
  • PlayerNode — Anonymized player node (stores only player_hash, never PII).
  • WordNode — Vocabulary word with definition, part of speech, and example usage (:Literacy:Word label).
  • LearningEventNode — Observations about player learning (mastered, struggled, frustrated, curious, etc.).
  • PersonalRepoNode — A player's personal vocabulary repository.

COPPA Compliance

Player identifiers are hashed with a salt before storage. Most Wisp endpoints receive a raw player_id and hash it server-side using hash_player_id(). The /api/familiar/word/evaluate endpoint is an exception — it receives a pre-hashed player_hash from Spyder (the COPPA boundary is enforced at the Spyder layer for this flow).

Team Awareness

Wisp's personality configuration (config/personality.json) includes a team block listing all Glass Umbrella agents (Minh, Arturo, Chisel, Roland, Taryn, Pearl, Spark) with their roles and domains. Collaboration guidelines instruct Wisp to acknowledge other agents by name and role rather than speculating about their domains, and to defer to the appropriate agent when a question falls outside Wisp's scope.

API Endpoints

All endpoints are served by the aiohttp app (default 0.0.0.0:8080).

Method Path Description
POST /api/familiar/interact Main conversation endpoint — handles player messages and returns personality-driven responses
POST /api/familiar/word/define Look up a word definition
POST /api/familiar/word/related Find words related to a given word
POST /api/familiar/word/evaluate Evaluate a player's freeform word definition (LLM-assessed)
POST /api/familiar/word/synonyms Get a reading-level-appropriate synonym for a word
POST /api/familiar/text/adapt Adapt text to a target reading level via deterministic synonym swaps
POST /api/familiar/progress Retrieve a player's vocabulary progress stats
POST /api/familiar/word-of-the-day Get a word-of-the-day for a player
GET /health Health check (includes Neo4j and vocabulary service status)

API Response Format

All successful API responses are wrapped in a consistent format:

{
  "status": "success",
  ...responseFields
}

Errors return HTTP 400 with:

{
  "status": "error",
  "error": "description"
}

Example: Evaluate a Definition

Request:

curl -X POST http://localhost:8080/api/familiar/word/evaluate \
  -H "Content-Type: application/json" \
  -d '{
    "player_hash": "abc123...",
    "word": "perseverance",
    "definition": "when you keep trying even when it is hard",
    "context": "The old gnome valued perseverance"
  }'

Response:

{
  "status": "success",
  "quality": "good",
  "feedback": "That is a wonderful definition!",
  "mastery_delta": 0.15,
  "new_mastery": 0.45,
  "shimmer_worthy": true
}

Quality tiers: good (core meaning captured, +0.15 mastery), fair (partially correct, +0.10), attempted (wrong/vague but encouraged, +0.05).

Example: Get a Level-Appropriate Synonym

Request:

curl -X POST http://localhost:8080/api/familiar/word/synonyms \
  -H "Content-Type: application/json" \
  -d '{
    "word": "perseverance",
    "target_level": "early",
    "player_id": "player-42"
  }'

Response:

{
  "status": "success",
  "word": "perseverance",
  "synonym": "grit",
  "word_level": "advanced",
  "target_level": "early"
}

The synonym endpoint traverses SYNONYM_OF relationships in the Neo4j knowledge graph (up to 3 hops) and returns the closest synonym at or below the target reading level. If the word is already at or below the target level, or no synonym is found, synonym and word_level are null.

An invalid target_level returns HTTP 400 with the valid levels listed in the error message. Valid levels: emergent, early, transitional, fluent, advanced.

Example: Adapt Text to a Reading Level

Request:

curl -X POST http://localhost:8080/api/familiar/text/adapt \
  -H "Content-Type: application/json" \
  -d '{
    "text": "The knight demonstrated perseverance during the arduous quest.",
    "target_level": "early",
    "player_id": "player-42"
  }'

Response:

{
  "status": "success",
  "original_text": "The knight demonstrated perseverance during the arduous quest.",
  "adapted_text": "The knight showed grit during the hard quest.",
  "swaps": [
    {"original": "demonstrated", "replacement": "showed", "position": 4},
    {"original": "perseverance", "replacement": "grit", "position": 6},
    {"original": "arduous", "replacement": "hard", "position": 10}
  ],
  "swap_count": 3
}

Text adaptation performs a deterministic (non-LLM) synonym swap: it tokenizes the input, batch-queries Neo4j for level-appropriate synonyms, and replaces words that exceed the target level while preserving punctuation and case. If no words need swapping (or Neo4j is unavailable), the original text is returned unchanged with swap_count: 0.

Request/Response Models

Key Pydantic models defined in src/models.py:

  • FamiliarContext — Interaction context including room_name, relationship_level, player_age_group, motivation_type, client_platform, and Reader-specific fields (storyline, chapter, scene, reading_level)
  • InteractRequest / InteractResponse — Main conversation interaction
  • WordLookupRequest / WordLookupResponse — Word definition lookup
  • WordEvaluateRequest / WordEvaluateResponse — Freeform definition evaluation
  • SynonymRequest / SynonymResponse — Reading-level-appropriate synonym lookup
  • TextAdaptRequest / TextAdaptResponse — Reading-level text adaptation
  • VocabularyProgressRequest / VocabularyProgressResponse — Player progress stats
  • WordOfTheDayRequest / WordOfTheDayResponse — Daily word challenges
  • HealthResponse — System health status

FamiliarContext Fields

Field Type Default Description
room_name str "" Current room/location name
recent_messages list[str] [] Recent message history
relationship_level int 0 Player–Wisp relationship level
player_age_group str "10-12" Age group (used by Grit persona)
motivation_type str "" Player motivation type
client_platform str "" Client platform identifier — "reader" selects the Reader persona; other values ("ios", "cascade", "") use the Grit persona
storyline str "" Current storyline name, e.g. "Paragon" (Reader-specific)
chapter int 0 Current chapter number (Reader-specific)
scene str "" Current scene ID (Reader-specific)
reading_level str "" Player's reading level, e.g. "fluent", "advanced" (Reader-specific)

Error Handling

  • Invalid JSON or validation errors return HTTP 400 with {"status": "error", "error": "..."} format.
  • If the LLM is unavailable during definition evaluation, the service falls back to a default "attempted" quality with generic encouraging feedback.
  • Failed Neo4j encounter recording is logged as a warning but does not fail the request.
  • The /api/familiar/word/synonyms and /api/familiar/text/adapt endpoints validate the target_level parameter before processing. An invalid level returns HTTP 400 with valid levels listed in the error.
  • If Neo4j is unavailable during synonym lookup or text adaptation, the service gracefully degrades — returning no synonym or the original text unchanged, respectively.
  • In subscription mode, if the Claude Agent SDK returns an error (ResultMessage.is_error), a RuntimeError is raised and the caller falls back to FALLBACK_RESPONSE. If the CLI subprocess returns no text content, a RuntimeError is also raised.

Configuration

Wisp is configured via environment variables (or .env file):

Variable Default Description
ANTHROPIC_API_KEY "" Anthropic API key for Claude (used in api auth mode)
CLAUDE_MODEL claude-sonnet-4-5-20250929 Claude model to use
MAX_TOKENS 180 Max tokens per LLM response
WISP_AGENT_SDK_AUTH "api" LLM auth dispatch mode: "subscription" routes through Claude Agent SDK + OAuth; "api" uses ANTHROPIC_API_KEY directly
CLAUDE_CONFIG_DIR "" Path to Claude config directory containing .credentials.json (subscription mode only)
WISP_AGENT_SDK_CLI_PATH "" Optional override for the claude CLI binary path (subscription mode; defaults to shutil.which("claude"))
NEO4J_URI bolt://localhost:7687 Neo4j connection URI
NEO4J_USER neo4j Neo4j username
NEO4J_PASSWORD "" Neo4j password
PLAYER_HASH_SALT "" Salt for COPPA-compliant player ID hashing
HOST 0.0.0.0 Server bind host
PORT 8080 Server bind port
LORE_DIR "" Directory containing personality lore files
TESTING false Enable testing mode

Note: WISP_AGENT_SDK_AUTH and CLAUDE_CONFIG_DIR are read directly by src/core/agent_sdk_config.py via os.getenv() — they are not part of the WispConfig pydantic-settings model. WISP_AGENT_SDK_CLI_PATH is similarly read by agent_sdk_config.resolve_cli_path().

Service Dependencies

  • Neo4j Knowledge Graph — Stores vocabulary words, player progress, learning events, and synonym relationships (:SYNONYM_OF edges between :Literacy:Word nodes with reading_level properties)
  • Anthropic Claude API — Generates personality responses and evaluates freeform definitions (used in api auth mode)
  • Claude Agent SDK (claude_agent_sdk) — Alternative LLM backend for subscription auth mode; spawns the claude CLI subprocess with OAuth credentials instead of using ANTHROPIC_API_KEY. Requires the system-installed Claude CLI and valid OAuth credentials at CLAUDE_CONFIG_DIR/.credentials.json. Credentials are managed by Taryn's refresh-claude-oauth.yml playbook.
  • Spyder Game Server — Provides player interaction bridge via handlers/wisp_handlers.py and handlers/familiar/

Deployment

Wisp runs as a containerized aiohttp service in the ai-coworkers namespace on k3s.

Connections

  • Shared Neo4j instance (knowledge graph)
  • Anthropic Claude API or Claude Agent SDK (LLM responses, depending on auth mode)
  • Receives requests from Spyder game server

Claude OAuth Credential Setup (Subscription Mode)

When WISP_AGENT_SDK_AUTH=subscription, the pod requires OAuth credentials for the Claude CLI. These are provisioned via a Kubernetes init container:

  1. Taryn's refresh-claude-oauth.yml playbook creates/updates the wisp-claude-oauth secret in the ai-coworkers namespace and rolls the pod.
  2. An init container (setup-claude-config) copies credentials.json from the secret to a writable emptyDir volume at /home/wisp/.claude-config/.credentials.json with chmod 600.
  3. The main wisp container mounts the same volume at /home/wisp/.claude-config, matching CLAUDE_CONFIG_DIR.

The wisp-claude-oauth secret is marked optional: true so the pod can start before the credentials are delivered — the service will fall back to FALLBACK_RESPONSE on LLM errors until credentials are available.

Health Monitoring

Health monitoring available via GET /health endpoint which reports Neo4j and vocabulary service connectivity status.

Feature Repositories

  • wisp → project_id: the-smithy1/agents/wisp
  • agents-shared → project_id: the-smithy1/agents/agents-shared
  • spyder → project_id: the-smithy1/spyder

Feature Repositories

  • wisp → project_id: the-smithy1/agents/wisp
  • agents-shared → project_id: the-smithy1/agents/agents-shared
  • spyder → project_id: the-smithy1/spyder

Code Paths to Explore

  • src/wisp_bridge/*.py in spyder
  • src/agents_shared/schemas/nodes.py in agents-shared
  • src/**/*.py in wisp