Your private, personalized internet companion.
Author: Jared Lockhart
Ask Penny anything and she'll search the web and text you back, always with sources.
But she's not just a question-answering bot. She pays attention. She remembers your conversations, learns what you're into, and starts sharing things she thinks you'd like on her own. She follows up on old topics when she finds something new. She gets to know you over time and her responses get more personal because of it.
Penny is a feed only for you. Private, personal, and local.
flowchart TD
User((User)) -->|"1. send message"| Channel[Signal / Discord]
Channel -->|"2. extract"| Penny[Penny Agent]
Penny -->|"3. prompt + tools"| Ollama[Ollama LLM]
Ollama -->|"4. tool call"| Search[SearchTool]
Search -->|"web search"| Perplexity[Perplexity API]
Search -->|"image search"| DDG[DuckDuckGo]
Search -->|"5. results"| Ollama
Ollama -->|"6. final response"| Penny
Penny -->|"7. send response"| Channel
Channel -->|"8. reply + image"| User
Penny -.->|"log"| DB[(SQLite)]
Penny -.->|"schedule"| BG["Background Agents\nResearch · Followup · Preference · Discovery · EntityExtractor"]
Penny uses specialized agent subclasses for different tasks:
- MessageAgent: Handles incoming user messages, prepares context, runs agentic loop
- ResearchAgent: Autonomous multi-iteration deep research on user-requested topics
- FollowupAgent: Background task that spontaneously follows up on conversations
- PreferenceAgent: Background task that extracts user preferences from messages and reactions
- DiscoveryAgent: Background task that shares new content based on user interests
- EntityExtractor: Background task that extracts named entities and facts from search results
- ScheduleExecutor: Runs user-created cron-based scheduled tasks
Each agent owns its own OllamaClient instance and can have its own tools and prompts.
Background tasks are managed by a priority-based scheduler with a global idle threshold. The scheduler runs tasks in priority order:
- Schedule (AlwaysRunSchedule) — runs user-created cron-based tasks every 60s
- Research (AlwaysRunSchedule) — processes in-progress research tasks
- Preference (PeriodicSchedule) — extracts user preferences from messages
- EntityExtractor (PeriodicSchedule) — extracts entities and facts from search results
- Followup (DelayedSchedule) — spontaneous conversation followups
- Discovery (DelayedSchedule) — proactive content sharing
Global idle threshold (default: 300s): Idle-dependent background tasks wait for the system to become idle before they can run. Background tasks are suspended during foreground message processing.
Schedule types:
- AlwaysRunSchedule: Runs regardless of idle state at a configurable interval (used for research and user-created schedules)
- PeriodicSchedule: Runs periodically while idle at a configurable interval (used for preference extraction, default: 300s)
- DelayedSchedule: Runs after system becomes idle + random delay (used for followups and discovery)
The scheduler resets all timers when a new message arrives.
- User sends message (Signal or Discord)
- Channel extracts message → checks for slash commands or research replies
- Channel notifies scheduler (resets timers, suspends background tasks)
- MessageAgent handles the message:
- If quote-reply: look up quoted message, walk parent chain for history
- If image: caption via vision model, then forward combined prompt
- Run agentic loop with tools (Perplexity search + DuckDuckGo images)
- Log messages to database (linked via parent_id)
- Send response back via channel with image attachment (if available)
- Background: research tasks run continuously; when idle, extract preferences, follow up on conversations, and share new discoveries
- Host Services: signal-cli-rest-api, Discord bot, and Ollama run directly on host
- Containerized Agent: Only the Python agent runs in Docker
- Networking:
--network hostfor simplicity - Persistence: SQLite on host filesystem via volume mount
- Channel Abstraction: Signal and Discord share the same interface
- Per-user Personality: Custom personality prompts stored in DB, applied via LLM response transformation
- Always-search: System prompt forces web search on every message — no hallucinated answers
- Background Suspension: Foreground messages pause background tasks to prevent interference
Penny supports slash commands sent as messages:
- /commands: List all available commands
- /debug: Show agent status, git commit, background task state
- /config: View and modify runtime settings (e.g.,
/config idle_seconds 600) - /profile: Set up user profile (name, location, DOB) — required before chatting
- /like, /dislike: Explicitly manage topic preferences for discovery
- /personality: Customize Penny's tone and behavior (e.g.,
/personality be a pirate) - /research: Start autonomous deep research on a topic with multi-iteration search
- /schedule: Create recurring background tasks (e.g.,
/schedule daily 9am weather forecast) - /draw: Generate an image from a text description (requires
OLLAMA_IMAGE_MODEL) - /bug: File a bug report on GitHub (requires GitHub App config)
- /email: Search Fastmail email via JMAP (requires
FASTMAIL_API_TOKEN) - /test: Enter isolated test mode with a separate DB for development
Penny stores data in SQLite across several tables:
PromptLog: Every call to Ollama
- Model name, full message list (JSON), tool definitions (JSON), response (JSON)
- Thinking/reasoning trace (if model supports it)
- Call duration in milliseconds
SearchLog: Every Perplexity search
- Query text, response text, call duration
MessageLog: Every user message and agent response
- Direction (incoming/outgoing), sender, content
- Parent ID (foreign key to self) for threading
- External ID (platform-specific message ID for quote-reply lookup)
- Is-reaction flag (true if message is a reaction)
- Processed flag (tracks whether PreferenceAgent has analyzed this message)
UserInfo: User profile information
- Name, location, timezone (IANA), date of birth
- Collected via
/profilecommand, required before chatting
Preference: User topic preferences for discovery
- User, topic, type (like/dislike)
- Set explicitly via
/like//dislikeor extracted by PreferenceAgent from messages
ResearchTask: Autonomous research requests
- Topic, status (awaiting_focus/in_progress/completed/failed), focus, max_iterations
- Thread ID for sending results back to the right conversation
ResearchIteration: Individual search iterations within a research task
- Query, findings (JSON), sources (JSON), iteration number
Schedule: User-created recurring background tasks
- Cron expression, prompt text, user timezone, timing description
PersonalityPrompt: Per-user personality customization
- Prompt text that transforms Penny's response style
Entity: Named entity knowledge base
- User, name (lowercased), facts stored as bulleted text lines (e.g., "- costs $1599\n- uses MAT driver")
- Extracted from SearchLog entries by EntityExtractor agent
- Progress tracked via
entity_extraction_cursortable (high-water mark per source type)
RuntimeConfig: User-configurable settings (via /config)
CommandLog: Log of every command invocation
- For Signal: signal-cli-rest-api running on host (port 8080)
- For Discord: Discord bot token and channel ID
- Ollama running on host (port 11434)
- Perplexity API key (for web search)
- Docker & Docker Compose installed
# 1. Create .env file with your configuration
cp .env.example .env
# Edit .env with your settings (Signal or Discord credentials)
# 2. Start the agent
make upmake up # Build and start all services (foreground)
make prod # Deploy penny only (no team, no override)
make kill # Tear down containers and remove local images
make build # Build the penny Docker image
make team-build # Build the penny-team Docker image
make check # Build, format check, lint, typecheck, and run tests
make pytest # Run integration tests
make fmt # Format with ruff
make lint # Lint with ruff
make fix # Format + autofix lint issues
make typecheck # Type check with ty
make token # Generate GitHub App installation token for gh CLI
make migrate-test # Test database migrations against a copy of prod DB
make migrate-validate # Check for duplicate migration number prefixesAll dev tool commands run in temporary Docker containers via docker compose run --rm, with source volume-mounted so changes write back to the host filesystem.
make prod starts the penny service only (skips docker-compose.override.yml and the team profile). Use make up for the full stack including agents and watcher.
Configuration is managed via a .env file in the project root:
# .env
# Channel type (optional - auto-detected from credentials)
# CHANNEL_TYPE="signal" # or "discord"
# Signal Configuration (required for Signal)
SIGNAL_NUMBER="+1234567890"
SIGNAL_API_URL="http://localhost:8080"
# Discord Configuration (required for Discord)
DISCORD_BOT_TOKEN="your-bot-token"
DISCORD_CHANNEL_ID="your-channel-id"
# Ollama Configuration
OLLAMA_API_URL="http://host.docker.internal:11434"
OLLAMA_FOREGROUND_MODEL="gpt-oss:20b" # Fast model for user-facing messages
OLLAMA_BACKGROUND_MODEL="gpt-oss:20b" # Smarter model for background tasks (defaults to foreground)
# Perplexity Configuration
PERPLEXITY_API_KEY="your-api-key"
# Database & Logging
DB_PATH="/penny/data/penny.db"
LOG_LEVEL="INFO"
# LOG_FILE="/penny/data/penny.log" # Optional
# Agent behavior (optional, defaults shown)
MESSAGE_MAX_STEPS=5
IDLE_SECONDS=300 # Global idle threshold for background tasks
FOLLOWUP_MIN_SECONDS=3600 # Random delay after idle (followup)
FOLLOWUP_MAX_SECONDS=7200
DISCOVERY_MIN_SECONDS=7200 # Random delay after idle (discovery)
DISCOVERY_MAX_SECONDS=14400
# Fastmail JMAP (optional, enables /email command)
# FASTMAIL_API_TOKEN="your-api-token"
# GitHub App (optional, enables /bug command and agent containers)
# GITHUB_APP_ID="12345"
# GITHUB_APP_PRIVATE_KEY_PATH="path/to/key.pem"
# GITHUB_APP_INSTALLATION_ID="67890"Penny auto-detects which channel to use based on configured credentials:
- If
DISCORD_BOT_TOKENandDISCORD_CHANNEL_IDare set (and Signal is not), uses Discord - If
SIGNAL_NUMBERis set, uses Signal - Set
CHANNEL_TYPEexplicitly to override auto-detection
Channel Selection:
CHANNEL_TYPE: "signal" or "discord" (auto-detected if not set)
Signal (required if using Signal):
SIGNAL_NUMBER: Your registered Signal numberSIGNAL_API_URL: signal-cli REST API endpoint (default: http://localhost:8080)
Discord (required if using Discord):
DISCORD_BOT_TOKEN: Bot token from Discord Developer PortalDISCORD_CHANNEL_ID: Channel ID to listen to and send messages in
Ollama:
OLLAMA_API_URL: Ollama API endpoint (default: http://host.docker.internal:11434)OLLAMA_FOREGROUND_MODEL: Fast model for user-facing messages (default: gpt-oss:20b)OLLAMA_BACKGROUND_MODEL: Smarter model for background tasks (default: same as foreground)OLLAMA_VISION_MODEL: Vision model for image understanding (e.g., qwen3-vl). Optional; if unset, image messages get an acknowledgment responseOLLAMA_IMAGE_MODEL: Image generation model (e.g., x/flux2-klein). Optional; enables the/drawcommand when setOLLAMA_MAX_RETRIES: Retry attempts on transient Ollama errors (default: 3)OLLAMA_RETRY_DELAY: Delay in seconds between retries (default: 0.5)
API Keys:
PERPLEXITY_API_KEY: API key for web search (without this, the agent has no tools)FASTMAIL_API_TOKEN: API token for Fastmail JMAP email search (optional, enables/emailcommand)
GitHub App (optional, enables /bug command; required for agent containers):
GITHUB_APP_ID: GitHub App ID for authenticated API accessGITHUB_APP_PRIVATE_KEY_PATH: Path to GitHub App private key fileGITHUB_APP_INSTALLATION_ID: GitHub App installation ID for the repository
Behavior:
MESSAGE_MAX_STEPS: Max agent loop steps per message (default: 5)IDLE_SECONDS: Global idle threshold for all background tasks (default: 300)FOLLOWUP_MIN_SECONDS: Minimum random delay after idle for followup (default: 3600)FOLLOWUP_MAX_SECONDS: Maximum random delay after idle for followup (default: 7200)DISCOVERY_MIN_SECONDS: Minimum random delay after idle for discovery (default: 7200)DISCOVERY_MAX_SECONDS: Maximum random delay after idle for discovery (default: 14400)RESEARCH_MAX_ITERATIONS: Max search iterations per research task (default: 10)RESEARCH_OUTPUT_MAX_LENGTH: Max research report length in characters (default: 2000)TOOL_TIMEOUT: Tool execution timeout in seconds (default: 60)
Logging:
LOG_LEVEL: DEBUG, INFO, WARNING, ERROR (default: INFO)LOG_FILE: Optional path to log fileDB_PATH: SQLite database location (default: /penny/data/penny.db)
- Create a Discord application at https://discord.com/developers/applications
- Create a bot for the application and copy the token
- Enable these intents in the Bot settings:
- Message Content Intent
- Server Members Intent (optional)
- Invite the bot to your server with the OAuth2 URL Generator:
- Scopes:
bot - Permissions:
Send Messages,Read Message History
- Scopes:
- Get the channel ID (enable Developer Mode in Discord settings, right-click channel → Copy ID)
- Add to your
.env:DISCORD_BOT_TOKEN="your-token" DISCORD_CHANNEL_ID="your-channel-id"
Penny includes end-to-end integration tests that mock all external services:
make pytest # Run all tests
make check # Run format, lint, typecheck, and testsCI runs make check in Docker on every push to main and on pull requests via GitHub Actions.
Test Coverage:
- Message flow: tool calls, direct responses, typing indicators, DB logging
- Background tasks: research, preferences, spontaneous followups, discovery, entity extraction
- Commands: /debug, /config, /test, /commands, /personality, /research, /schedule, /bug, /draw, /email, /like, /dislike
- Startup announcements, Signal channel integration, vision processing
- Tool validation: timeouts, missing params, non-existent tools, search redaction
Tests use mock servers and SDK patches:
MockSignalServer: Simulates Signal WebSocket + REST APIMockOllamaAsyncClient: Configurable LLM responsesMockPerplexity,MockDDGS: Search API mocks
Penny includes a Python-based agent orchestrator that manages autonomous Claude CLI agents. Agents process work from GitHub Issues on a schedule, using labels as a state machine:
backlog → requirements → specification → in-progress → in-review → closed (features)
bug → in-review → closed (bug fixes)
Agents:
- Product Manager: Gathers requirements for
requirementsissues (5-min cycle, 600s timeout) - Architect: Writes detailed specs for
specificationissues, handles spec feedback (5-min cycle, 600s timeout) - Worker: Implements
in-progressissues — creates branches, writes code/tests, runsmake check, opens PRs; addresses PR feedback onin-reviewissues; fixesbugissues directly (5-min cycle, 1800s timeout) - Monitor: Watches production logs for errors, deduplicates against existing issues, and files
bugissues automatically (5-min cycle, 600s timeout)
Each agent checks for matching GitHub issue labels before waking Claude CLI, so idle cycles cost ~1 second instead of a full Claude invocation.
make up # Run orchestrator with full stack- Pydantic for all structured data: All structured data (API payloads, config, internal messages) must be brokered through Pydantic models — no raw dicts
- Constants for string literals: All string literals must be defined as constants or enums — no magic strings in logic
signal-cli-rest-api supports markdown-style text formatting:
**bold**→ bold*italic*→ italic~strikethrough~→strikethrough(note: single tilde, not double)`monospace`→monospace
Formatting pipeline (SignalChannel.prepare_outgoing):
- Table conversion: Markdown tables are converted to bullet-point lists (tables don't render well in Signal)
- Tilde escaping: Regular tildes converted to tilde operator (U+223C) to prevent accidental strikethrough (e.g., "~$50" stays as-is)
- Strikethrough: Intentional
~~text~~converted to Signal's single-tilde format - Heading removal: Markdown
#headings stripped (keeps text) - Link conversion:
[text](url)converted totext (url)
When a user quote-replies to a Penny message, Signal:
- Converts markdown to native formatting (so
**bold**becomes plain bold) - Strips all formatting when including the quoted text in the reply envelope
- Truncates the quoted text (often to ~100 characters)
To reliably look up the original message:
- Outgoing messages are stored with markdown stripped (in
Database.log_message) - Tilde operators (U+223C) are normalized back to regular tildes for matching
- Quoted text is stripped before lookup (in
Database.find_outgoing_by_content) - Lookup uses prefix matching (
startswith) instead of exact match
- Messages are limited to 2000 characters (auto-chunked if longer)
- Typing indicators auto-expire after ~10 seconds
- Bot ignores its own messages and messages from other bots
MIT


