A nanobot-class personal AI agent as a single ~19MB Go binary — no Python, no venv, no runtime dependencies. Self-hosted, curl to running in under a minute, and scriptable enough to test in CI. Self-learning memory, skill self-creation, subagent delegation, multi-provider LLM routing, and Telegram/Discord chat all ship in the one static binary.
| joshbot | Typical Python agent (e.g. nanobot) | |
|---|---|---|
| Install | curl … | bash → one binary |
pip/venv, interpreter + wheels to keep in sync |
| Runtime deps | Zero — static Go binary (~19MB) | CPython + a dependency tree |
| Startup | No interpreter, no imports — the binary is the runtime | Interpreter + import cost |
| Shell safety | Deny-listed and env-stripped (no API keys inherited), optional OS sandbox | Varies |
| Untrusted skills | Inert until joshbot skills trust, bound to a directory-tree hash |
Varies |
| Scriptability | Every command non-interactive; --output-format json, exit-code contract |
Varies |
joshbot is heavier on guarantees, lighter on your machine.
- Self-Learning Memory - Automatically remembers important facts across conversations using a structured fact system (categorized with SHA256-based IDs, confidence scoring, source tracking, and deduplication)
- Context Compression - Summarizes old context to stay within token limits; works well with small local models
- Skill Self-Creation - Creates new capabilities for itself as markdown files, with auto-detection from conversation patterns and LLM-based extraction
- Subagent Delegation - Spawns focused subagents for complex multi-step tasks
- Browser chat UI - optional, off by default: set
api.webuitotrueandjoshbot servealso serves a self-contained dark-mode chat page at/, embedded in the binary, behind a cookie login that exchanges anapi.api_keysvalue for a session - OpenAI-Compatible API -
joshbot serveexposes the agent at/v1/chat/completions(streaming included) and/v1/models, plus/v1/audio/transcriptionswhensttis configured and/v1/embeddingswhenembeddingsis configured, so any OpenAI client can drive it; authentication is mandatory and it binds loopback by default - Telegram & Discord - Chat from your phone with full media support; both fail closed on an empty allowlist
- Scriptable / Non-Interactive - Every command runs headless;
agent -mfor one-shot,--output-format json/stream-jsonfor machine-readable output,--resumeto thread sessions, and a stable exit-code contract for CI - Interactive CLI - Rich terminal interface with markdown rendering
- Multi-Provider LLM - OpenRouter, Anthropic, OpenAI, Groq, Poolside, DeepSeek, Gemini, NVIDIA, and more
- Named Profiles - Switch model, provider and endpoint per run with
--profile; profiles hold a credential's variable name, never the credential - Model-Centric Config - Simplified model configuration with provider auto-detection and fallback chains
- Prompt Caching - Intelligent caching of system prompts with mtime-based invalidation for faster responses
- Tool Use - File operations, shell commands, web search, scheduling, and more
- Proactive Tasks - Heartbeat system for autonomous task processing
- Scheduled Reminders - Ask for a reminder in
30m,2hor1d, one-off or repeating; jobs and their delivery persist across restarts — a reminder that fires after a reboot still reaches your chat
- Go 1.24+ (for building from source)
- An LLM API key — OpenRouter free tier works, no credit card needed
- Linux or macOS recommended
curl -fsSL https://raw.githubusercontent.com/bigknoxy/joshbot/main/install.sh | bashDownloads the latest binary release for your platform. Supports Linux and macOS (amd64/arm64).
For specific versions:
curl -fsSL https://raw.githubusercontent.com/bigknoxy/joshbot/main/install.sh | bash -s -- -v v1.0.0go install github.com/bigknoxy/joshbot/cmd/joshbot@latestEnsure $GOPATH/bin or $HOME/go/bin is in your PATH.
git clone https://github.com/bigknoxy/joshbot.git
cd joshbot
go build -o joshbot ./cmd/joshbotdocker build -t joshbot .
docker run -it -v ~/.joshbot:/home/joshbot/.joshbot joshbot onboardjoshbot onboard # First-time setup
joshbot agent # Interactive CLI chat
joshbot agent --debug # CLI chat with debug logging
joshbot gateway # Start all channels (Telegram, etc.)
joshbot serve # Start the OpenAI-compatible HTTP API
joshbot gateway --debug # Gateway with debug logging
joshbot status # Show configuration and status
joshbot preflight # Check the config would work, without calling any provider
joshbot skills list # Review workspace skills and approval state
joshbot skills trust <name> # Approve a workspace skill after reviewing it
joshbot mcp list # Review MCP servers and the tools they advertise
joshbot mcp trust <name> # Approve an MCP server's tool manifest
joshbot configure # Configure LLM providers and settings
joshbot configure --fallback "nvidia,poolside" # Set the provider fallback order ("" clears)
joshbot configure --migrate # Convert a legacy provider config to the model-centric format
joshbot auth github-copilot # Authenticate with GitHub Copilot
joshbot service install # Install joshbot as a system service
joshbot update # Update to the latest release
joshbot uninstall # Remove joshbot binary and configThese apply to every command:
| Flag | Effect |
|---|---|
--no-color |
Strip ANSI colour from all output |
--log-level debug|info|warn|error |
Set log verbosity (takes precedence over --verbose/--debug) |
--verbose / --debug |
Shortcuts for more detailed logging |
Every command works headless — no TTY, no prompts. This makes joshbot safe to drive from scripts and CI.
# One-shot message, plain text on stdout
joshbot agent -m "summarize ./NOTES.md"
# Machine-readable single JSON result (stdout is data only; logs go to stderr)
joshbot agent -m "hello" --output-format json
# Streaming NDJSON: tool_start / tool_done lines, then a terminal result line
joshbot agent -m "run the tests" --output-format stream-json
# Resume a prior session by the id echoed in a previous json result
joshbot agent -m "and now lint it" --output-format json --resume <session-id>
# Or just continue the most recently updated session, no id needed
joshbot agent -m "and now lint it" --continue
# Attach an image (repeatable; requires a vision-capable model)
joshbot agent -m "what is in this screenshot?" --image ~/Desktop/shot.png--image <path> attaches a picture to the message. It is repeatable, and it
requires -m/--message — an image with no question attached has nothing to
answer. Telegram photos and image documents are attached automatically,
PDFs sent on Telegram ride the turn as a document attachment and are read
by the model itself, and text documents (txt, md, csv, json, code) are read and
inlined into the message, capped at 64KB with a visible truncation marker. Voice
messages are transcribed and answered when stt is configured (see Voice
message transcription); media the agent cannot perceive — untranscribed
voice, audio, video — gets an honest "I can't listen/watch yet" reply instead
of a confident answer about content nobody heard; a caption on such media is forwarded as the message text,
framed so the model knows what it cannot see. Stickers are quietly ignored.
Three things are enforced, in this order, and all of them before any provider is called:
- Type is decided by content, never by name or by what the sender declared.
A
.pngthat is really prose is refused. Supported: PNG, JPEG, GIF, WebP. - Size: 5 MB per image, 20 MB per request.
- Capability: if no configured model is known to accept images, the request
fails immediately with an error naming the models tried and the config key to
change — rather than a provider
400mid-conversation. Unknown models are treated as not vision-capable, so a typo produces a legible error.
Sessions record that an image was sent — its type, size and SHA-256 — and not
the bytes. Session files are exempt from redaction and are protected only by
their 0600 mode, and re-sending stored images would re-bill them on every
later turn in the memory window.
A PDF sent on Telegram is downloaded after the allowlist check and carried on the turn as a document attachment, so the model reads the file rather than its name. The same three rules apply, in the same order, all before any provider is called:
- Type is decided by content: the bytes must start with
%PDF-. A PNG namedreport.pdfis refused, and a PDF namedshot.pngis not smuggled through the image path. - Size: 8 MiB per document, 16 MiB per request
(
providers.MaxDocumentBytes/MaxTotalDocumentBytes). An over-limit file is refused from its declared size before any transfer; the download itself is read through aLimitReaderat cap+1, so a file that lies about its size is refused rather than silently truncated. - Capability: document reading is a narrower capability than vision (a model that reads images does not necessarily parse PDFs), so it has its own list. If no configured model is known to accept documents, the request fails before any provider call with an error naming the models tried. Unknown models are treated as not document-capable.
Sessions record a DocumentRef — label, type, size and SHA-256 — never the
bytes, for the same reason images are not stored.
Office formats (docx, xlsx, pptx) are still not supported, and the refusal now
says what is: PDFs, text files and images. There is no --document CLI flag;
this is a Telegram inbound path only.
--image paths are deliberately not workspace-contained: they come from
the operator's own command line, not from the model, so
joshbot agent --image ~/Downloads/shot.png works. What is enforced is what
the operator cannot check for themselves — a regular file, and real image
content.
--output-format accepts text (default), json, or stream-json. The JSON
modes are non-interactive and require -m/--message. In JSON modes stdout
carries only the result document — logs are routed to stderr — so consumers
can parse stdout directly. cost_usd is emitted as null (no pricing table is
bundled; compute cost from the returned token usage).
joshbot returns a stable exit code so scripts can branch on the failure class:
| Code | Meaning |
|---|---|
0 |
Success |
1 |
General error |
2 |
Auth / no provider configured (remediation included in the message) |
3 |
Validation error (bad flag, e.g. unknown --output-format, or JSON mode without -m) |
4 |
Confirmation required (reserved for destructive flows) |
In JSON modes a failure is emitted as a well-formed {"type":"error","code":…,"remediation":…} object on stderr.
A turn that fails inside the agent counts as a failure too: the agent reports
LLM errors in band as reply text (Error processing request: ...) so a chat
channel can show them, but agent -m translates that back into exit code 1. In
JSON mode the result document carries "is_error": true with the failure text in
result, alongside the {"type":"error",…} document on stderr. The success path
is unchanged.
When joshbot agent runs interactively in a real terminal, it now shows
what's happening while it works instead of going silent for the length of
the ReAct loop:
-
A single-line elapsed-time spinner is shown while waiting on the model ("thinking...").
-
Each tool call the agent makes is announced, and its completion is shown with elapsed time, e.g.:
⏺ shell(go test ./...) ⎿ ok (1.2s)
This is purely cosmetic and terminal-aware: it is disabled automatically
when stdout is not a TTY (piped output, joshbot agent -m "...",
scripts/verify-local.sh, etc.), so scripted and non-interactive usage stays
clean and parseable — no spinner, no ANSI codes, no progress lines.
Use --debug flag to enable detailed logging for troubleshooting:
# Debug mode for agent
joshbot agent --debug
# Debug mode for gateway
joshbot gateway --debugDebug mode outputs detailed information about:
- LLM request/response details (model, content length, tool calls)
- HTTP response status codes
- Tool execution results
- Empty response detection and fallback behavior
This is especially useful when troubleshooting why joshbot returns "I've processed your request." instead of actual responses.
joshbot onboard # Interactive setup
joshbot onboard --force # Overwrite existing config
joshbot onboard --keep-data # Reconfigure but preserve memory/skills
# Fully non-interactive: configure a provider without any prompts
joshbot onboard --force \
--provider openrouter \
--api-key "$OPENROUTER_API_KEY"Non-interactive onboarding takes --provider, --api-key and --api-base
(the last is required for azure/custom). The API key also falls back to
JOSHBOT_PROVIDERS__<PROVIDER>__API_KEY. If --force is given with no way to
wire a real credential — no flag, no env key, no existing provider — onboarding
now fails with a non-zero exit and an actionable message instead of writing
a stub config and reporting success. --provider must name one of the supported
providers (openrouter, openai, nvidia, groq, ollama, anthropic,
poolside, azure, custom, litellm, github-copilot); anything else is
rejected and nothing is written. With --force --provider <name> the default
model comes from that provider (e.g. llama3.1:8b for ollama), not from
OpenRouter. Interactive onboard follows the same rule: if no provider ended up
configured — running with stdin closed, say — it exits non-zero with the same
message the --force path uses, though the config and workspace scaffold are
still written. After saving, onboard validates the credential (non-fatal) and
prints the provider's key URL if it looks wrong. Providers with no fixed
endpoint (azure, custom, litellm) report "could not verify ... no API base
URL configured" rather than being validated against someone else's API.
The interactive onboard flow will:
- Ask for your LLM API key (defaults to NVIDIA NIM; OpenRouter free tier also supported at openrouter.ai/keys)
- Let you choose a personality (Professional, Friendly, Sarcastic, Minimal, or Custom)
- Set up your workspace and memory files
joshbot uses a structured fact-based memory system that learns from your conversations:
| File | Purpose |
|---|---|
MEMORY.md |
Long-term structured facts (always in context) |
HISTORY.md |
Searchable event log with timestamps |
Facts use a structured format with SHA256-based IDs, categorized by type (user_info, preference, project, decision, skill, system), with confidence scoring (0.0-1.0) and source tracking. The memory_search tool enables keyword + category + tag search with relevance scoring.
When conversations grow large:
- Old messages are summarized by the LLM
- Key facts are extracted as structured facts to MEMORY.md (with reconciliation to avoid duplicates)
- A summary is appended to HISTORY.md
- Context is compressed to stay within limits
Dream is an optional second memory track. It is off by default; set
agents.defaults.dream_mode to turn it on:
| Value | Behaviour |
|---|---|
"" / "off" |
Off (default) |
"record" |
Stage 1 only — every history entry is appended to a raw log |
"full" |
Stage 1 plus Stage 2 consolidation |
Stage 1 records each turn to <workspace>/memory/dream_raw.log.
Stage 2 fits a local TF-IDF embedding over those records, clusters them by
cosine similarity, and writes durable insights to
<workspace>/memory/dream_consolidated.jsonl. Embeddings are computed in-process
— no embedding API, no extra dependency.
Insight confidence decays with a 30-day half-life, so a stale insight loses to a
fresh fact instead of outranking it forever. The memory_search tool surfaces
matching insights alongside keyword facts, marked consolidated. MEMORY.md
and HISTORY.md are untouched: Dream is additive, and with it off the output of
memory_search is byte-for-byte what it was before.
joshbot memory status # mode, raw record count, stored insights
joshbot memory consolidate # run Stage 2 nowBoth are redacted, and consolidate exits non-zero when Dream is off — or when
it is in "record" mode, which records but never consolidates — rather than
silently doing nothing. The env override is
JOSHBOT_AGENTS__DEFAULTS__DREAM_MODE.
Stage 2 only runs when you run it. Nothing schedules it, so dream_raw.log
grows for as long as the agent runs. Drain it with joshbot memory consolidate,
or schedule that command yourself (cron, systemd timer, launchd).
Context Compression works efficiently with small local models (e.g., gemma-2-9b, llama-3.2-3b) — the summarization task is simple enough that you don't need a large model.
Skills are markdown files that extend joshbot's capabilities without code changes.
| Skill | Description |
|---|---|
memory |
Memory system usage (always loaded) |
skill-creator |
How to create new skills |
github |
GitHub CLI patterns (requires gh binary) |
cron |
Scheduling guidance for the cron tool |
sharing |
Sending workspace files to the user with the send_file tool |
joshbot can create its own skills! Ask it to learn something, and it will create ~/.joshbot/workspace/skills/{name}/SKILL.md with YAML frontmatter.
A workspace skill becomes part of the agent's standing instructions, so it is inert until an operator approves it — including skills the agent creates for itself. Approval is bound to the file's SHA-256, so editing an approved skill revokes it until it's approved again. Bundled skills (the ones listed above) are exempt.
joshbot skills list # See what's pending
joshbot skills trust <name> # Approve after reviewing the file
joshbot skills trust --all # Approve every pending skill
joshbot skills untrust <name> # Revoke approvaljoshbot status also flags any skills awaiting review.
A session is one conversation, keyed channel:senderID and stored as JSONL under
~/.joshbot/sessions. There is exactly one per user per channel and it is loaded
automatically on every message, so there is nothing to "resume" — what these
commands give you is a way to see what exists, read one back, and clear one.
joshbot sessions list # ID, message count, size, age, notes
joshbot sessions show <id> # print the conversation (redacted)
joshbot sessions show <id> --last 20 # just the tail
joshbot sessions search "magic word" # grep every transcript, newest first
joshbot sessions prune <id> # delete one conversation
joshbot sessions prune --older-than 30d # delete everything untouched for 30 days
joshbot sessions new <id> # archive it and start empty
joshbot sessions export <id> # redacted Markdown + JSON manifest
joshbot sessions export <id> --out ./bug --forceshow output is redacted: credentials and your home directory are stripped
before display, though the files on disk are left verbatim. Destructive
commands prompt for confirmation and take --force to run unattended; without a
terminal they decline rather than hang, and exit non-zero so a script does not
read a refusal as success. A damaged session is flagged in the NOTES column,
so it is visible without reading the directory; its .jsonl.corrupt quarantine
copy survives being loaded, but prune removes it along with the conversation.
export writes two files — <id>.export.md, a readable transcript, and
<id>.export.manifest.json, carrying the session ID, message and role counts,
per-tool call/result tallies, the byte size and a SHA-256 of the source session
file. Everything is redacted before any bytes are written, so a credential never
exists in the output even briefly, and the export is deterministic: nothing in it
comes from an export-time clock, so two exports of an unchanged session are
byte-identical. It reads only — the session file and its sidecars are untouched,
including a damaged one, whose recoverable messages still export and whose
skipped lines are counted in corrupt_lines and flagged in the transcript. An
existing export is never replaced without --force. --out selects the
directory, defaulting to the current one.
Skills use progressive loading:
- Level 1: Name + description always in context (~100 tokens)
- Level 2: Full content loaded on demand
- Level 3: Scripts/assets loaded as needed
---
name: my-skill
description: "What this skill does"
always: false
requirements: [bin:git, env:GITHUB_TOKEN]
tags: [development]
---
# My Skill
Instructions and examples...For complex tasks, joshbot can spawn focused subagents that:
- Keep the main context clean
- Handle one specific objective
- Report back with results
Subagents are useful for:
- File exploration and pattern discovery
- Multi-step implementation tasks
- Parallel independent work
An orchestrator subagent can delegate to child subagents via the
delegate_subagent tool, optionally with a different model per task. Nesting
is bounded to a maximum depth (agents.defaults.subagent_max_depth, default
2) so a recursive delegation chain cannot grow unbounded; a leaf subagent is
not offered the subagent-spawning tools at all.
The heartbeat service (active in gateway mode) reads ~/.joshbot/workspace/HEARTBEAT.md periodically. Add tasks in checkbox format:
- [ ] Check if the server is still running
- [ ] Summarize today's news about AIScan interval defaults to 30m and is configurable:
{ "heartbeat": { "interval": "1h" } }The value is a Go duration string (30m, 1h, 1h30m); empty, unparseable or
non-positive values fall back to 30m. Override with JOSHBOT_HEARTBEAT__INTERVAL.
Completion contract: each task is published to the agent with a marker telling
it this is an automated background check, not a user message — and to reply with
exactly HEARTBEAT_OK when nothing needs your attention. Those HEARTBEAT_OK
(and empty) replies are suppressed rather than delivered, so the heartbeat is
silent unless something genuinely warrants a ping. A tick is skipped (tasks
left unchecked, to retry) when no recipient chat ID is known yet; a task is only
checked off [x] once it has actually been published, so it never re-fires or
silently burns tokens against a dead end.
Config file: ~/.joshbot/config.json
The new model-centric format is simpler and more intuitive. Define models directly with their API configuration:
{
"models_config": {
"models": [
{
"name": "smart",
"model": "anthropic/claude-sonnet-4",
"api_key": "sk-ant-..."
},
{
"name": "fast",
"model": "groq/llama-3.3-70b-versatile",
"api_key": "gsk_..."
},
{
"name": "local",
"model": "ollama/llama3.2",
"api_base": "http://localhost:11434/v1"
}
],
"agent": {
"model": "smart",
"fallback": ["fast", "local"]
}
},
"channels": {
"telegram": {
"enabled": false,
"token": "",
"allow_from": []
}
},
"tools": {
"web": { "search": { "api_key": "" } },
"exec": { "timeout": 60 },
"restrict_to_workspace": true
}
}Benefits:
- Provider auto-detected from model prefix (e.g.,
groq/→ Groq API) - Easy fallback chains — try next model if one fails. Each entry in the chain is called with its own model, never the failed entry's: model IDs are provider-specific, so forwarding one would earn a "model not found" that hides the real failure. If every entry fails, the error names each one and what it returned.
- Transient failures are retried on the same provider first (429/5xx/network,
up to
providers.<name>.max_retriestimes, default 2,0= fail over immediately) with exponential backoff, honouring an upstreamRetry-Afterheader — so one blip doesn't switch the model you're talking to. A provider that keeps failing (or asks for a longRetry-After) is deprioritized in the chain for a cooldown window instead of being re-dialled and timed out on every turn; the in-chat/statuscommand shows any provider currently cooling down. - When a fallback does answer, the reply opens with a one-line notice —
⚠️ nvidia unavailable (rate_limit) — answered by poolside (poolside/laguna-s-2.1)— so a silent model switch never masquerades as the primary working. Setagents.defaults.quiet_fallbacktotrueto suppress it. - No separate provider configuration needed
| Model Prefix | Provider | Default API Base |
|---|---|---|
anthropic/ |
Anthropic | https://api.anthropic.com |
openai/ |
OpenAI | https://api.openai.com/v1 |
groq/ |
Groq | https://api.groq.com/openai/v1 |
ollama/ |
Ollama | http://localhost:11434/v1 |
openrouter/ |
OpenRouter | https://openrouter.ai/api/v1 |
nvidia/ |
NVIDIA NIM | https://integrate.api.nvidia.com/v1 |
deepseek/ |
DeepSeek | https://api.deepseek.com/v1 |
gemini/ |
Google Gemini | https://generativelanguage.googleapis.com/v1beta |
cerebras/ |
Cerebras | https://api.cerebras.ai/v1 |
poolside/ |
Poolside | https://inference.poolside.ai/v1 |
The prefix is stripped before the request is sent, because for most providers it
is joshbot's routing hint rather than part of the model name. Poolside is the
exception — its published IDs really are poolside/laguna-s-2.1, so the prefix
is kept. Nothing else needs to change; just use the ID exactly as the provider
lists it.
joshbot configure \
--provider poolside \
--api-key "$POOLSIDE_API_KEY" \
--model poolside/laguna-s-2.1The API base defaults to https://inference.poolside.ai/v1, so --api-base is
optional. Current models are poolside/laguna-s-2.1 and poolside/laguna-xs-2.1
(poolside/laguna-m.1 is deprecated as of 2026-07-28). Ask the endpoint for the
authoritative list:
curl -s https://inference.poolside.ai/v1/models \
-H "Authorization: Bearer $POOLSIDE_API_KEY" | jq -r '.data[].id'A provider or model entry may name an environment variable instead of carrying the secret, so a config file that is backed up, synced or pasted into an issue holds a variable name rather than a key:
{"providers": {"openrouter": {"enabled": true, "api_key_env": "MY_OPENROUTER_KEY"}}}Setting both api_key and api_key_env on the same entry is an error, not a
precedence question — joshbot refuses to start rather than leave you unable to tell
which credential is in use. Naming a variable that is not set is also fatal, and the
error names the variable, because a typo there is otherwise indistinguishable from a
revoked key.
Precedence, highest first:
| Source | Example |
|---|---|
JOSHBOT_PROVIDERS__<NAME>__API_KEY |
JOSHBOT_PROVIDERS__OPENROUTER__API_KEY=sk-... |
api_key_env |
"api_key_env": "MY_OPENROUTER_KEY" |
api_key |
"api_key": "sk-..." |
A profile is a named provider/model/endpoint setup you switch between with
--profile, instead of editing the config to move between (say) a hosted model and
a local Ollama:
{
"default_profile": "local",
"profiles": {
"local": {
"provider": "ollama",
"model": "qwen3:8b",
"api_base": "http://localhost:11434/v1",
"description": "local dev box"
},
"cloud": {
"provider": "openrouter",
"model": "z-ai/glm-4.6",
"api_key_env": "MY_OPENROUTER_KEY"
}
}
}joshbot profiles list # what is configured and where each would send requests
joshbot agent --profile cloud -m hi # one run, one profile
joshbot gateway --profile local # also on gateway and preflightSelection precedence, highest first: --profile, then default_profile, then
nothing. A config that has profiles but selects neither behaves exactly as it did
before profiles existed — nothing about an existing install changes until you opt in.
A selected profile becomes the only model for that run: it replaces the models block rather than being added to it, so a profile can never quietly fall back to some other endpoint you did not pick.
A profile cannot hold a credential. api_key inside a profile is refused when the
config loads, with an error pointing at api_key_env — a profiles block is the thing
most likely to be pasted into an issue or committed to dotfiles. Every other way a
profile can be wrong is a startup error too, not a provider error mid-conversation: an
unknown name (the error lists the configured ones), a disabled profile (its own
distinct message), and an api_key_env variable that is not set (the error names the
variable).
joshbot profiles list is safe to paste into a bug report. It names the variable
holding each credential and whether it is set, never the credential, and it reduces
api_base to a host so userinfo embedded in a URL cannot leak:
$ joshbot profiles list
Profiles:
* cloud openrouter/z-ai/glm-4.6
provider openrouter endpoint provider default
credential from $MY_OPENROUTER_KEY (set)
. local ollama/qwen3:8b
local dev box
provider ollama endpoint localhost:11434
credential not required
Default profile: local
Select one for a run with: joshbot agent --profile <name>
* marks the profile this run would use, . the configured default. --output json
works here as it does on the other reporting commands.
joshbot preflight resolves the config the same way the agent does and reports what
would actually be used — provider, the exact model ID sent on the wire, the API host,
and whether a credential is present and where it came from. It contacts no provider
and prints no credential, and it exits non-zero when joshbot would not start, so it is
usable as a scripted check:
$ joshbot preflight
config: ~/.joshbot/config.json
format: model-centric
workspace: ~/.joshbot/workspace
✓ claude (active) → anthropic model=claude-sonnet-4 endpoint=api.anthropic.com credential source=$MY_ANTHROPIC_KEY
✗ backup (fallback) → openrouter model=x credential source=not configured
problem missing-credential — model "backup" (provider "openrouter") has no credential; set api_key, api_key_env, or JOSHBOT_PROVIDERS__OPENROUTER__API_KEY
OK — joshbot would start.
Unlike every other command, preflight does not fall back to defaults when the config
file is unusable: a report about a default config you never wrote is the opposite of a
diagnosis.
The read-only reporting commands take a global --output flag with values text
(the default, byte-for-byte what they have always printed) and json:
joshbot --output json preflight
joshbot --output json status
joshbot --output json skills list
joshbot --output json auth status
joshbot --output json configure --list
The JSON document goes to stdout on its own, carries a schema_version field, and is
byte-stable across runs so two invocations can be diffed. Exit codes are unchanged —
--output json preflight still exits non-zero on a config that would not work. When a
command fails in JSON mode the failure is reported as a document on stdout too, so a
caller does not need a second reader on stderr:
{
"schema_version": 1,
"error": { "code": 2, "message": "not authenticated" }
}error.code is the process exit code. An unknown --output value is a usage error and
exits 3, which is how a script tells a typo apart from a command that ran and reported a
problem. No credential or home directory appears in either format.
Note this is separate from joshbot agent --output-format text|json|stream-json, which
shapes an agent turn rather than a report.
For backward compatibility, the old format still works:
{
"providers": {
"openrouter": {
"api_key": "sk-or-v1-your-key-here",
"enabled": true
}
},
"agents": {
"defaults": {
"workspace": "~/.joshbot/workspace",
"model": "openai/gpt-4",
"max_tokens": 8192,
"temperature": 0.7,
"max_tool_iterations": 20,
"memory_window": 50,
"streaming": true,
"subagent_max_depth": 2,
"timeout": "10m"
}
},
"channels": {
"telegram": {
"enabled": false,
"token": "",
"allow_from": []
}
},
"tools": {
"web": { "search": { "api_key": "" } },
"exec": { "timeout": 60 },
"restrict_to_workspace": true,
"shell_allow_list": [],
"filesystem_allowed_paths": [],
"shell_sandbox": "off",
"shell_sandbox_allow_network": false,
"shell_approval": "off"
}
}Two keys bound how long joshbot waits:
| Key | Bounds | Default |
|---|---|---|
agents.defaults.timeout |
one agent turn, end to end | 2m |
providers.<name>.timeout |
one request to that provider | 300s for ollama, 120s for every other provider |
Both accept a duration string — "600s", "10m", "1h30m" — which is the form
joshbot writes when it saves a config. A bare number is read as seconds, so
"timeout": 600 means ten minutes. This is Go's time.ParseDuration grammar, not
the cron tool's: cron also takes a "d" suffix, and "1d" here is an error.
agents.defaults.timeout can also be set as an environment variable —
JOSHBOT_AGENTS__DEFAULTS__TIMEOUT=10m — under the same rules, for a deployment
with no config file. An unparseable value is an error at startup, not a silent
fallback to the default you were trying to raise.
Raise agents.defaults.timeout when a turn legitimately runs long: a cold local
model with a large prompt regularly outruns the 2m default, and before this key
existed there was no way to change it short of patching the binary. It bounds the
top-level turn only; a subagent spawned by delegate_subagent and friends runs
under its own fixed 60s budget, which this key does not change.
Anything under one second is rejected at load, naming the key. A sub-second timeout fails every request the moment it is used and blames the context, not the config.
Upgrading: a config written by an older joshbot stores this as a raw nanosecond count (
"timeout": 900000000000). It is still read correctly — as 900s — and is rewritten in the string form the next time the config is saved.
tools.shell_sandbox adds OS-level containment for shell commands, on top of the deny list (which screens command text — a filter, not a boundary). It's off by default so upgrading doesn't silently change what an existing setup can do.
"off"(default) — no containment beyond the deny list."workspace"— confines the filesystem to the workspace plus toolchain build caches (e.g.GOCACHE,~/.cache);$HOMEand everything else outside that is unreachable. Outbound TCP is denied unlesstools.shell_sandbox_allow_networkistrue.
Per-platform enforcement:
| Platform | Mechanism when "workspace" |
Default ("off") posture |
|---|---|---|
| Linux | Landlock LSM (re-exec helper) | Deny-list only |
| macOS | Seatbelt (sandbox-exec profile) |
Deny-list only |
| Other (no sandbox available) | n/a | Allowlist-only — the shell tool falls back to a small set of non-escaping read/inspect commands unless the operator sets an explicit shell_allow_list |
While an allowlist is in force — whether set explicitly or defaulted on a
platform with no sandbox — a command containing a shell construct that can
introduce a second command word (;, &, |, a newline, a backtick, $(,
<(, >() is refused: the command is passed to sh -c unchanged, so matching
only the first word admitted echo hi; id. Run one command per call. The
default list deliberately omits find, go and git, each of which launches a
program of the caller's choosing; name them in shell_allow_list if you need
them.
It fails closed: an unrecognized value, or "workspace" on a host whose kernel lacks the needed support, is a startup error rather than a silent no-op — set it back to "off" to run without containment. The runtime default is intentionally not the sandbox: network-denied-by-default breaks common workflows, so macOS/Linux still rely on the deny list by default and you opt in with tools.shell_sandbox: "workspace".
tools.shell_approval asks you before a shell command runs. The sandbox
decides what a command is able to touch; approval decides whether it runs at
all, and the two are independent — you can enable either, both, or neither.
{
"tools": {
"shell_approval": "interactive"
}
}| Value | Behaviour |
|---|---|
"off" (default) |
Commands run without asking. |
"interactive" |
Prompt before each command, with a [a]ll for this session answer that stands until you exit. |
"always" |
Prompt before every command, with no remembered answer. |
The prompt shows the whole command and its working directory — the arguments are the dangerous part, so nothing is elided:
⚠️ shell wants to run:
rm -rf ./build
in /home/you/workspace
Allow? [y]es / [n]o / [a]ll for this session:
Two things are worth knowing before you turn it on.
Only the interactive CLI can ask. The approver is installed by the
interactive loop, and only when stdout is a real terminal. Every other entry
point — the Telegram and Discord gateway, cron jobs, the heartbeat scanner,
agent -m in a pipeline — has nobody to ask, so with the gate on its shell
commands are refused, not queued. That is deliberate: a prompt that blocked
would hang a background goroutine, and one that auto-approved on timeout would
not be a gate. If you run joshbot as a service and want gated shell access
there, leave shell_approval off and reach for shell_sandbox and
shell_allow_list instead.
Anything that is not an explicit y is a no, including a closed stdin, a
timed-out turn, and Ctrl-C at the prompt. An unrecognised value for the setting
itself is a startup error rather than a silent "off", so a typo can never
leave you believing commands are gated when they are not.
agents.defaults.streaming prints the assistant's reply as it arrives instead of
after the whole turn completes. It is on by default since v1.48.0 and applies
to both config formats. To restore whole-reply delivery:
"agents": { "defaults": { "streaming": false } }Upgrading from v1.47.x flips it on even though your saved config already contains
"streaming": false — the field has no omitempty, so every config written by
those versions carries that value whether or not you ever set it. The schema v4→v5
migration therefore resets it once, and logs that it did. Set it to false after
upgrading and it stays off.
Two limits are worth knowing before turning it on:
- It takes effect in the interactive CLI on a real terminal and on
Telegram.
joshbot agent -mand piped output are unaffected, so scripted output stays byte-identical. On Telegram the reply is sent once and then edited in place at most every 3 seconds; heartbeat turns never stream. A reply that grows past Telegram's 4096-byte limit rolls over into a new message, splitting on code-fence boundaries. Message formatting (Markdown) is applied only on the final edit — interim edits are sent as plain text, so a half-written code fence can never fail withcan't parse entities. While the agent runs tools, the Telegram chat shows a live status line ("⚙️ shell: go test ./...") that the streamed answer then replaces in place. - Streaming narrows the non-streaming path's transparent provider fallback
to one case. Once the first token has been printed it cannot be unprinted, so
a failure after text has appeared appends a visible
[stream error: ...]marker to the reply rather than silently retrying against the next provider. A stream that dies before any text arrived is retried invisibly through the non-streaming path — retries,Retry-Afterand the fallback chain all apply — and the reply simply arrives unstreamed. If you value the mid-text retry more than the latency, leave streaming off.
Where two sources set the same value, the later one in this list wins:
- Defaults — compiled in (
config.Defaults()). - Config file —
~/.joshbot/config.json, or whatever--configpoints at. - Environment variables — any
JOSHBOT_*variable overrides the file value for that key (config.Loadapplies the file first, then the env overrides). - Command flags — a flag that carries a config value (
onboard --provider,--api-key,--api-base,agent --model) overrides both.
Two things this list deliberately does not include:
- There is no project-scoped config. joshbot does not read a
.joshbot/orjoshbot.jsonfrom the working directory — one machine has one config, chosen by--configwhen you need a second. A per-directory config would silently change which provider and workspace an agent run used depending on where it was invoked from. --configselects which file is read; it is not itself an override. Point it at a file and the env layer still applies on top.--configanchors the whole home, not just the file. Sessions, media, cron and the skills trust store live beside the config that selected them, sojoshbot onboard --config /tmp/trial/config.jsonbuilds a complete second install under/tmp/trial/and leaves~/.joshbot/alone.
All config values can be set via environment variables with JOSHBOT_ prefix:
Model-Centric Format (New):
# Configure models
export JOSHBOT_MODELS_CONFIG__MODELS__0__NAME="smart"
export JOSHBOT_MODELS_CONFIG__MODELS__0__MODEL="anthropic/claude-sonnet-4"
export JOSHBOT_MODELS_CONFIG__MODELS__0__API_KEY="sk-ant-..."
export JOSHBOT_MODELS_CONFIG__MODELS__1__NAME="fast"
export JOSHBOT_MODELS_CONFIG__MODELS__1__MODEL="groq/llama-3.3-70b-versatile"
export JOSHBOT_MODELS_CONFIG__MODELS__1__API_KEY="gsk_..."
# Set active model and fallback chain
export JOSHBOT_MODELS_CONFIG__AGENT__MODEL="smart"
export JOSHBOT_MODELS_CONFIG__AGENT__FALLBACK="fast"Legacy Format:
export JOSHBOT_PROVIDERS__OPENROUTER__API_KEY="sk-or-..."
export JOSHBOT_CHANNELS__TELEGRAM__ENABLED="true"
export JOSHBOT_CHANNELS__TELEGRAM__ALLOW_FROM="123456789,987654321" # comma-separatedWith the new model-centric config, changing models is straightforward:
Use Anthropic Claude:
{
"models_config": {
"models": [{ "name": "claude", "model": "anthropic/claude-sonnet-4", "api_key": "sk-ant-..." }],
"agent": { "model": "claude" }
}
}Use OpenAI GPT-4:
{
"models_config": {
"models": [{ "name": "gpt", "model": "openai/gpt-4o", "api_key": "sk-..." }],
"agent": { "model": "gpt" }
}
}Use local Ollama (no API key needed):
{
"models_config": {
"models": [{ "name": "local", "model": "ollama/llama3.2" }],
"agent": { "model": "local" }
}
}With fallback chain:
{
"models_config": {
"models": [
{ "name": "primary", "model": "anthropic/claude-sonnet-4", "api_key": "sk-ant-..." },
{ "name": "fallback", "model": "groq/llama-3.3-70b-versatile", "api_key": "gsk_..." }
],
"agent": { "model": "primary", "fallback": ["fallback"] }
}
}GitHub Copilot uses a device-code OAuth flow and stores its token in ~/.joshbot/auth.json.
- Start authentication:
joshbot auth github-copilot
- Follow the on-screen device flow instructions.
- When prompted, choose a model (saved to
config.jsonwithenabled: true).
After auth, you can run joshbot agent or joshbot gateway normally.
The stored GitHub token does not expire on its own. joshbot exchanges it for a short-lived Copilot API token on each request and refreshes that automatically, so re-authentication is only needed if you revoke the authorization on GitHub. To force a fresh device flow when a token is already stored:
joshbot auth github-copilot --force
⚠️ BREAKING (unreleased): An emptyallow_fromnow denies every sender instead of allowing everyone. Previously a Telegram bot with no allowlist was open to the whole internet — anyone who found it got a direct line into an agent loop holding the shell tool. It now fails closed and logs a loud warning at startup naming the exact key to set. If you relied on an empty allowlist, your bot will reject all messages until you add your numeric Telegram user ID tochannels.telegram.allow_from. The same fail-closed rule applies to Discord'sallow_from.
- Message @BotFather and send
/newbotto create your bot - Copy the bot token
- Find your user ID from @userinfobot
- Add to config:
{
"channels": {
"telegram": {
"enabled": true,
"token": "123456789:ABCdef...",
"allow_from": ["123456789"]
}
}
}- Run:
joshbot gateway
channels.telegram.api_url points joshbot at a self-hosted telegram-bot-api
server instead of api.telegram.org.
Leave it unset for the public Bot API. Only http and https URLs are accepted,
and a malformed value is a fatal config error rather than a silent fallback.
{
"channels": {
"telegram": {
"enabled": true,
"token": "123456789:ABCdef...",
"allow_from": ["123456789"],
"api_url": "http://127.0.0.1:8081"
}
}
}Setting it raises the outbound attachment cap for send_file from 10 MiB to
50 MiB, on both the tool and the transport, from one shared rule so the two
cannot disagree. The ceiling is 50 MiB and not the local server's 2 GB because
the whole payload is held in memory from the tool call until the upload
finishes — the cap is a memory bound, and raising it further needs the
file-descriptor rework tracked in
#305.
It changes nothing inbound: what joshbot downloads from a chat and forwards to a provider is still bounded by the provider limits (5 MiB per image, 8 MiB per PDF), because those bound what a model is billed to read, not what Telegram will carry.
channels.telegram.reactions turns on an emoji acknowledgement placed on your
own message, so you get a "it heard me" signal before the first token of the
reply exists — and it costs no message slot, which matters in a group.
{
"channels": {
"telegram": {
"enabled": true,
"token": "123456789:ABCdef...",
"allow_from": ["123456789"],
"reactions": true
}
}
}👀 goes on the moment the turn is admitted to the bus, and 👍 replaces it when
the reply is on its way. Telegram's setMessageReaction sets rather than
appends, so the second write clears the first with no extra call. The completion
emoji is 👍 and not ✅ because ✅ is not in Telegram's free reaction set — a
premium-only emoji is rejected with REACTION_INVALID and the acknowledgement
would silently never appear.
It is off by default and opt-in: a bot in a group without permission to react would otherwise log a failure on every single turn. A reaction is an ornament on the turn and never part of it, so a failure is logged at debug and the reply is unaffected. A sender the allowlist rejects is never acknowledged.
On startup joshbot registers its command menu with Telegram (/start, /new,
/status, /model, /personality, /compact, /resume, /help), so they
appear behind the menu button and autocomplete as you type. If Telegram rejects
the registration it is logged and the bot starts anyway. A command that does not
exist gets an "Unknown command" reply listing the real ones instead of silence.
The menu is scoped to the allowlist. Every numeric allow_from entry is a
chat id, so each one gets its own per-chat menu and the global all-private-chats
menu is deleted: a stranger who finds the bot sees no menu at all. If any
allow_from entry is a username instead, the global menu is kept, because a
username cannot be turned into a chat id until that user speaks — an operator
allowlisted by name would otherwise see nothing. With an empty allowlist (which
denies everyone) the menu is simply deleted. An allowlisted user who has never
started the bot makes Telegram answer "chat not found" for their scope; that is
logged and the remaining users still get their menus.
The commands whose behaviour lives in the agent (/status, /model,
/personality, /compact, /resume) are forwarded to it with the same
allowlist gate as a direct message, so they work identically in the Telegram
menu and the CLI. This completes command names only — Telegram has no
mechanism to complete paths or arguments, so it does not match the TUI's Tab
completion for those.
While the agent is working, the "typing…" indicator is refreshed every 4 seconds until the reply is sent, so it stays visible for the whole turn.
channels.telegram.stream_drafts streams a turn through the Bot API
sendMessageDraft method
instead of the sendMessage + editMessageText loop. Telegram renders it as a
native animated draft, and before any output exists an empty-text draft renders
as Telegram's own "Thinking…" placeholder, so the phone shows the turn started
before the first token arrives.
{
"channels": {
"telegram": {
"enabled": true,
"token": "123456789:ABCdef...",
"allow_from": ["123456789"],
"stream_drafts": true
}
}
}Off by default and carrying omitempty, so it is absent from every config
joshbot has already saved and needs no schema migration.
Four things to know before switching it on:
- Private chats only. The Bot API documents
chat_idfor this method as "the target private chat", so a group or supergroup keeps the edit loop. - It needs a new enough Bot API. Empty text — the "Thinking…" placeholder — was allowed in Bot API 10.0 (changelog dated 8 May 2026). Note that #308 says "Bot API 9.3/9.5"; that is wrong, and the version above is what the published reference says. A server that does not know the method is not a problem: the first refusal turns drafts off for that turn and the edit loop carries the answer from that delta on, so nothing is lost but the animation.
- A draft is ephemeral. The reference calls it "a temporary 30-second
preview", and the finished text must still be sent with an ordinary
sendMessageto persist it. joshbot always does that at the end of the turn, so a draft never counts as delivery — which is also why a turn that streams nothing still gets its reply through the normal path while the placeholder expires on its own. - Tool progress rides the draft too. In draft mode the
⚙️ …status line goes into the draft slot rather than a real message, so there is no status message left over for the reply to replace.
It honours channels.telegram.api_url, since the raw call goes to the same
configured base URL.
Voice notes can be transcribed and answered like any text message. It is off by default — a voice message gets an honest "I can't listen yet" reply — and is enabled by naming which configured provider to transcribe through:
{
"stt": {
"provider": "groq"
}
}stt.provider reuses that provider's existing API key and endpoint — there is
no second credential to manage. It must be a provider with an OpenAI-compatible
/audio/transcriptions endpoint: groq (default model
whisper-large-v3-turbo) and openai (default model whisper-1) work out of
the box; any other provider needs an explicit stt.model and an api_base.
stt.timeout bounds one transcription request (default 60s, same duration
grammar as every other timeout). A misconfigured stt block is a startup
error naming the key, never a per-message failure.
Limits: voice notes over 10 minutes (by Telegram's declared duration) or 20 MB
are refused before any download. The audio is sent to the transcription
provider and is never stored — only the transcript enters the session, framed
as [Voice message, transcribed]: ... so the model knows how it arrived. A
failed transcription ends the turn with an error in the chat rather than
guessing.
joshbot also speaks Discord (gateway websocket + REST via the pure-Go
discordgo library, compiled into the single binary). Configure it in
config.json or via env vars — the onboarding wizard does not yet prompt for it:
{
"channels": {
"discord": {
"enabled": true,
"token": "your-bot-token",
"allow_from": ["123456789012345678"]
}
}
}allow_fromentries are numeric Discord user IDs (snowflakes), usernames, or global names. Like Telegram, an empty allowlist rejects everyone.- Env overrides:
JOSHBOT_CHANNELS__DISCORD__ENABLED,JOSHBOT_CHANNELS__DISCORD__TOKEN,JOSHBOT_CHANNELS__DISCORD__ALLOW_FROM(comma-separated). - Messages over 2000 chars are split (code-fence aware); the bot ignores its own
and other bots' messages;
/helpand/newwork as text commands.
⚠️ Enable one chat channel at a time. The message bus exposes a single outbound channel that channel implementations read competitively, so running Discord and Telegram simultaneously has them steal each other's replies — roughly half of each conversation's answers are delivered to the other service's chat, with no error anywhere. Until the bus fans out per channel, enable eitherchannels.telegramorchannels.discord, not both. (internal/channels/discord.go,consumeOutbound.)
joshbot serve exposes joshbot over the OpenAI chat API, so any client that can
point at a custom base URL — the OpenAI SDKs, curl, an editor plugin, another
agent — can use joshbot as a backend.
joshbot serve # binds api.listen (default 127.0.0.1:18791)
joshbot serve --listen 127.0.0.1:9000| Endpoint | Method | Purpose |
|---|---|---|
/v1/chat/completions |
POST | Run a turn. Supports "stream": true (SSE). |
/v1/models |
GET | List the served model. |
/v1/audio/transcriptions |
POST | Transcribe an audio upload. Needs stt.provider. |
/v1/embeddings |
POST | Embed one or more texts. Needs embeddings.provider. |
/healthz |
GET | Liveness. The only route needing no credential. |
The model is the agent. A request runs the full ReAct loop — tools, memory, skills, sessions — rather than proxying an upstream provider. Two consequences:
/v1/modelsreturns exactly one id,joshbot. There is nothing to select, so the request'smodelfield is accepted and ignored — a client hardcoded togpt-4works unchanged.- A
systemmessage from the client is dropped, not forwarded. joshbot's own system prompt carries its tool and safety rules, and letting a caller prepend to it would be a way to talk the agent out of them.
The optional user field selects the conversation: it becomes the session key
api:<user>, so two values are two independent histories. It defaults to
default, and is validated: at most 64 characters, and only letters, digits,
., _ and -. Anything else — a :, a path separator, a newline — is a 400,
never a file written outside the sessions directory or a name that renders
deceptively in joshbot sessions.
curl http://127.0.0.1:18791/v1/chat/completions \
-H "Authorization: Bearer $JOSHBOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"joshbot","messages":[{"role":"user","content":"what files are in my workspace?"}]}'from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:18791/v1", api_key="<your key>")
print(client.chat.completions.create(
model="joshbot",
messages=[{"role": "user", "content": "hello"}],
).choices[0].message.content)POST /v1/audio/transcriptions is the one route that is not the agent. It
transcribes a multipart/form-data upload with the speech-to-text provider
configured under stt and returns the text — no ReAct loop, no session, no
memory. It exists so a client that speaks the OpenAI audio API can reach the same
transcriber joshbot uses for Telegram voice notes, without a second credential
store.
curl http://127.0.0.1:18791/v1/audio/transcriptions \
-H "Authorization: Bearer $JOSHBOT_API_KEY" \
-F file=@voice.ogg \
-F response_format=json- Without
stt.providerthe route answers 501 naming the config key, rather than a 404 or a 200 carrying an empty transcript. response_formatacceptsjson(default,{"text": "..."}) andtext(text/plain).model,language,promptandtemperatureare accepted and ignored — the model comes fromstt.model, the same way/v1/chat/completionsignoresmodel.- The upload is capped at 25 MiB and its content is sniffed: flac, mp3,
mp4/m4a, ogg, wav and webm. A text file named
voice.mp3is a 400 and never reaches the provider, because a filename and a declared Content-Type are both written by the caller. - A provider failure is a 502 with the upstream text redacted, for the same reason chat errors are.
POST /v1/embeddings is the other route that is not the agent. It embeds one
or more texts with the provider configured under embeddings and returns the
vectors — no ReAct loop, no session, no memory. joshbot does not consume
embeddings itself (memory_search is lexical), so this exists to give a client
one endpoint and one credential store for both chat and retrieval.
{
"embeddings": {
"provider": "ollama",
"model": "nomic-embed-text",
"timeout": "60s"
}
}embeddings.provider must name a configured, enabled provider with an
OpenAI-compatible /embeddings endpoint, and reuses that provider's API key and
api_base — there is no second credential. Unlike stt, no API key is
required: ollama is keyless and is the main local case. embeddings.model
defaults per provider (ollama → nomic-embed-text, openai →
text-embedding-3-small); any other provider must set it explicitly.
embeddings.timeout bounds one request (default 60s, same duration grammar as
every other timeout — "60s", "2m", or a bare number of seconds). A broken
embeddings block is a startup error naming the key, never a per-request
failure.
curl http://127.0.0.1:18791/v1/embeddings \
-H "Authorization: Bearer $JOSHBOT_API_KEY" \
-H "Content-Type: application/json" \
-d '{"input":["dog","puppy"]}'- Without
embeddings.providerthe route answers 501 naming the config key. inputaccepts a bare string or an array of strings, since both are real SDK traffic.modelis accepted and ignored — the model comes fromembeddings.model.encoding_formatacceptsfloat(default) andbase64(raw little-endian float32 bytes, whatnumpy.frombuffer(..., dtype="float32")expects). Anything else is a 400.- Limits, all enforced before the provider is dialled: at most 128 inputs, at most 64 KiB per input, and the whole body under the shared 1 MiB request cap.
- Vectors are returned in input order, placed by the response's
indexfield rather than by array position. A provider that answers out of order is handled; one that answers with a duplicate, out-of-range or missing index is an error, not a silently mismatched vector. - A provider failure is a 502 with the upstream text redacted, for the same reason chat errors are.
joshbot serve can also serve a browser chat page at /. It is off by
default — set api.webui to true to enable it:
{
"api": {
"listen": "127.0.0.1:18791",
"api_keys": ["<a long random string>"],
"webui": true
}
}With it on, joshbot serve prints the URL in its startup banner. With it off,
every web UI route (/, /webui/static/..., /webui/login, /webui/logout,
/webui/config, /webui/session) answers 404 as if the feature did not exist.
The default is false on purpose: the page is a login form that accepts an
api.api_keys value, and that key reaches the shell and filesystem tools — a
form like that must not appear on every existing joshbot serve bind the moment
someone upgrades.
The page is served from the binary (//go:embed), uses system fonts and loads
nothing from a CDN, so it works with no network beyond joshbot itself.
How it signs in. A browser page cannot ship a key against a fail-closed
server, and a ?key= in the URL leaks into history, proxy logs and Referer, so
POST /webui/login takes an api.api_keys value and exchanges it for a session
cookie: 32 random bytes, HttpOnly, SameSite=Strict, Path=/, and Secure
when the request arrived over TLS. The key is checked through the same
constant-time comparison and the same rate-limited rejection logging the bearer
header uses. Sessions live in memory only, expire after 12 hours and are bounded,
so they vanish on restart — that is a logout, not a bug. POST /webui/logout
clears one.
The bearer path is untouched: Authorization: Bearer <key> is checked first and
is not subject to any of the cookie rules, so existing OpenAI clients keep working
unchanged. Only the cookie path requires an X-Joshbot-CSRF header (served by
GET /webui/config, compared in constant time) on non-GET requests, plus a
same-origin check.
What it does and does not do. Chat with streaming, over the same
POST /v1/chat/completions every other client uses; the transcript survives a
reload via a read-only GET /webui/session. "New conversation" mints a fresh
session key in the browser — it does not delete anything, and the old transcript
is still there for joshbot sessions. There is deliberately no model picker
(the API is agent-as-model; there is nothing to choose — #296), no settings
panel (editing config over HTTP is shell-grade privilege escalation — #297), and
no tool-progress lines (the API does not emit them today — #298).
Authentication is mandatory and there is no unauthenticated mode. A caller
that reaches this endpoint reaches the shell and filesystem tools, so joshbot serve refuses to start when no key is configured rather than starting open.
{
"api": {
"listen": "127.0.0.1:18791",
"api_keys": ["<a long random string>"]
}
}Or by environment: JOSHBOT_API__LISTEN, and JOSHBOT_API__API_KEYS as a
comma-separated list (it replaces the configured list rather than adding to it,
so a key can be revoked without editing the file). Keys are compared in constant
time and never appear in a response body or a log line.
The default bind address is loopback on purpose. Binding to 0.0.0.0 publishes
an agent with shell access to your network; if you need remote access, put it
behind a reverse proxy with TLS, and consider tools.shell_sandbox and
tools.shell_allow_list (see Shell Sandbox).
Requests are capped at 1 MB and must arrive within 60 seconds; there is no write
deadline, because a streamed answer legitimately outlives any fixed one.
Streaming responses are text/event-stream frames terminated by data: [DONE];
token usage rides the final frame and is the sum of every provider call the turn
made, not just the last.
The optional user field picks the session (api:<user>). It becomes part of a
filename, so it is capped at 64 characters and may contain only letters, digits,
., _ and -; omitting it puts every anonymous caller in one shared
conversation, which is the right default for a single-operator install.
Rejected requests are logged at most once a minute, with a count of how many the line covers — unauthenticated requests are the one thing an attacker can send without a credential, and a line each would fill the disk of any install that redirects the log to a file.
Two requests carrying the same user share one session, and joshbot serialises
them: the second waits for the first to finish rather than loading the same
history and overwriting it. They are queued, not parallel, which is what a single
conversation means — a client that wants genuine concurrency should send distinct
user values. A request that gives up waiting fails on its own timeout rather
than blocking indefinitely. The lock is process-local: two joshbot processes
sharing one sessions directory (the gateway plus a concurrent joshbot agent -m)
can still interleave.
One limit worth knowing. A client that disconnects mid-turn cancels the request context, so that turn is not saved to the session — the conversation resumes from the last completed turn.
memory_searchis lexical and does not use embeddings./v1/embeddingsis served for callers, not consumed by joshbot itself.
joshbot ships a stdio MCP client. Declaring a
server is an operator-only act — config.json lives outside the workspace
and cannot be written by a workspace-confined tool, so it is the trust boundary.
Discovered tools are registered under a namespaced name mcp__<server>__<tool>,
so a server can never shadow a built-in tool like shell.
{
"mcp": {
"servers": {
"myserver": {
"command": "some-mcp-server",
"args": ["--stdio"],
"env": { "FOO": "bar" },
"enabled": true
}
}
}
}Note: declared servers are started during component setup; startup is fail-soft, so a server that will not start is logged and skipped rather than aborting joshbot, and the processes are reaped on exit. MCP child processes get the same allowlisted, credential-screened environment as shell children (no provider API keys), but their filesystem access is not sandboxed. See
SECURITY.md.
An enabled server is inert until you approve it, the same way a workspace skill is. Review what it advertises, then approve it:
joshbot mcp list # servers, trust state, and the tools each advertises
joshbot --output json mcp list # same, machine-readable
joshbot mcp trust myserver # approve the manifest you just read
joshbot mcp untrust myserver # revokeApproval is bound to a hash of the server's advertised tool manifest — names,
descriptions and input schemas — stored in ~/.joshbot/mcp.trust (mode 0600).
If the server later changes anything it advertises, approval is revoked
automatically and its tools disappear until you review and re-approve. Until
then it contributes nothing: no tool of its is callable and no text of its
reaches the prompt.
| Tool | Description |
|---|---|
read_file |
Read file contents |
write_file |
Write/create files |
edit_file |
Find-and-replace editing |
list_dir |
List directory contents |
glob |
Find files by pattern |
grep |
Search file contents |
shell |
Execute shell commands (deny-listed, allowlisted env, optional Linux sandbox) |
web_search |
Search the web (exa-cli / Exa MCP / DuckDuckGo — no key required) |
web_fetch |
Fetch and extract web page content |
message |
Send messages to other channels |
send_file |
Send a workspace file to the chat as a native attachment (photo or document; type decided by sniffing the bytes) |
memory_search |
Search stored facts by keyword, category, or tags, plus Dream consolidated insights when dream_mode is on |
skill_registry |
List, create, and delete skills (workspace skills need joshbot skills trust before use) |
parallel_subagent |
Run multiple subagent tasks in parallel |
chain_execution |
Run subagent steps sequentially, feeding output forward |
delegate_subagent |
Spawn a child subagent (leaf or orchestrator) with an optional model override; nesting depth is bounded |
Security defaults:
web_fetchandweb_searchblock localhost, private IP ranges, and metadata hosts (SSRF protection), enforced at dial time.restrict_to_workspacelimits file and shell operations to the workspace unless explicitly allowed.send_fileis an egress path — it moves workspace bytes out of the process — so it resolves its path through the same two containment layers as thefilesystemtool and refuses anything outside the workspace, including an escape via an intermediate symlink. The bytes are read once through that contained handle and carried on the message; nothing re-opens the path afterwards. The recipient is the channel the turn arrived on — the tool takes no address, so the model cannot choose where a file goes.- Shell commands get an allowlisted environment, not joshbot's own — provider API keys and other secret-shaped variables are never inherited.
tools.shell_sandbox: "workspace"additionally confines shell commands with an OS-level sandbox (Landlock on Linux, Seatbelt on macOS) — see Shell Sandbox below. On platforms with no sandbox, the shell tool falls back to allowlist-only by default.tools.shell_approvalasks before each shell command runs — see Shell Approval. Only the interactive CLI can prompt; unattended turns (gateway, cron, heartbeat) are denied rather than left blocking.- Everything joshbot logs or prints is redacted first: API keys,
Authorizationheaders, credential-shaped assignments and your home directory path are replaced with[REDACTED]and~, so a log orjoshbot statusdump can be pasted into a bug report. Session files on disk are deliberately exempt and stay verbatim at0600— rewriting conversation content on save would mangle legitimate text.
| Command | Channel | Description |
|---|---|---|
/start |
Telegram | Start a conversation (shows the help text) |
/new |
Telegram, Discord, CLI | Start a fresh session (clears context, model override and personality). Takes effect immediately even while a long turn is still running — it is the one command not queued behind the in-flight turn |
/status |
Telegram, CLI | Show the current model, tool count, memory window and max iterations |
/model [name] |
Telegram, CLI | Switch model for this session (--global makes it the default for all sessions) |
/personality [name] |
Telegram, CLI | Set a named personality (concise, technical, pirate, cheerful, formal), any custom instruction, or none to clear |
/compact |
Telegram, CLI | Summarize older conversation context now |
/help |
Telegram, Discord, CLI | Show available commands |
/clear |
CLI | Clear the terminal screen |
/history |
CLI | Show input history |
/quit, /exit |
CLI | Exit the program |
When joshbot agent runs in a real terminal (stdin and stdout are TTYs), the
plain > prompt is replaced by a lightweight line editor:
- Tab cycles slash-command completions, with a hint line listing candidates.
- Up / Down recall history on a single-line buffer, or move the cursor between lines in a multiline buffer.
- Left / Right / Home / End / Backspace / Delete move and edit the line.
- Alt+Enter (or Ctrl+J) inserts a newline for multiline editing.
- Ctrl+C quits; Ctrl+D quits on an empty buffer, otherwise deletes forward.
The prompt shows the session's current model, so a /model switch is visible
before you type your next message. The editor activates only when both input
and output are real terminals — piped or scripted joshbot agent output is
untouched.
/model and /personality changes are per-session and persisted, so a
model you pick mid-conversation survives a restart.
joshbot/
├── cmd/joshbot/ # CLI entry point
├── internal/
│ ├── agent/ # Core brain (loop, context)
│ ├── memory/ # Structured fact store (fact.go, search.go, metadata.go)
│ ├── skills/ # Skill discovery, detection, extraction, validation
│ ├── tools/ # Built-in tools (incl. memory_search, skill_registry)
│ ├── channels/ # Chat integrations (Telegram, Discord)
│ ├── bus/ # Message bus (decouples channels from agent)
│ ├── providers/ # LLM provider layer
│ ├── mcp/ # stdio MCP client (namespaced tool registration)
│ ├── session/ # Conversation persistence (JSONL)
│ ├── cron/ # Task scheduling
│ ├── heartbeat/ # Proactive wake-ups
│ └── config/ # Configuration loading
Key patterns:
- Message bus: Channels decoupled from agent via async queues
- ReAct loop: LLM → tools → reflect → repeat (max 20 iterations)
- Progressive skill loading: Minimal context overhead, full content on demand
- Plain-file memory: No databases, just markdown — simple and portable
- Context compression: Summarizes old context to stay within token limits
- Prompt caching: Static system prompt cached with mtime-based invalidation, reducing file I/O on every message
- Model-centric config: Provider auto-detected from model prefix, fallback chains for resilience
"No providers configured" — Run joshbot onboard or create ~/.joshbot/config.json with at least one provider.
LLM calls failing — Check your API key. Run joshbot status to verify configuration.
Telegram bot not responding — Verify channels.telegram.enabled is true and check your user ID is in allow_from.
GitHub Copilot not authenticated — Run joshbot auth github-copilot. If a token is already stored, use joshbot auth github-copilot --force to redo the device flow.
"URL blocked by security policy" — web_fetch blocks localhost/private IPs and metadata endpoints to prevent SSRF. Use a public URL or proxy through an external service.
Getting "I've processed your request." instead of actual responses — This can happen when:
- The LLM returns empty content (rate limiting, API errors)
- Tool execution completes but the follow-up LLM call fails
To diagnose, run with --debug flag:
joshbot agent --debugDebug output will show:
- LLM response details (content length, tool calls, finish reason)
- HTTP response status codes
- Empty content warnings when detected
- Fallback provider activation
Rate limits (HTTP 429) and transient server errors are retried on the same
provider with backoff before falling over — providers.<name>.max_retries
(default 2) controls how many times. If you see persistent 429s, add a fallback
chain from the CLI — no config editing needed:
joshbot configure --provider groq --api-key "$GROQ_API_KEY"
joshbot configure --fallback "nvidia,groq"MIT — see LICENSE.
JOSHBOT_WORKSPACE: (add description)