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.
These 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 20 iterations). 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 or sequential chain execution. Each subagent gets a focused one-turn LLM call with its own system prompt. 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 20 think-act-observe iterations. 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 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.
internal/tools/)23 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, 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, the gateway 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. 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.
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.
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.
Enable one chat channel at a time. The bus exposes a single outbound channel that channel implementations read competitively, so Telegram and Discord running together steal each other's replies — about half of each conversation's answers are delivered to the other service, silently. Per-channel fan-out in the bus is the fix; until then channels.telegram.enabled and channels.discord.enabled should not both be true.
internal/mcp/)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.
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: streaming forfeits the transparent provider fallback, because text already printed cannot be retried, so a mid-stream failure appends a visible [stream error: ...] marker rather than silently switching providers.
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.
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. 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], prune <id>|--older-than <d> and new <id>. 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.