A personality-driven, multi-turn conversational agent for Discord, built with TypeScript and designed for extensibility. Skiff combines long-term memory, tool use, and customizable personas to create engaging interactions that go beyond simple Q&A.
- Multi-turn conversations via
/ask,/clear, and @mentions - Switch models on the fly,
/modelper channel or/ask model:for a single question - Long-term memory, semantic search, automatic fact extraction, and topic knowledge
- Living Logbook, durable storylines with decisions, commitments, risks, and open questions
- The Wake, evidence-backed causal trails explaining how decisions and outcomes came to be
- Multiple LLM backends, OpenAI, Anthropic, Ollama, or any OpenAI-compatible API
- Embedded database, PGlite with pgvector, no external PostgreSQL needed
- Discord tools, look up server info, users, react to messages, and more
- Web + browser tools, Brave search, page fetch/markdown extraction, and Cloudflare CDP browser control
- MCP integration, extend capabilities with external tool servers
- Skills, activated on demand with minimal prompt overhead
- Customizable personas, define a character with prose, voice, and example dialogue
git clone https://github.com/dougley/skiff.git
cd skiff
pnpm installCreate a .env file:
DISCORD_BOT_TOKEN=your-bot-token
OPENAI_API_KEY=sk-...Run it:
pnpm build && pnpm startThe included docker-compose.yml runs the bot alongside Ollama for fully local inference, no external API keys needed (besides your Discord token):
docker compose up -dThis pulls nomic-embed-text for embeddings and uses Ollama as the embedding provider. Set your LLM provider and model in .env as usual.
All config lives in environment variables. Only DISCORD_BOT_TOKEN and a LLM provider's API key are strictly required to get started, but there are many options for customizing behavior, access control, tools, and more.
| Variable | Default | Description |
|---|---|---|
DISCORD_BOT_TOKEN |
required | Your Discord bot token |
LLM_DEFAULT_MODEL |
gpt-4o-mini |
Model to use for chat, unless a channel overrides it with /model |
LLM_ALLOWED_MODELS |
-- | Comma-separated models /model and /ask model: may select. Unset means any model the provider accepts. The first 25 appear as slash-command choices (Discord's limit) |
LLM_DEFAULT_PROVIDER |
openai |
openai, anthropic, or ollama |
OPENAI_API_KEY |
-- | OpenAI API key |
OPENAI_API_BASE_URL |
-- | Custom OpenAI-compatible endpoint |
ANTHROPIC_API_KEY |
-- | Anthropic API key |
ANTHROPIC_API_BASE_URL |
-- | Custom Anthropic endpoint |
OLLAMA_API_KEY |
-- | Ollama API key (if needed) |
OLLAMA_BASE_URL |
http://localhost:11434 |
Ollama endpoint |
VISION_ENABLED |
true |
Enable/disable vision (image) support |
LLM_MAX_OUTPUT_TOKENS |
8192 |
Maximum output tokens requested per generation step |
LLM_REASONING_EFFORT |
-- | off, minimal, low, medium, or high. Unset leaves the provider default. Maps to reasoning_effort on OpenAI/Ollama and an extended-thinking token budget on Anthropic (1k/4k/8k/16k, reserved on top of LLM_MAX_OUTPUT_TOKENS) |
LLM_MAX_RETRIES |
2 |
Retries after the first attempt, so the default is 3 tries. Applies to every provider call — chat, embeddings, memory extraction, and the sleep phases. 0 disables retrying |
LLM_TURN_TIMEOUT_MS |
180000 |
Overall timeout for one conversational turn, retries and their backoff included |
Provider calls retry with exponential backoff, honouring Retry-After. Only worthwhile failures are retried: HTTP 408/409/429/5xx and connection errors. A 4xx from a malformed request fails immediately, as does an abort or a timeout. Retries wrap the model call itself, so a retried turn never re-runs tools that already executed.
Every transient failure is logged as it happens, so a blip that the retry recovers from still shows up as a WARN rather than passing silently — that's the signal that a provider is degrading before it starts failing outright. Exhausting the budget is reported by the call site as usual, with the attempt count in the message.
During a chat turn the wait is also shown in the live status message (Provider failed (503), trying again in 2s), so a stalled turn reads as waiting rather than hung. It's replaced as soon as the turn moves on.
Retries share the turn budget rather than extending it — LLM_TURN_TIMEOUT_MS still caps the whole turn, and a timeout during backoff aborts rather than hanging. Web and Discord calls have their own error handling and aren't governed by LLM_MAX_RETRIES.
LLM_DEFAULT_MODEL is the fallback, not a hard setting. Without restarting:
/model set <model>points this channel at another model. It sticks for @mentions too, and the conversation history carries over./model showreports what the channel is using and what it may pick./model resetgoes back toLLM_DEFAULT_MODEL./ask question:… model:…overrides one question only, leaving the channel's model alone.
The Ask context-menu commands take no options, so they follow the channel's /model. Background work (memory extraction, sleep phases) keeps using MEMORY_EXTRACT_MODEL / LLM_DEFAULT_MODEL and is deliberately unaffected, so a channel on an expensive model doesn't drag the batch jobs along.
Only the model moves; the provider stays LLM_DEFAULT_PROVIDER, so pick models that provider serves. A channel with no override always follows LLM_DEFAULT_MODEL, so changing the env default still moves every channel that never opted out. Set LLM_ALLOWED_MODELS anywhere /model is reachable by people you'd rather not hand your priciest model to — a model that later drops off the allowlist falls back to the default instead of stranding the channel.
| Variable | Default | Description |
|---|---|---|
EMBEDDING_MODEL |
text-embedding-3-small |
Embedding model for RAG |
EMBEDDING_PROVIDER |
openai |
openai, ollama, or disabled |
MEMORY_EXTRACT_MODEL |
-- | Model override for background memory extraction |
RAG_TOP_K |
5 |
Semantic search results to retrieve (1-20) |
RAG_RECENT_LIMIT |
12 |
Recent messages to include (1-50) |
RAG_MIN_SIMILARITY |
0.3 |
Minimum similarity score (0-1) |
The web tool group includes:
web_search(Brave Search)fetch_url(HTML fetch; markdown extraction via Cloudflare when configured)browser_cdp(Cloudflare Browser Rendering via CDP: session lifecycle, tabs, navigation, snapshots, screenshots, JS evaluation, and click/type/key interactions)
| Variable | Default | Description |
|---|---|---|
BRAVE_SEARCH_API_KEY |
-- | API key for Brave Search (enables web_search) |
CLOUDFLARE_ACCOUNT_ID |
-- | Cloudflare account ID for Browser Rendering endpoints |
CLOUDFLARE_API_TOKEN |
-- | Cloudflare API token with Browser Rendering - Edit permission |
Control where and for whom the bot operates. Default is open (no restrictions).
Caution
Leaving the bot open can lead to abuse. Consider using allowlist policies and specifying allowed guilds, channels, or users.
| Variable | Default | Description |
|---|---|---|
ACCESS_POLICY |
open |
Guild/channel policy: open, disabled, or allowlist |
ACCESS_DM_POLICY |
open |
DM policy: open, disabled, or allowlist |
ACCESS_ALLOWED_GUILDS |
-- | Comma-separated guild IDs (only when policy is allowlist) |
ACCESS_ALLOWED_CHANNELS |
-- | Comma-separated channel IDs (only when policy is allowlist) |
ACCESS_ALLOWED_USERS |
-- | Comma-separated user IDs (applies to both guild and DM allowlists) |
DISABLED_TOOLS |
-- | Tool groups or individual built-in tools turned off everywhere (see below) |
TOOL_CHANNEL_RULES |
-- | Per-channel tool restrictions (see below) |
TOOL_GUILD_RULES |
-- | Per-guild tool restrictions (see below) |
TOOL_DM_RULES |
-- | Tool groups disabled in all DMs (see below) |
TOOL_USER_RULES |
-- | Per-user tool restrictions (see below) |
When ACCESS_POLICY is allowlist, each level is checked independently — if an allowlist is empty at a given level, that level is unrestricted. For example, setting only ACCESS_ALLOWED_GUILDS restricts by guild but allows all channels and users within those guilds.
Tool rules let you disable specific tool groups at different scopes. Rules from all layers are merged (union) — if a tool group is disabled at any level, it's unavailable.
| Variable | Format | Scope |
|---|---|---|
TOOL_GUILD_RULES |
guildId:group1,group2;... |
Guild-wide defaults |
TOOL_CHANNEL_RULES |
channelId:group1,group2;... |
Per-channel overrides |
TOOL_USER_RULES |
userId:group1,group2;... |
Per-user restrictions |
TOOL_DM_RULES |
group1,group2,... |
All DMs (no ID prefix) |
DISABLED_TOOLS |
group1,tool_name,... |
Everywhere, including autonomous turns |
# Disable shell guild-wide, additionally disable web in one channel
TOOL_GUILD_RULES=999999999999999999:shell
TOOL_CHANNEL_RULES=111111111111111111:web
# Disable shell and web in all DMs
TOOL_DM_RULES=shell,web
# Restrict a specific user from using scheduler
TOOL_USER_RULES=222222222222222222:schedulerAvailable tool groups: discord, persona, memory, topic, logbook, web, scheduler, heartbeat, shell, mcp, user-input, skills.
The web group controls web_search, fetch_url, and browser_cdp together.
Replacing a built-in tool. DISABLED_TOOLS is the only rule that takes individual tool names, which is what you want when an MCP server does one job better than the built-in — the group vars would take out its neighbours too:
# drop Brave search, keep page fetching and the browser
DISABLED_TOOLS=web_search
# turn off a whole group everywhere, without listing every guild
DISABLED_TOOLS=shell,heartbeatIt names built-in tools. An MCP tool normally has to yield to a built-in of the same name and gets exposed as mcp_<name> instead; disabling the built-in frees the name, so the replacement is offered under it directly and the model sees one tool rather than two. MCP tools themselves are governed by mcp.json and the mcp group. A name that matches no built-in does nothing.
Gives the LLM access to the shell. Disabled by default.
Note
Skiff is designed with the assumption the agent is running in a container, or some other isolated environment with limited access to the host system. If that's not the case, be extra cautious with shell tools. Skiff makes best-effort safety measures, but a malicious or careless prompt could still cause damage.
Warning
Shell tools are dangerous, use with caution and only for trusted users. Always review your tool rules to ensure you don't accidentally expose powerful capabilities.
| Variable | Default | Description |
|---|---|---|
SHELL_ENABLED |
false |
Enable shell tools (true / false) |
SHELL_WORK_DIR |
/home/skiff |
Working directory for shell commands |
SHELL_ALLOWED_DIRS |
/tmp |
Comma-separated directories the shell can access |
Proactive monitoring — the bot periodically checks in on enabled channels.
| Variable | Default | Description |
|---|---|---|
HEARTBEAT_ENABLED |
true |
Enable the heartbeat system |
HEARTBEAT_INTERVAL_MINUTES |
30 |
Minutes between heartbeat checks (1-1440) |
HEARTBEAT_CHECKLIST_PATH |
./HEARTBEAT.md |
Markdown file with heartbeat instructions |
HEARTBEAT_QUIET_HOURS_START |
23:00 |
Start of quiet hours (HH:MM) |
HEARTBEAT_QUIET_HOURS_END |
08:00 |
End of quiet hours (HH:MM) |
HEARTBEAT_TIMEZONE |
UTC |
IANA timezone for quiet hours |
HEARTBEAT_ACK_MAX_CHARS |
300 |
Max characters for heartbeat acknowledgments (0-1000) |
Skills extend Skiff's capabilities without touching core code. Drop a directory with a SKILL.md file into skills/ and the LLM can activate it on demand. See skills/README.md for the full format and examples.
Skills are able to define their own tools in the form of MCP tool servers. When a skill is activated, its tools become available to the agent for the duration of the conversation, and are automatically removed when the conversation ends.
| Variable | Default | Description |
|---|---|---|
SKILLS_DIR |
./skills |
Directory to scan for skills |
The sleep cycle is a background maintenance system that runs during idle periods. It consolidates memories, deduplicates knowledge, evolves the persona, and can auto-author new skills. Enabled per-scope via the /sleep-cycle enable Discord command — in a server it dreams over that guild's memory, in a DM it dreams over that conversation's own facts, topics, and persona notes. Runs in dry-run mode by default (changes are logged but not applied). Use /sleep-cycle set-report-channel to get a digest after each pass (DMs report into the DM by default).
| Variable | Default | Description |
|---|---|---|
SLEEP_CONSOLIDATE_LOOKBACK_DAYS |
30 |
Days of user activity to scan for fact consolidation |
SLEEP_CONSOLIDATE_MAX_USERS |
10 |
Max users to process per pass |
SLEEP_CONSOLIDATE_MIN_FACTS |
2 |
Minimum facts per user to trigger consolidation |
SLEEP_DEDUPE_SIMILARITY |
0.9 |
Cosine similarity threshold for topic deduplication (0-1) |
SLEEP_MAX_TOPICS |
200 |
Max topics to scan for deduplication |
SLEEP_MAX_MERGES_PER_RUN |
20 |
Max topic merges per pass |
SLEEP_CLUSTER_THRESHOLD |
0.85 |
Cosine similarity threshold for message clustering (0-1) |
SLEEP_MIN_CLUSTER_SIZE |
5 |
Minimum messages in a cluster to synthesize a new topic |
SLEEP_MAX_CLUSTERS_PER_RUN |
3 |
Max new topics to synthesize per pass |
SLEEP_MAX_SAMPLES |
500 |
Max message embeddings to consider for clustering |
SLEEP_NEW_TOPIC_OVERLAP_THRESHOLD |
0.85 |
Skip clusters that overlap existing topics above this threshold (0-1) |
SLEEP_SYNTHESIZE_LOOKBACK_DAYS |
7 |
Days of messages to scan for topic synthesis |
SLEEP_REFLECT_LOOKBACK_DAYS |
14 |
Days of messages to reflect on for persona growth |
SLEEP_REFLECT_MAX_MESSAGES |
120 |
Max messages to include in persona reflection |
SLEEP_REFLECT_MIN_CONFIDENCE |
70 |
Minimum confidence (0-100) for a persona note to be kept |
SLEEP_REFLECT_MAX_ADDENDA_PER_RUN |
3 |
Max persona notes to generate per pass |
SLEEP_PROPOSE_LOOKBACK_DAYS |
7 |
Days of user messages to scan for skill proposals |
SLEEP_PROPOSE_MAX_USER_MESSAGES |
200 |
Max user messages to consider for skill proposals |
SLEEP_PROPOSE_MIN_CONFIDENCE |
75 |
Minimum confidence (0-100) for a skill proposal to be kept |
SLEEP_MAX_ADDENDA_PER_SCOPE |
15 |
Max persona addenda injected into a single system prompt |
The Logbook tracks endeavors that unfold across conversations. A storyline has a goal, a concise current state, owners, lifecycle status, and an append-only history of decisions, commitments, open questions, risks, milestones, and notes. Ask Skiff to track something, then use /logbook list or /logbook show to inspect it. Relevant active storylines are recalled automatically during later conversations.
The Wake connects those events with typed relationships such as supports, depends_on, contradicts, supersedes, and caused_by. Ask Skiff why a decision was made, or use /wake, to trace the reasoning back to its source messages. Skiff can add supporting evidence over time without rewriting history, and its dream pass can propose high-confidence links already explicit in the Logbook.
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
file://pg_data |
PGlite data directory |
LOG_LEVEL |
info |
trace, debug, info, warn, error, fatal |
NODE_ENV |
development |
development, production, or test |
MCP_CONFIG_PATH |
mcp.json |
Path to MCP server config |
MCP_TOOLS_CACHE_TTL_MS |
300000 |
How long MCP tool listings are reused before refetching (0 to disable) |
PERSONA_FILE |
./agent.persona.json |
Path to persona file |
GUILD_ID |
-- | Restrict command registration to a single guild (faster for dev) |
CONTEXT_WINDOW_SIZE |
200000 |
Max context window size in tokens |
Skiff's personality is defined by a persona JSON file: prose description, a voice list, and examples of how the character talks. It deliberately avoids numeric trait scoring (Big Five, MBTI, etc.) because models mirror a few concrete example exchanges far more reliably than they interpret a creativity: 0.6. Point PERSONA_FILE at any valid persona:
PERSONA_FILE=examples/agent.persona.research-analyst.json pnpm devThe examples/ directory has ready-made personas:
| Persona | Style |
|---|---|
| template | Blank slate, fill in the blanks |
| customer-support (Mara) | Warm, empathetic, checklist-driven |
| research-analyst (Iris) | Methodical, evidence-first, thorough |
| playful-storyteller (Jun) | Creative, expressive, collaborative |
| strict-sysadmin (Kade) | Blunt, safety-first, asks for logs |
| kid-friendly-tutor (Pip) | Patient, encouraging, safe |
To build your own, copy examples/agent.persona.template.json and fill it in. See examples/README.md for the full field reference.