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.py—WispConfig(pydantic-settings) loads from environment variables.src/core/agent_sdk_config.py— Shared helpers for Claude Agent SDK auth dispatch: resolves the auth mode (subscriptionvsapi), builds the subprocess environment, discovers the Claude CLI path, and provides a stderr logging callback. Used byWispLLMServicewhenWISP_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'sassessment-mappings.json5-level system).src/personality/—WispPersonalityEngine(Grit persona),WispLoreLoaderfor in-character responses, andReaderCompanionPersona(Reader persona).src/personality/reader_persona.py—ReaderCompanionPersonaclass; 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.py—InteractHandlerorchestrates 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 tomessages.create()with the configured model andmax_tokens. Returns(text, stop_reason). - Subscription mode (
_call_via_agent_sdk) — collapses multi-turn messages into a single prompt string (the Agent SDK only acceptsprompt=str), injects a soft token cap into the system prompt, and runsclaude_agent_sdk.query()withmax_turns=1and 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_tokensoption, 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 × 4characters). - 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 capture —
agent_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
claudeCLI is used (viaagent_sdk_config.resolve_cli_path()) because the SDK's bundled CLI does not haveclaude logincredentials. Override withWISP_AGENT_SDK_CLI_PATHif needed. - Environment —
agent_sdk_config.build_agent_env()setsANTHROPIC_API_KEY=""so the CLI subprocess falls through to OAuth, and forwardsCLAUDE_CONFIG_DIRif 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, andscenefields fromFamiliarContextto 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 onlyplayer_hash, never PII).WordNode— Vocabulary word with definition, part of speech, and example usage (:Literacy:Wordlabel).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:
Errors return HTTP 400 with:
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 includingroom_name,relationship_level,player_age_group,motivation_type,client_platform, and Reader-specific fields (storyline,chapter,scene,reading_level)InteractRequest/InteractResponse— Main conversation interactionWordLookupRequest/WordLookupResponse— Word definition lookupWordEvaluateRequest/WordEvaluateResponse— Freeform definition evaluationSynonymRequest/SynonymResponse— Reading-level-appropriate synonym lookupTextAdaptRequest/TextAdaptResponse— Reading-level text adaptationVocabularyProgressRequest/VocabularyProgressResponse— Player progress statsWordOfTheDayRequest/WordOfTheDayResponse— Daily word challengesHealthResponse— 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/synonymsand/api/familiar/text/adaptendpoints validate thetarget_levelparameter 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), aRuntimeErroris raised and the caller falls back toFALLBACK_RESPONSE. If the CLI subprocess returns no text content, aRuntimeErroris 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_AUTHandCLAUDE_CONFIG_DIRare read directly bysrc/core/agent_sdk_config.pyviaos.getenv()— they are not part of theWispConfigpydantic-settings model.WISP_AGENT_SDK_CLI_PATHis similarly read byagent_sdk_config.resolve_cli_path().
Service Dependencies¶
- Neo4j Knowledge Graph — Stores vocabulary words, player progress, learning events, and synonym relationships (
:SYNONYM_OFedges between:Literacy:Wordnodes withreading_levelproperties) - Anthropic Claude API — Generates personality responses and evaluates freeform definitions (used in
apiauth mode) - Claude Agent SDK (
claude_agent_sdk) — Alternative LLM backend for subscription auth mode; spawns theclaudeCLI subprocess with OAuth credentials instead of usingANTHROPIC_API_KEY. Requires the system-installed Claude CLI and valid OAuth credentials atCLAUDE_CONFIG_DIR/.credentials.json. Credentials are managed by Taryn'srefresh-claude-oauth.ymlplaybook. - Spyder Game Server — Provides player interaction bridge via
handlers/wisp_handlers.pyandhandlers/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:
- Taryn's
refresh-claude-oauth.ymlplaybook creates/updates thewisp-claude-oauthsecret in theai-coworkersnamespace and rolls the pod. - An init container (
setup-claude-config) copiescredentials.jsonfrom the secret to a writableemptyDirvolume at/home/wisp/.claude-config/.credentials.jsonwithchmod 600. - The main
wispcontainer mounts the same volume at/home/wisp/.claude-config, matchingCLAUDE_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.
Related Documentation¶
docs/education/wisp-pedagogy.md— Pedagogical approach and learning design
Feature Repositories¶
wisp→ project_id:the-smithy1/agents/wispagents-shared→ project_id:the-smithy1/agents/agents-sharedspyder→ project_id:the-smithy1/spyder
Feature Repositories¶
wisp→ project_id:the-smithy1/agents/wispagents-shared→ project_id:the-smithy1/agents/agents-sharedspyder→ project_id:the-smithy1/spyder
Code Paths to Explore¶
src/wisp_bridge/*.pyinspydersrc/agents_shared/schemas/nodes.pyinagents-sharedsrc/**/*.pyinwisp