joshbot is built around a goroutine-based message bus that decouples chat channels from a ReAct agent loop. The agent is backed by a multi-provider LLM layer via OpenRouter-compatible APIs. Everything — memory, skills, tools — is file-based, making the system portable and auditable without a database.
Every message — from CLI, Telegram or Discord — follows the same path through the system.
InboundMessage.InboundMessage into the agent goroutine via a channel. The bus handles multiplexing across channels.OutboundMessage on bus → routed to correct channel → displayed to user.The project follows Go conventions with cmd/ for the entry point and internal/ for all implementation packages.
joshbot docs check
│ ├── channels/ Channel interface + Telegram and Discord implementations
│ ├── mcp/ stdio MCP client + server manager (namespaced tool import)
│ ├── bus/ goroutine message bus
│ ├── providers/ LLM provider layer (OpenRouter, Anthropic, etc.)
│ ├── session/ JSONL conversation persistence, inventory, transcript formatting
│ ├── subagent/ restricted subagent runner
│ ├── cron/ task scheduler
│ ├── heartbeat/ proactive wake-up checker
│ ├── context/ budget manager, compression
│ ├── config/ JSON config, env overrides
│ ├── learning/ summary extraction
│ ├── log/ structured logging (charmbracelet/log)
│ ├── copilot/ GitHub Copilot auth
│ ├── configure/ config wizard
│ ├── service/ systemd/launchd install
│ └── integration/ integration tests
├── docs/ documentation
├── skills/ workspace skills (SKILL.md files)
├── site/ ← website (this page)
├── README.md
├── AGENTS.md
├── CLAUDE.md
├── CHANGELOG.md
└── VERSIONThese design decisions shape how every component works.
Channels decoupled from agent via chan InboundMessage and chan OutboundMessage. Adding a new channel requires zero agent changes.
LLM → tools → reflect → repeat (max 50 iterations by default). Each iteration appends tool results as observations for the next LLM call.
No databases. MEMORY.md, HISTORY.md, SKILL.md files. Portable, auditable, grep-able, syncs via Dropbox/git.
Skills summarized in context; full content loaded on demand. Keeps token overhead minimal until a skill is actually invoked.
Static system prompt segments cached with mtime-based invalidation. Reduces file I/O on every message — critical for memory/skill files read each turn.
Provider auto-detected from model prefix (claude → Anthropic, gpt → OpenAI). Fallback chains for resilience. ExtraBody support for provider-specific fields.
Conversation history summarized when approaching token limits. Budget manager tracks and enforces per-iteration costs.
Parallel fan-out, sequential chain execution, or hierarchical delegation. Each subagent runs an isolated ReAct loop with tool access and its own system prompt. An orchestrator subagent can spawn child subagents via delegate_subagent (leaf or orchestrator, optional per-task model override); nesting depth is bounded (agents.defaults.subagent_max_depth, default 2) so a recursive chain cannot grow unbounded, and a leaf subagent is not offered the subagent-spawning tools. A run can be constrained to a JSON output schema (required keys, optional per-key types) — a mismatch gets one repair round-trip and then errors rather than returning prose as success — and RunBackground returns a handle with cancel, wait and a once-only completion callback for work that outlives the turn that started it. Results merged back into the parent agent context.
A deny-list screens command text, an allowlisted environment strips API keys before spawn, and an opt-in OS sandbox (Linux Landlock, macOS Seatbelt) confines the filesystem and network — each layer catches what the one before it can't guarantee.
A workspace SKILL.md becomes standing system-prompt content, so it's inert until an operator runs joshbot skills trust. Trust is bound to a content hash and stored outside the workspace, so editing a trusted skill revokes it.
internal/agent/)The core ReAct loop. Each message triggers up to 50 think-act-observe iterations by default. System prompt is assembled from identity files + memory + skills summaries (with mtime-based caching). The agent serializes the tool schema from the Registry into the LLM's function calling format.
Slash commands are one table (internal/commands) that feeds the agent's /help, the Telegram menu, the Discord list, the CLI's Tab completion and every unknown-command reply; every channel forwards every command to the agent, none answers one locally, so a command reads and behaves the same everywhere. They dispatch on the command word and include /new, /model [name], /personality [name], /compact, /status and /help. /model and /personality are per-session — each Session carries ModelOverride and Personality persisted in its metadata sidecar, so a model or personality chosen mid-conversation survives a restart (resolution order: session override, then a global runtime override, then the config default). /model ... --global writes the config default for all sessions. /new clears both; /compact summarizes context on demand. These commands are forwarded from the Telegram menu too, with the same allowlist gate as a direct message. On Telegram a bare /model or /personality carries its list as an inline keyboard (internal/channels/telegram_picker.go): the agent exposes the entries as agent.Choice values, the gateway attaches them as a typed channels.Keyboard, and a press runs the very same command turn a typed /model <spec> runs for the presser's own session, then edits the picker message in place. A switch keeps the transcript by design; only /new resets. While a turn streams, the in-progress message carries a [⏹ Stop] button (internal/channels/telegram_stop.go): the press runs on the Telegram poller goroutine and calls the turn's CancelFunc directly, never through the bus or the per-session lock the running turn holds — a Stop queued behind the turn it cancels would do nothing. Tokens are per turn, valid only from the chat they were issued in, and released when the turn settles.
internal/tools/)Built-in tools implement the Tool interface (the optional message, skill and cron tools register only when their dependency is wired). Tools discovered from configured MCP servers register alongside them under a server-scoped name, so an MCP server cannot shadow a built-in. Each declares Name, Description, Parameters, and Execute. The Registry handles discovery and execution. Tools include: filesystem (plus read/write/edit/list/glob/grep aliases), shell (deny-listed, opt-in sandbox), web fetch (SSRF-protected, aliased for search/fetch/code/company/research), memory search, skill management, config, parallel subagent, chain execution, delegate subagent, subagent profile config, message sending, and cron (duration-based scheduled reminders).
Shell commands run with an allowlisted environment (internal/childenv) rather than inheriting joshbot's own process environment — provider API keys and other secrets are stripped before a command spawns, screened a second time against secret-shaped variable names. Optionally, tools.shell_sandbox: "workspace" confines shell commands to the workspace plus build caches and denies outbound network (internal/tools/sandbox_<os>.go): Linux uses Landlock via a re-exec helper, macOS a deny-by-default Seatbelt profile through /usr/bin/sandbox-exec. Outbound TCP is gated separately by tools.shell_sandbox_allow_network. It is off by default and fails closed — enabling it on a platform with no sandbox is a startup error, and on such platforms the shell tool defaults to allowlist-only, since the deny list alone is bypassable. The macOS profile allowlists mach-lookup by service name rather than granting it wholesale — a blanket grant reaches XPC services that run outside the profile and would act on the sandboxed process's behalf. Whenever an allowlist is in force, a command containing a construct that can introduce a second command word (;, &, |, newline, backtick, $(, <(, >() is refused outright, because first-word matching admitted echo hi; id. Web fetches are checked for SSRF both by hostname up front and again at dial time against the resolved IP, closing the DNS-rebinding gap between the two checks.
Human approval gate. tools.shell_approval ("off" default, "interactive", "always") requires an explicit y before a shell command runs, with the whole command line and working directory shown — the arguments, not the binary, are the dangerous part. It is orthogonal to the sandbox: approval decides whether a command runs, the sandbox decides what it can reach. The Approver is carried on the request context (tools.WithApprover) rather than on the tool struct, for the same reason the progress sink is — a struct field would be shared across the concurrent Process calls Telegram makes and would hand one conversation's prompt to another's turn. It fails closed by construction: ApproverFromContext returns DenyAll when none is installed and Deny is the zero Decision, so cron jobs, the heartbeat scanner, Discord turns and piped agent -m runs — none of which has a human to ask — are refused immediately rather than blocking a background goroutine or auto-approving on timeout. On the Telegram gateway the gate can ask: the command is posted in the chat with an inline keyboard (Allow / Deny, plus a session-wide allow under "interactive") and the turn blocks on the press, bounded by the turn's own timeout — installed only for turns whose inbound message carries its own chat id, with the press matched to its request by id and chat behind the sender allowlist. EOF, a context deadline, Ctrl-C and an approver that errors are all denials; ExecuteAsync is gated identically so async=true is not a bypass; and the gate runs after deny-list and allowlist screening, so a command that would be refused anyway never produces a prompt.
Anything joshbot logs or prints goes through internal/redact first. The log writer and joshbot status are wrapped, so a credential that arrives in a tool result — the model runs cat config.yml and the output carries an API key — never reaches the log verbatim. Vendor key shapes, Authorization headers (the scheme is kept, the credential replaced) and credential-shaped assignments become [REDACTED], and the host home directory becomes ~. Session files on disk are deliberately exempt: rewriting conversation content on save would mangle legitimate text and add a second way for a session to be corrupted, so data at rest relies on the 0600 mode instead.
internal/memory/)Two files: MEMORY.md (always in context — key facts, preferences, decisions) and HISTORY.md (grep-searchable event log for recall). Facts are extracted after each conversation turn by the learning module.
Dream is an optional second track, off unless agents.defaults.dream_mode is "record" or "full". Stage 1 rides on the history append and writes each turn to dream_raw.log; Stage 2 fits a TF-IDF embedding in-process, clusters the records by cosine similarity, and appends durable insights to dream_consolidated.jsonl before clearing the raw log — persist first, so a crash costs a re-run rather than the records. Embeddings are local: there is no embedding API and no new dependency. Insight confidence decays with a 30-day half-life, applied both when promoting an insight to a fact and when ranking a similarity search, so a stale cluster loses to a fresh fact instead of outranking it indefinitely. Search reads the consolidated file rather than the in-memory vector store, so insights survive a restart. The mode is a string rather than a bool on purpose: joshbot's config bools have no omitempty and are serialized into every saved config, so a bool default can never be flipped without a schema migration. joshbot memory status and joshbot memory consolidate are the operator's view of it; without them a configured mode would be indistinguishable from a no-op.
internal/skills/)Markdown files with YAML frontmatter in workspace/skills/{name}/SKILL.md. Auto-discovered on startup. Progressive loading: summary only in system prompt, full content loaded when the skill is invoked. The detection module observes tool usage patterns and auto-creates skills. Workspace skills are inert until an operator approves them with joshbot skills trust — approval is bound to a hash of the skill's content, stored outside the workspace, so editing an approved skill (or having something write a new one) revokes trust rather than inheriting it. Skills bundled with the release itself are exempt.
internal/providers/)Multi-provider LLM support via a unified Provider interface. Registered providers: OpenRouter, OpenAI, NVIDIA, Groq, Ollama, Anthropic, Poolside, Azure, Custom, LiteLLM, GitHub Copilot. Provider auto-selection by model prefix. ExtraBody support for provider-specific fields (e.g., poolside's chat_template_kwargs).
Images ride on Message.Images and are serialized at a single point: every provider, Anthropic included, is dialled through the OpenAI-compatible wire format, so one Message.MarshalJSON covers them all. A text-only message takes a fast path and serializes byte-identically to what it did before attachments existed; a message carrying images becomes a content parts array with image_url data URLs. Type is decided by sniffing the bytes (PNG, JPEG, GIF, WebP), never by filename or declared MIME. Limits are 5 MB per image and 20 MB per request. SupportsVision screens the request against every candidate model before the first network call, in both Chat and ChatStream, and fails closed on unknown models — the resulting error names the models tried and the config key to change, instead of a provider 400 arriving mid-conversation. Sessions persist an ImageRef (type, size, SHA-256), not the bytes: session JSONL is exempt from redaction and protected only by its 0600 mode, and re-sending stored images would re-bill them on every later turn inside the memory window. On Telegram the download happens strictly after the allowlist check — it carries a file id to the Bot API and confirms the bot is live — and an over-limit photo is refused from its declared size without spending the transfer.
Documents ride on Message.Documents through that same single serialization point, as OpenAI Chat Completions file content parts ({"type":"file","file":{"filename":…,"file_data":"data:application/pdf;base64,…"}}). The text-only fast path still serializes byte-identically, which is pinned by a test comparing exact JSON bytes. PDF is the only supported type and it is decided by the %PDF- magic, never by filename or declared MIME; limits are 8 MiB per document and 16 MiB per request. SupportsDocuments is a separate, narrower list from SupportsVision — a model that reads images does not necessarily parse PDFs — and screenForDocuments runs it against every candidate model before the first network call from both Chat and ChatStream, failing closed on unknown models with an error naming them. Sessions persist a DocumentRef (label, type, size, SHA-256), never the bytes.
internal/channels/)Channel is the interface; Telegram (telebot, long-polling) and Discord (discordgo, an outbound gateway websocket) implement it. Neither needs an inbound port or a public webhook URL. Both split long replies on a code-fence-aware boundary (4096 chars for Telegram, 2000 for Discord), keep a typing indicator alive for the length of an agent turn, and enforce the allowlist deny-by-default: an empty or unset allow_from rejects every sender and the channel logs an actionable startup warning naming the config key. The interactive CLI is not a Channel — it is runAgentLoop in cmd/joshbot/main.go.
channels.telegram.api_url points the Telegram channel at a self-hosted telegram-bot-api server instead of api.telegram.org; empty means the public Bot API, and a non-http(s) or unparseable value is a fatal config error rather than a silent fallback. Setting it raises the outbound attachment cap from 10 MiB to 50 MiB through one shared rule (channels.TelegramAttachmentLimitsFor) that both the send_file tool and the transport read, so the producer cannot refuse a send the transport would accept. The 50 MiB ceiling is a memory bound, not a protocol one — the payload is held in memory for the length of the upload. Inbound limits are untouched: what is downloaded and forwarded to a provider is still bounded by providers.MaxImageBytes and providers.MaxDocumentBytes.
channels.telegram.stream_drafts streams a turn through the Bot API sendMessageDraft method instead of the sendMessage + editMessageText loop, so Telegram renders the growing answer as a native animated draft and an empty-text draft renders as Telegram's own "Thinking…" placeholder before the first token exists. It is off by default and carries omitempty, so it is absent from every config joshbot has already saved and needs no schema migration. Three constraints define it. It is private-chat only, because the Bot API documents chat_id for the method as the target private chat. It needs a Bot API server new enough to know the method — empty text was allowed in Bot API 10.0 (changelog dated 8 May 2026, not the 9.3/9.5 that issue #308 claims) — and the first refusal disables drafts for that turn only, with the edit loop carrying the answer from that delta on, so an old server costs the animation and not the reply. And a draft is ephemeral: the reference calls it a temporary 30-second preview and requires an ordinary sendMessage to persist the finished text, which is why a draft write never marks the reply delivered and the persisting send still runs at the end of every turn. Tool progress rides the draft slot too, so in draft mode no status message is created and none has to be cleaned up.
Telegram and Discord can both be enabled at once. The bus fans out every outbound message to a private channel per registered consumer (bus.OutboundChannel, one call per channel implementation), so each one sees every reply and delivers only the ones addressed to it — no shared channel, no race, no silently dropped replies.
Telegram UX. Replies are authored as Markdown and sent as HTML (three escapes instead of MarkdownV2's eighteen, so ordinary prose like snake_case survives; anything Telegram still rejects degrades to the plain Markdown source, never gets lost) and thread to the message that asked. While the agent works, the chat shows a live tool-progress status line ("⚙️ shell: go test ./...") that the streamed answer replaces in place. When channels.telegram.reactions is on (opt-in, off by default), the turn is acknowledged with an emoji on the user's own message: 👀 the moment it is admitted to the bus, replaced by 👍 when the reply is on its way. It costs no message slot, so it works in groups; setMessageReaction sets rather than appends, so the second write clears the first; and the completion emoji is 👍 rather than ✅ because ✅ is premium-only and would be rejected with REACTION_INVALID, leaving an acknowledgement that silently never appears. It is best effort — a bot without permission to react logs at debug and the turn is unaffected. The last chat id per channel is persisted (~/.joshbot/chat_ids.json, 0600, atomic writes), so a cron reminder fired after a restart still knows where to go. Voice notes are transcribed when stt.provider names a configured provider with an OpenAI-compatible /audio/transcriptions endpoint (groq, openai — key and endpoint reused, audio never stored, over-limit refused from declared metadata before download). Media the agent cannot perceive — untranscribed voice, audio, video — gets an honest "I can't listen/watch yet" reply with no LLM turn spent (a caption is forwarded, framed so the model knows what it cannot see); stickers are quietly ignored; an edited message is forwarded as a correction rather than brand-new context. Text documents (txt, md, csv, json, code) are downloaded after the allowlist check and their content inlined into the turn, capped at 64KB with a visible truncation marker — binary content behind a text name is refused by the bytes, not the label. PDFs are downloaded on the same terms and carried as document attachments, refused from their declared size before any transfer and read through a LimitReader at cap+1 so a lying declaration is refused rather than truncated; Office formats (docx, xlsx, pptx) stay refused, with the refusal listing what is supported.
internal/api/)joshbot serve exposes POST /v1/chat/completions (with SSE streaming) and GET /v1/models in the OpenAI chat dialect, so any client that accepts a custom base URL can drive joshbot. The served model is the agent, not a provider: every request runs the full ReAct loop — tools, memory, skills, sessions — so /v1/models advertises exactly one id (joshbot) and the request's model field is accepted and ignored, which keeps a client hardcoded to gpt-4 working unchanged. The optional user field selects the conversation (session key api:<user>) and is validated before it can build a path.
Authentication is mandatory and there is no unauthenticated mode. A caller reaching this endpoint reaches the shell and filesystem tools, so api.New drops blank entries from api.api_keys and refuses to construct a server when nothing usable is left — the failure happens before anything is listening, rather than starting open. Bearer tokens are compared with crypto/subtle and never appear in a response body or a log line, the default bind is loopback (api.listen, 127.0.0.1:18791), bodies are capped at 1 MB, and /healthz is the one deliberately unauthenticated route. A system message sent by a client is dropped rather than forwarded: joshbot's own system prompt carries its tool and safety rules, and letting a caller prepend to it is a way to argue the agent out of them. Because agent.Process reports LLM failures in band as reply text, the handler translates them back — a pre-stream failure is a 502, a mid-stream one is reported inside the stream without a finish_reason: "stop" — never a 200 carrying an error as the answer. /v1/embeddings is served when embeddings.provider is set; joshbot does not consume embeddings itself (memory_search is lexical), so the route exists to give a client one endpoint and one credential store for both chat and retrieval.
POST /v1/audio/transcriptions is one of the two routes that are not the agent. It transcribes a multipart/form-data upload with the speech-to-text provider configured under stt — no ReAct loop, no session, no memory — so a client speaking the OpenAI audio dialect reaches the same transcriber Telegram voice notes use, with one credential store rather than two. Without stt.provider it answers 501 naming the config key, because a 404 reads as "joshbot cannot do this" and a 200 with an empty transcript cannot be told apart from silence. The body is read through r.MultipartReader rather than ParseMultipartForm, which spills past its memory budget to temp files: streaming keeps the audio off disk and denies an authenticated caller a disk-fill primitive. The upload is capped at 25 MiB (matching what the upstream endpoint enforces, so joshbot refuses locally and names the limit instead of spending the transfer) and read through a LimitReader at cap+1 so over-limit is distinguishable from truncated. Content decides the type, exactly as it does for images: providers.SniffAudio checks the bytes for flac, mp3, mp4/m4a, ogg, wav or webm, because the multipart filename and its declared Content-Type are both written by the caller — a 25 MiB text file named voice.mp3 would otherwise be uploaded and billed before anything noticed. model, language, prompt and temperature are accepted and ignored for drop-in compatibility; the model comes from stt.model. A provider failure is a 502 with the upstream text redacted, since an API caller is authenticated but is not the operator.
POST /v1/embeddings is the other. It embeds one or more texts with the provider named by embeddings.provider, reusing that provider's key and api_base, and returns the vectors — no ReAct loop, no session, no memory. Unlike stt it needs no API key, because ollama is keyless and is the main local case; embeddings.model defaults per provider (ollama → nomic-embed-text, openai → text-embedding-3-small) and any other provider must set it. Without embeddings.provider the route answers 501 naming the config key, and a broken embeddings block is fatal at serve startup rather than at the first request. Vectors are placed by the response's index field, never by array position: the OpenAI schema carries an index precisely because the array may come back reordered, and a vector attached to the wrong input is invisible — the caller stores it, retrieves the wrong text, and nothing ever errors. A duplicate, out-of-range or missing index is an error rather than a silent mismatch. The caps — 128 inputs, 64 KiB per input, on top of the shared 1 MB body limit — are enforced before the provider is dialled, since a limit checked after the work has run has not limited anything. input accepts a bare string or an array of strings, encoding_format accepts float and base64 (raw little-endian float32), and model is accepted and ignored. A provider failure is a 502 with the upstream text redacted, per the same origin split.
internal/api/webui.go, internal/api/webui/)Off by default, and the default is the security decision. Setting api.webui to true makes joshbot serve also serve a browser chat page at /, its assets under /webui/static/, and the three routes the page needs: POST /webui/login, POST /webui/logout, GET /webui/config and the read-only GET /webui/session. With it false, registerWebUI is never called and all of them answer 404 as if the feature were absent; a session cookie is not honoured either. The reason is what the page is: a login form that accepts an api.api_keys value, and that key reaches the agent loop and therefore the shell and filesystem tools — a form like that must not appear on every existing serve bind the moment someone upgrades. The page, its stylesheet and its script are embedded with //go:embed, use system fonts and load nothing from a CDN, so the single-binary, zero-runtime-deps property is unchanged.
Two authentication paths, and the bearer one is first and ungated. A browser cannot ship a bearer key against a fail-closed server; ?key= is refused because it leaks into history, proxy logs and Referer; a loopback exemption is refused because it would disable authentication for every process on the host. So POST /webui/login exchanges a configured key — checked through the same subtle.ConstantTimeCompare path and the same rate-limited rejection logging the bearer header uses — for a cookie holding 32 crypto/rand bytes: HttpOnly, SameSite=Strict, Path=/, and Secure when the request arrived over TLS. Server-side sessions are in memory only, expire, and are bounded, so the map cannot be grown and a restart signs everyone out. requireAuth checks Authorization: Bearer first and returns on a match with no origin check and no CSRF requirement, so existing OpenAI clients are entirely unaffected; only the cookie path additionally requires a same-origin check and a matching X-Joshbot-CSRF header (constant-time compared, served per session by GET /webui/config) on anything that is not a GET or HEAD. The document is served under default-src 'none' with no inline script or style, so model output rendered into the transcript — as text nodes, never innerHTML — cannot execute.
Chat goes through the same POST /v1/chat/completions with stream: true that every other client uses; there is no second chat path. The transcript survives a reload because GET /webui/session returns the current session's user and assistant turns (tool, system, compaction-record and empty-content messages are filtered out by transcriptReader in cmd/joshbot/serve.go). It is read-only: "New conversation" mints a fresh browser-side session key and deletes nothing, because a destructive button should not be reachable through a cookie. Deliberately absent in v1: a model picker (the API is agent-as-model — one advertised id, model ignored — so a picker would be a lie), a settings panel (editing config.json over HTTP is shell-grade privilege escalation) and tool-progress lines (agent.StreamEvent carries only {Delta, Done} and internal/api never installs agent.WithSink, so they do not exist to render).
internal/mcp/)Outbound attachments (send_file). A file leaves the process the same way text does: the tool publishes a typed bus.Attachment on the outbound message — a slice on OutboundMessage, not a Metadata key, because a mistyped map key sends nothing and reports success — and the channel decides how to deliver it. Telegram branches before its text path and sends a Photo or Document; a channel with no attachment support appends the name, size and workspace-relative path to the message text under an explicit marker rather than dropping it. Three rules define the tool. The path is contained twice, through the workspace check and through the openat(2) component walk in internal/tools/openat.go, so a lexical escape and an intermediate-symlink escape both fail before any bytes are read. Content decides photo-versus-document: the first 512 bytes are sniffed, never the extension, mirroring the inbound image rule. And there is exactly one egress route: the bytes are read through the handle the contained walk opened and ride on the message, so nothing downstream re-opens the path — Attachment.SourcePath is a human label, never a file to open. That is why both limits are 10 MiB, under Telegram's own 50 MiB document ceiling: the payload is held in memory, so the cap is a memory bound. The limits are read through AttachmentLimits() so a local Bot API server can raise them in one place, memory cost included. The recipient is not an argument — internal/agent puts the inbound turn's channel on the request context and the tool reads it back, so the model cannot address a file anywhere. On Telegram a send is retried once under the existing retry classification with the payload rebuilt each attempt — a consumed reader would upload zero bytes — and a can't parse entities failure clears the parse mode of the caption alone.
setupComponents in cmd/joshbot/main.go connects the declared servers and registers their tools; the spawned processes are owned by a package-level manager reaped on exit. Model Context Protocol servers declared in config.json are launched as child processes and spoken to over stdio (JSON-RPC: handshake, tools/list, tools/call). Discovered tools are imported into the same registry the built-in tools live in as mcp__<server>__<tool>; because no built-in carries that prefix and Registry.Register refuses a duplicate, an MCP server cannot shadow shell or any other built-in. Registration is fail-soft and bounded: a server that fails its handshake or tools/list inside 15s is logged and skipped rather than sinking the others, a single call is capped at 60s, a result is truncated at 4,000 characters and a single server message over 4 MiB kills the connection — third-party text lands straight in the prompt, so it must not be able to exhaust the context or the heap. Server processes get the same allowlisted, credential-screened environment as shell children (internal/childenv), so an MCP server cannot read joshbot's provider API keys; their filesystem access is not sandboxed.
A configured server is inert until approved. An enabled server's tools are registered only if an operator has run joshbot mcp trust <name>; approval is bound to a SHA-256 of the server's advertised tool manifest (each tool's name, description and input schema, sorted and length-prefixed) held in ~/.joshbot/mcp.trust at mode 0600, so a server that changes what it advertises is revoked and must be re-approved. The gate is on the manifest, not on execution: an unapproved server is still spawned and asked for tools/list, because that is the only way to learn what it advertises — what the store governs is whether any of it may reach the model. A nil or unreadable store means "not trusted", so the gate fails closed. Server-supplied descriptions are capped at 1,024 characters and joshbot's own prompt envelope tags (<memory>, <skills>, <current_time>, <conversation_context>, <personality>) are defanged, so a description cannot close a section joshbot opened and have its remainder read as joshbot's own instructions. joshbot mcp list renders each server's state and manifest — text or --output json — so an operator reads what they are approving first.
internal/bus/)Goroutine-safe channel multiplexer. Inbound messages from any channel are routed to the agent goroutine. Outbound messages from the agent are routed back to the originating channel. This decoupling means channels and agent can be developed independently.
internal/tools/web.go, internal/tuning/)Deadline-aware fallback, on all four operations. web_search, web_code, web_company and web_research each fall back exa-cli → Exa MCP → DuckDuckGo on a native failure — previously only web_search had the full chain. Every leg runs under its own sub-deadline (webPerCallDeadline), derived from whatever remains of the turn's own timeout and clamped by a per-operation budget (tools.web.search_timeout 25s, research_timeout 45s, code_timeout/company_timeout 20s each, all config.Duration, zero meaning "use the built-in default") minus tools.web.finish_reserve (5s), so one slow exec.CommandContext call can no longer consume the whole turn budget and leave nothing for the ReAct loop to reply with. On native failure, web_code/web_company/web_research degrade to a plainly-marked "degraded/best-effort" DuckDuckGo-only result instead of a hard error. A per-(operation, backend) health map (webBackendHealth, modeled on internal/providers/health.go) deprioritizes a failing backend for a short cooldown rather than dropping it, and any success resets it.
DuckDuckGo hardening. The response read is capped at 2 MiB (io.LimitReader) rather than an unbounded io.ReadAll — the earlier form was reproduced OOM-killing the process against a server that streams forever — and the capped body is run through bytes.ToValidUTF8 before parsing, so malformed bytes from a broken page cannot corrupt downstream JSON or session content. Each search engine attempt additionally runs under its own 10s sub-deadline, composed with the outer per-operation deadline, so one hung engine can no longer starve every later engine of a try within the same call.
A tool reaches the agent's progress sink through a fire-and-forget context callback (tools.WithProgress/ProgressFromContext), because internal/tools cannot import internal/agent's sink directly. reactLoop bridges a tool's mid-call note (e.g. "falling back to Exa MCP") onto the existing agent.ProgressFunc sink as a new phase, ToolProgressNote, distinct from Start/Done — rendered on its own line with neither the start nor the done glyph, since it is not a new call and not a completion.
Timeout auto-tuning is optional and off by default (tuning.enabled, omitempty). Enabled, a Tracker/Tuner pair (internal/tuning) replays an append-only per-tool timeout/success event log (~/.joshbot/tuning_events.jsonl) and raises or lowers each of the four web operations' effective timeout within an operator-declared ceiling (tuning.max_bump, default 30s) in steps of tuning.step (default 5s), with a cooldown between tune events so it cannot thrash on alternating success/timeout turns. The learned adjustment persists in a small overlay file (~/.joshbot/tuning_overlay.json), never in config.json, and is merged onto the configured web-tool budgets once, at process startup — joshbot has no live config-reload mechanism for this class of value, so a tune made mid-run takes effect on the next restart, the same story every other config.Duration follows. joshbot tuning status/joshbot tuning reset inspect or clear it, and work even when tuning is disabled.
Turn incidents are recorded to a bounded JSONL, and timeout self-heal is optional and off by default (agents.defaults.heal_timeouts, a string ""/"off"/"bump" with omitempty, not a bare bool). Turn failures that would otherwise leave only a generic message in the chat are persisted to ~/.joshbot/incidents.jsonl (owner-only, redacted, in-memory cap 500, malformed lines skipped on load): turn_timeout when the agent's own budget kills a turn (carrying the loop's iteration and tool-call counts, which is why the stream state is created in Process rather than inside the loop), llm_failure for an in-band LLM error with a live context, and stream_died when a stream dies mid-answer; a stream that died before any content retries invisibly and is not an incident. One WARN line accompanies each record. joshbot incidents list|summary|clear inspects and administers it (clear truncates — not an audit trail), and joshbot status gains an Incidents: line when any fired in the last 24h. With heal_timeouts: "bump", the effective turn timeout at the next startup is base + 30s per turn_timeout in the last 24h, capped at 4 timeouts (+2m) — next-restart semantics, the same convention the tuning overlay follows. llm_failure incidents deliberately do not drive the bump: a failing provider is not a budget problem.
internal/driftscan/)joshbot docs check runs a bounded, read-mostly subagent that compares joshbot's own source and config against README.md, docs/INSTALL.md, site/*.html, AGENTS.md/CLAUDE.md and the bundled SKILL.md files, then writes a propose-only report to workspace/reports/doc-drift-<YYYY-MM-DD>.md. It is deliberately a standalone CLI command under its own timeout (5 minutes, distinct from agents.defaults.timeout), not a tool the model can call mid-chat. The fail-closed evidence rule is enforced in Go, not left to the model's own judgement: the subagent's structured answer is passed through driftscan.FilterUnverified before anything is written, and any item whose evidence_path is empty or whitespace is silently dropped regardless of what the model claims about it. The only file this command ever writes is the report itself, through the same filesystem tool and containment layers every other write goes through — never a raw os.WriteFile. Verification commands (go build, wc -l, go test -cover, ...) run through the shell tool like any other call; with tools.shell_approval on, this command installs the same interactive terminal approver joshbot agent uses, rather than let the fail-closed default deny every command and produce an empty report indistinguishable from a clean bill of health. Cron wiring for a scheduled run is a documented follow-up, not implemented.
Providers are auto-detected from model names. The config supports both legacy and model-centric formats.
A provider or model entry may carry api_key_env — the name of an environment variable — instead of a literal api_key, so the config file holds a variable name rather than a secret. Precedence is JOSHBOT_PROVIDERS__<NAME>__API_KEY > api_key_env > api_key, and it falls out of ordering: resolveProviderCredentials runs before applyEnvOverrides in internal/config. Setting both fields on one entry, or naming a variable that is not set, is a fatal load error rather than a downgrade to a config nothing can dial — that failure would otherwise resurface as a 401 mid-turn and read as a revoked key.
Named profiles (profiles plus default_profile, internal/config/profiles.go) are named provider/model/endpoint setups selected per run with --profile on agent, gateway and preflight. Precedence is --profile > default_profile > nothing, and that last case is the compatibility contract: a config carrying profiles but selecting none is left completely untouched, so no existing install changes behaviour on upgrade. A selected profile is applied through the model-centric path and replaces the models block rather than being appended to it — a profile whose fallback was some other entry in the file would dial an endpoint the operator did not choose, which is the exact surprise switching profiles is meant to avoid. A profile cannot hold a credential: api_key is a field only so that writing one is a fatal load error naming api_key_env instead, since a profiles block is what gets pasted into an issue. Every other failure mode is a startup error rather than a provider error mid-turn — an unknown name (listing the configured ones), a disabled profile (its own message), an unset api_key_env variable (named in the error), and a post-apply ResolveModelConfig check so an unresolvable profile never reaches a first request. joshbot profiles list (text or --output json) reports each profile's provider, wire model ID, endpoint host — reduced from api_base so userinfo embedded in a URL cannot leak — and the name of the variable holding its credential with whether it is set, never the credential.
joshbot preflight answers “would joshbot start, and with what?” without dialling anything. It is built on ResolveModelConfig and StripProviderPrefix rather than a second reading of the config, so the prefix rules (poolside keeps its prefix, every other provider has it stripped) cannot drift from what is actually sent. It reports provider, wire model ID, API host and credential source — never the credential — classifies failures as no-default-provider, provider-not-enabled, missing-credential or unresolvable, and exits non-zero when joshbot would not start. It loads through config.LoadStrict, not Load: Load warns and substitutes defaults so a broken file cannot stop the daemon starting, which is the right call for the daemon and useless for a diagnostic that would then describe a config nobody wrote.
The read-only reporting commands — preflight, status, skills list, profiles list, auth status and configure --list — take a global --output text|json flag. text is the default and is byte-for-byte the historical output; json emits one versioned document (schema_version) alone on stdout, deterministic so two runs can be diffed, with a failure reported as {"schema_version":1,"error":{"code":N,"message":"..."}} on the same stream. The document types and both renderers live in internal/output, so cmd/joshbot keeps only flag wiring. Redaction differs by form on purpose: text is written through redact.Writer, while JSON is made safe before encoding — the byte-stream redactor recognises name: value pairs anywhere, which an encoded document is built from, and rewriting it produced output no JSON parser accepts. The JSON documents therefore carry no credential fields by construction, and free-text and path fields are passed through redact.String/redact.HomePath as they are built.
agents.defaults.streaming (default true since v1.48.0; set it to false to restore whole-reply delivery — the schema v4→v5 migration resets an inherited false once, because the field has no omitempty and so was written into every config v1.47.x saved) makes the ReAct loop call the provider's ChatStream and forward text deltas to a context-carried sink as they arrive, so the interactive CLI prints the reply while it is being generated. It activates only when a sink is attached — the interactive CLI on a real terminal, and the Telegram gateway, which edits the reply message in place at most every 3 seconds — so joshbot agent -m and piped output are byte-identical to the non-streaming path. The trade is deliberate but bounded: text already printed cannot be retried, so a stream that dies mid-text appends a visible [stream error: ...] marker rather than silently switching providers — but a stream that dies before delivering anything is retried invisibly through the non-streaming path, which brings the whole retry/fallback chain with it, because nothing has reached the screen yet.
The interactive CLI (runAgentLoop in cmd/joshbot/main.go) swaps the plain > prompt for a raw-mode line editor (editor.go/terminal.go) when both stdin and stdout are real terminals. It adds Tab slash-command completion, Up/Down history, Home/End/Delete editing, Alt+Enter multiline input and Ctrl+C/Ctrl+D to quit, and shows the session's current model in the prompt. A single reader goroutine lives for the editor's lifetime so it never swallows the first bytes of the next line. The editor activates only on a genuine TTY, so piped or scripted output is byte-identical to the plain prompt. The interactive path also routes all logging to stderr (runAgent redirects the logger when stdout is a TTY), so INFO/WARN lines never corrupt the full-screen view — only non-interactive -m/--message text keeps logs on stdout so tool output and logs share one capture stream. A streamed answer after a tool-progress line is separated by a blank line, and the editor's cursor-up math (rows - cursorRow) keeps the prompt from reprinting on every keystroke.
agents.defaults.timeout bounds one agent turn (default 2m) and providers.<name>.timeout bounds one request to a provider. Both are a config.Duration, which reads a duration string ("600s", "10m", "1h30m") or a bare number of seconds, and always marshals back as the string form. The type exists because a plain time.Duration is an int64 of nanoseconds and encoding/json decodes it as exactly that: "timeout": 600 set a six-hundred nanosecond timeout, so every request to that provider failed instantly with context deadline exceeded — a symptom indistinguishable from a dead provider. A bare integer at or above one second's worth of nanoseconds is still read as nanoseconds, because that is what an older joshbot wrote; as a seconds value the same number would be over thirty years, so the two ranges cannot collide in practice. The agent key is new and carries omitempty, so an absent value keeps the old default with no schema migration — the trap the streaming bool hit at v4→v5 — and WithTimeout ignores a non-positive value, so the call site passes the config through unconditionally. Config.Validate rejects any nonzero timeout under a second at load, naming the key: below that it is certainly a unit mistake, and left alone it fails at the first request and blames the context rather than the config. That rejection is a fatal config error rather than an ordinary validation failure, because config.Load answers an ordinary one by logging "Config unusable, using defaults" and substituting Defaults() — a mistyped "500ms" would otherwise take every provider, API key and allowlist with it. The parsed value reaches the provider registrations for every provider, not only ollama, and the agent key also reads from JOSHBOT_AGENTS__DEFAULTS__TIMEOUT under the same rules for an env-only deployment; a value that will not parse there is a startup error, not a silent fall back to the default the operator was raising.
providers.<name>.max_retries (default 2, range 0–10, 0 = fail over immediately) is the same-provider retry budget: a transient failure — 429, 5xx, or a network error — is retried in place with jittered exponential backoff, honouring an upstream Retry-After header, before the fallback chain moves to the next provider. Retrying first matters because falling over switches the model mid-conversation, a bigger quality change than a short wait. A Retry-After longer than a turn should stall for (20s) is not waited out: the provider goes into a cooldown seeded from it and the chain moves on now. The cooldown table is process-local and deliberately forgetful — a provider that keeps failing is deprioritized to the end of the chain, never dropped, so a wrong guess costs latency rather than availability, and any success resets the record. The in-chat /status command reports consecutive failures and remaining cooldown per provider. When a fallback does answer, the reply opens with a one-line notice naming the failed provider, the reason, and who answered — on every channel, streamed and not — because a silent model switch reads as the primary working when it is not; agents.defaults.quiet_fallback suppresses it.
Environment variable overrides: JOSHBOT_PROVIDERS__OPENROUTER__API_KEY or JOSHBOT_MODELS_CONFIG__AGENT__MODEL. Config is validated JSON, stored at ~/.joshbot/config.json, written with 0600 permissions since it holds live provider API keys. An explicit --config <file> flag names a specific config file, overriding the default home-directory location.
The same owner-only treatment covers everything else joshbot writes under ~/.joshbot/: session JSONL files, their .meta.json sidecars and the log file are all 0600, inside 0700 directories re-applied on every start, because a transcript routinely contains credentials, personal data and raw tool output. Session writes go through an atomic write with a per-writer temp name, so the gateway and a concurrent joshbot agent -m sharing the sessions directory cannot publish a torn file. Within one process, whole turns are serialised per session key, so two HTTP API requests carrying the same user queue rather than both loading the same history and overwriting each other; across processes only the atomic write applies, so that pair can still interleave. A load that meets an unreadable line skips that line rather than failing the whole session — there is one session per channel:senderID and a fatal parse would lock that user out permanently — and preserves the original bytes at <session-id>.jsonl.corrupt for inspection.
joshbot sessions export <id> turns a session into a Markdown transcript plus a JSON manifest for attaching to a bug report. Redaction runs before the bytes are written rather than over the finished file, so an unredacted export never exists on disk; the output is deterministic, carrying no export-time clock, so two exports of an unchanged session are byte-identical; and it does not go through the loading path, whose quarantine side effect would repair the very damage the report is capturing. The manifest records message and per-role counts, per-tool call and result tallies, the source file's size and a SHA-256 of it, and the count of unreadable lines skipped.
Sessions are inspected and cleared with joshbot sessions — list, show <id> [--last N], search <query> [--limit N] (case-insensitive across every transcript, newest first, redacted output), prune <id>|--older-than <d> and new <id>. The agent has the same recall through the session_search tool, so "what did we decide about X last week" is answered from the transcripts on any channel. joshbot agent --continue (-c) resumes the most recently updated session in headless runs, with a one-line recap on stderr. It is not a resume feature: a session is loaded automatically on every inbound message and there is exactly one per user per channel, so there is nothing to select. show output passes through the same redaction as the log. Destructive subcommands prompt, take --force for unattended use, and exit non-zero rather than block when there is no terminal.
Once a session crosses agents.defaults.compaction_threshold it is summarized into a single compaction record stored at index 0 of the session, so the summary is computed once rather than rebuilt on every later turn. The messages it replaces are appended to an append-only <session-id>.history.jsonl archive before they leave the live session — the agent never reads that archive back, and if the append fails the compaction is abandoned, since recomputing a summary is cheaper than destroying history. The archive is not rotated or pruned, so it grows for the life of the session. The turn in progress and the six messages before it are never summarized: the summary stands in for the earlier conversation, and the request the user just made reaches the model verbatim. Models the context registry does not recognise are assumed to have a 128k window; the earlier 4096 default drove the budget to 256 tokens and compacted the whole conversation away on every tool call.