Compare commits

...

711 Commits
v0.2.2 ... main

Author SHA1 Message Date
chengyongru
67805f5db8
feat: add provider-native request switches (#5254) 2026-08-05 18:26:39 +08:00
chengyongru
5a1ab44baa
fix(whatsapp): detect outbound media content before dispatch (#5203) 2026-08-05 15:44:23 +08:00
chengyongru
9098ffd38f
refactor(webui): improve visual consistency (#5249) 2026-08-05 13:24:45 +08:00
chengyongru
a54d5d14cb fix(webui): feather clipped activity edges 2026-08-05 11:10:46 +08:00
chengyongru
6e9ae5bd05
refactor(session): remove request-scoped access grants (#5238) 2026-08-05 10:18:46 +08:00
Xubin Ren
858f6d96a6 fix(mattermost): preserve thread policy compatibility 2026-08-05 09:31:55 +08:00
Kenneth Zhao
cd4c1d0f6e feat(mattermost): separate group policy for threads vs channels 2026-08-05 09:31:55 +08:00
Xubin Ren
be5af019b9 fix(wecom): sanitize fallback media filename 2026-08-04 22:04:21 +08:00
santhreal
98507ae4fe fix(wecom): fallback to default filename when sanitize strips to empty 2026-08-04 22:04:21 +08:00
concertypin
cb2f9d0bbd fix(webui): configure public websocket URL 2026-08-04 21:53:16 +08:00
concertypin
e318e21cad fix(webui): require proxy-generated auth assertions 2026-08-04 21:53:16 +08:00
concertypin
465a918cf8 feat(webui): bypass tokens for trusted proxy auth 2026-08-04 21:53:16 +08:00
concertypin
5cd14a42df feat(webui): support trusted proxy bootstrap auth 2026-08-04 21:53:16 +08:00
santhreal
170c7083ed fix(telegram): require newline for language tag to preserve single-line fenced code 2026-08-04 21:40:03 +08:00
santhreal
a13e29bf07 fix(telegram): preserve code block content when language tag contains special characters 2026-08-04 21:40:03 +08:00
chengyongru
5770329542
fix(webui): render markdown in prompt rail previews (#5244) 2026-08-04 18:24:54 +08:00
chengyongru
29fdb7d628 fix(webui): align timestamp tooltip styles 2026-08-04 18:22:12 +08:00
Xubin Ren
fa65a01977 refactor(webui): narrow floating control migration 2026-08-04 18:05:34 +08:00
Xubin Ren
28ec8a1b47 fix(webui): correct combobox navigation semantics 2026-08-04 18:05:34 +08:00
Xubin Ren
3b4a056947 chore(webui): sync npm lockfile 2026-08-04 18:05:34 +08:00
Xubin Ren
7819cef7bd refactor(webui): unify floating controls 2026-08-04 18:05:34 +08:00
chengyongru
faff0ac2fa fix(webui): align automation metadata with timestamps 2026-08-04 17:46:24 +08:00
chengyongru
f45436b61d fix(commands): reject invalid slash commands 2026-08-04 17:11:44 +08:00
chengyongru
287fd88fe4
fix(webui): refine inline token highlights (#5241) 2026-08-04 16:40:41 +08:00
chengyongru
2fe135db3e
feat(webui): add integrated Vite dev mode (#5239) 2026-08-04 16:14:32 +08:00
chengyongru
4e8702a47b
fix(anthropic): support Opus 5 effort controls (#5236) 2026-08-04 13:38:54 +08:00
Xubin Ren
d99f589a59 refactor(session): clarify reference boundaries 2026-08-04 12:14:51 +08:00
Xubin Ren
d8aeb0eb2c refactor(session): simplify cross-session flow 2026-08-04 12:14:51 +08:00
Xubin Ren
62d34b5eb7 refactor(session): tighten cross-session access 2026-08-04 12:14:51 +08:00
Xubin Ren
f15ea84dd1 fix(session): enforce trusted read scope 2026-08-04 12:14:51 +08:00
Xubin Ren
4c07c40b34 feat(session): link agent references 2026-08-04 12:14:51 +08:00
Xubin Ren
cf01978e71 feat(webui): link session mentions 2026-08-04 12:14:51 +08:00
Xubin Ren
5dd3dc5450 fix(session): harden cross-session references 2026-08-04 12:14:51 +08:00
Xubin Ren
9b25da7b92 feat(session): add cross-session references 2026-08-04 12:14:51 +08:00
chengyongru
44b7e1bf41 fix(providers): keep serde errors explicit 2026-08-03 18:06:45 +08:00
arcdrake22
6eda67b50c fix(providers): keep reasoning items wire-valid for DeepSeek Responses
convert_messages() emitted reasoning items with ``content`` as a plain
string whenever preserve_reasoning was enabled (the DeepSeek spec).
DeepSeek's Responses gateway rejects that shape with a serde error
("input: invalid type: string ..., expected a sequence"), which surfaced
only after token consolidation cleared provider_state and forced the
full-history conversion path; replayed server items already carry list
content, which is why normal multi-turn requests never failed. Serialize
reasoning content as a list of output_text parts, matching the OpenAI
Responses schema and DeepSeek's accepted wire shape (verified live against
api.deepseek.com/responses).

The serde fallback classifier introduced in the previous commit remains as
a last-resort safeguard for any remaining wire incompatibility.

Tests: extend test_preserves_deepseek_reasoning_content to the array shape;
add a full-history regression with the observed failing item, a
replay/consolidation regression covering both replayed and converted
reasoning items, and provider-level request fixtures for both paths.
Full suite: 5773 passed, 22 skipped (only the known local-only
channels/sms packaging failure remains).
2026-08-03 18:06:45 +08:00
arcdrake22
fb2688fd37 fix(providers): fall back to chat completions on serde body rejections
DeepSeek's new Responses endpoint (deepseek-v4-flash) intermittently rejects valid request bodies with serde deserialization errors such as 'input: invalid type: string ..., expected a sequence'. These were not classified as compatibility errors, so affected conversations died instead of falling back to Chat Completions.

The wire format is correct (input serializes as a list), so this is a server-side Responses compatibility issue; Chat Completions is strictly more permissive, making fallback safe. Extend the fallback classifier to recognize serde body-parsing markers. Repeated failures still trip the existing circuit breaker.
2026-08-03 18:06:45 +08:00
chengyongru
2b63715282 fix(webui): complete i18n audit 2026-08-03 17:53:33 +08:00
Xubin Ren
df11fd92a6 docs(providers): link ModelScope setup sources 2026-08-03 16:57:10 +08:00
Xubin Ren
b29f9dcbcb docs(providers): align ModelScope setup with current config 2026-08-03 16:57:10 +08:00
Krislu1221
02df20cd55 docs(providers): add ModelScope (魔搭) section
ModelScope is a fully implemented provider (nanobot/providers/registry.py,
image_generation.py, schema.py) with async image-generation task submission
and polling, but was previously undocumented in docs/providers.md.

This patch adds a ModelScope entry under 'Common Provider Patterns',
covering:

- Default base URL: https://api-inference.modelscope.cn/v1
- OpenAI-compatible chat/completions endpoint
- Async image-generation flow (task submit + status poll)
- Automatic 'modelscope/' prefix stripping when calling the API
- A minimal nanobot.yaml example

No code changes; docs-only.
2026-08-03 16:57:10 +08:00
chengyongru
f11710a578
fix(webui): show actual local trigger messages (#5228) 2026-08-03 16:43:01 +08:00
chengyongru
eeecfac538 fix(webui): stabilize thread during IME input 2026-08-03 16:41:08 +08:00
Xubin Ren
ac216c3e94 docs(providers): document Eden AI setup and WebUI parity 2026-08-03 16:40:13 +08:00
Xubin Ren
e7ec981f79 test(providers): verify Eden AI gateway contract 2026-08-03 16:40:13 +08:00
Victor M. SMITH
f42a44817a feat(providers): add Eden AI as an OpenAI-compatible gateway provider
Eden AI (https://www.edenai.co) is an EU-hosted, OpenAI-compatible gateway exposing 100+ models from many providers through a single endpoint and API key. Models use the provider/model naming scheme (the full id is sent upstream, like OpenRouter).

Adds it following the registry's documented two-step recipe:
- a ProviderSpec in providers/registry.py (backend openai_compat, gateway, default_api_base https://api.edenai.run/v3, EDENAI_API_KEY, reasoning_effort)
- the matching field in ProvidersConfig (config/schema.py)

API key via EDENAI_API_KEY only; never hardcoded.

Signed-off-by: Victor M. SMITH <72023257+MVS-source@users.noreply.github.com>
2026-08-03 16:40:13 +08:00
Xubin Ren
84f98f5e92 test(cron): cover invalid schedule expressions 2026-08-03 16:20:22 +08:00
ferkans-amir
73a0080484 fix(cron): validate expression syntax in _validate_schedule_for_add 2026-08-03 16:20:22 +08:00
arcdrake22
c6bd5f0075 test(gateway): align runtime-tasks gather tests with bounded retrieval
The helper never waits on the runtime-tasks gather after cancelling it
(its children are bounded individually), so the finished-gather test must
hand the helper an already-complete gather to exercise the bounded
retrieval path, and the cancelled-gather test must settle the gather
itself instead of expecting the helper to await a still-pending future.

Use a pre-completed child for the finished case and suppress(await) for
the cancelled case; both now assert done() and a single close.
2026-08-03 16:00:39 +08:00
Xubin Ren
39e1533c3b fix(gateway): make resource teardown cancellation-safe 2026-08-03 16:00:39 +08:00
arcdrake22
a91ce900ef test(gateway): add shutdown teardown regression coverage
Covers the lifecycle contract of _close_gateway_runtime: runtime tasks are
cancelled before shared resources close, pending background work is drained
before the close returns, cancellation-swallowing tasks and hanging cleanup
are bounded by their timeouts, a failing close is logged without blocking the
stop, duplicate cleanup is idempotent, and the runtime_tasks gather await path
is exercised for both completed and cancelled gathers.
2026-08-03 16:00:39 +08:00
arcdrake22
8942c22d86 fix(gateway): close agent resources deterministically on shutdown
The gateway shutdown path never closed agent resources explicitly: it relied
on the agent loop task's own finally to run close_mcp() when that task is
cancelled. When the service stops with an in-flight exec session or MCP
subprocess, that path can be skipped or cut short, leaving asyncio subprocess
transports alive after the event loop closes. They are then finalized by
__del__ against a closed loop, producing "RuntimeError: Event loop is closed"
noise in the shutdown log, and in the worst case orphaned subprocesses with
the stop stalling until systemd's timeout kills the cgroup.

The teardown is now extracted into _close_gateway_runtime() with explicit
ordering and bounds:

- Runtime tasks (including the agent loop and any in-flight turn) are
  cancelled and awaited -- bounded -- before exec sessions, subagents, and MCP
  servers are closed, so no active turn is using a shared resource when it
  closes.
- Channel transports are closed before waiting for their runners to exit, since
  some SDKs swallow task cancellation while attempting to reconnect.
- agent.close_mcp() is invoked explicitly, bounded to 15s, and is idempotent:
  it is a no-op when the agent loop's own cleanup already ran, and the
  guaranteed final close otherwise.
- A coroutine that swallows cancellation (e.g. an SDK reconnect loop) can no
  longer hold the stop open until systemd's timeout kills the cgroup; cleanup
  failures are logged instead of blocking shutdown.
2026-08-03 16:00:39 +08:00
chengyongru
a9bb39b833 fix(webui): dismiss mobile keyboard after send 2026-08-03 15:53:05 +08:00
KDB
52bc79d3a0 fix(plugins): use uv when pip is unavailable 2026-08-03 15:42:26 +08:00
chengyongru
5c72fdcd88 fix(webui): remove unused bot identity settings 2026-08-03 14:06:44 +08:00
yang1
f7a6bc2d21 fix(webui): globally register correct MIME types for static assets (#5190)
On Windows, mimetypes.guess_type() reads the Content Type value from
HKEY_CLASSES_ROOT\.js (and other extensions) in the registry, which is
commonly set to text/plain because .js is associated with Windows Script
Host rather than web JavaScript. The registry value overrides Python's
built-in mapping and causes browsers to reject ES module scripts.

Fix by explicitly registering correct MIME types via mimetypes.add_type()
at module import time for common web static extensions (.js, .mjs, .css,
.html, .json, .svg, .wasm). Using strict=True ensures the values replace
the registry-backed standard mappings used by mimetypes.guess_type(). This
benefits all callers of mimetypes.guess_type() in the gateway process, not
just _serve_static.

Closes #5190

Co-authored-by: amkile <44280409+amkile@users.noreply.github.com>
2026-08-03 11:33:02 +08:00
arcdrake22
08fe9f7b3a fix(image): send Gemini Flash hints via generationConfig.imageConfig
The live v1beta API rejects the legacy responseFormat.image block
(enum-based aspectRatio/imageSize fields) for gemini-3.1-flash-lite-image
with INVALID_ARGUMENT, even for documented plain-string values. Gemini
Flash image models accept plain-string hints under
generationConfig.imageConfig instead (e.g. aspectRatio 16:9, imageSize
1K), which the API accepts. Switch the flash path to imageConfig and
update the provider tests accordingly. Other providers (aihubmix,
ollama, imagen) are untouched.
2026-08-03 11:22:09 +08:00
chengyongru
8fde956c64 fix(webui): show timestamps for replayed messages 2026-08-03 10:41:50 +08:00
chengyongru
580824a15a
perf(webui): accelerate JSONL session list and thread loading (#5194) 2026-08-03 09:51:35 +08:00
Xubin Ren
db6c9effc3 fix(webui): position sidebar highlight on mount 2026-08-01 23:01:43 +08:00
Xubin Ren
0cb7dd5cc9 refactor(webui): reuse sidebar selection highlight 2026-08-01 23:01:43 +08:00
Xubin Ren
e1894d6f0b fix(providers): respect explicit cloud namespaces 2026-08-01 20:25:58 +08:00
NearlCrews
5eb818e800 fix(providers): require api_base before local provider wins on keyword match
Ollama's spec keeps "nemotron" as a keyword so bare `nemotron-3-nano`
auto-routes to a configured Ollama install (PR #1863). NVIDIA NIM was
later registered with the same "nemotron" keyword (commit 046d0831),
creating the only keyword collision in the registry.

In `_match_provider`, the keyword loop accepted any local provider on
`spec.is_local` alone — no api_base check. Models like
`nvidia/nemotron-3-super-120b-a12b` (intended for OpenRouter or NVIDIA
NIM) were therefore hijacked to http://localhost:11434/v1 even when the
user had never configured Ollama, causing silent connection errors at
runtime.

Add the same api_base gate the local-fallback loop already uses: a local
provider only wins by keyword when the user has actually set its
api_base. Preserves PR #1863's intent for users who configured Ollama;
fixes the silent hijack for everyone else.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-01 20:25:58 +08:00
santhreal
4c387f6633 fix(memory): handle non-string timestamp and missing role in raw_archive 2026-08-01 20:14:28 +08:00
Xubin Ren
e152e7bc0b test(cron): cover stop during manual execution 2026-08-01 20:03:19 +08:00
yu-xin-c
e26e09c205 fix(cron): preserve manual run completion state 2026-08-01 20:03:19 +08:00
KDB
f3bbb543d0 refactor(cli): narrow Pyright suppressions 2026-08-01 19:52:08 +08:00
KDB
b1030ab131 fix(exec): preserve wait targets across response truncation 2026-08-01 19:40:36 +08:00
KDB
39bb20c76b fix(session): tolerate malformed persisted session summary
AutoCompact.prepare_session runs on the turn hot path
(AgentLoop._compact_session) and read the persisted _last_summary metadata
with an unguarded meta['text'] and datetime.fromisoformat(meta['last_active']).
A _last_summary dict that was hand-edited or written by another version
(missing text/last_active, or a non-ISO last_active) raised KeyError/ValueError
out of the turn.

Sibling readers already tolerate the same data: estimate_session_prompt_tokens
uses .get('text') and _archive parses inside try/except. Mirror that tolerance:
skip when text is unusable, and fall back to the session's own updated_at (the
value the writer persists) when last_active is missing or unparseable, so the
archived summary is preserved instead of crashing the turn.
2026-08-01 19:29:16 +08:00
chengyongru
cdb75f8e7d
feat(providers): support DeepSeek Responses API (#5197) 2026-08-01 11:53:51 +08:00
chengyongru
971b977a84
fix(weixin): recover refreshed state after session expiry (#5196) 2026-08-01 00:28:21 +08:00
Pablo Cabeza García
54650332fb fix(slack): scope channel thread openers to their own session
A top-level channel message that opens a thread fell back to the
channel-wide session, because the session key required `raw_thread_ts` —
which Slack only sets on messages that already arrived inside a thread.
Every new thread therefore began life in one shared channel session and
only became thread-scoped from its first reply onward, so unrelated
threads saw each other's opening turns.

Key off `thread_ts` instead. It is set both for messages arriving inside
a thread and for channel messages that `reply_in_thread` opens a thread
for. DM roots never get a `thread_ts`, so they keep the default per-chat
session and the DM routing from 82c5083 is preserved; with
`reply_in_thread` disabled no thread exists and the channel session is
still used.

This restores the per-thread isolation introduced in #1048.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 23:47:26 +08:00
chengyongru
172fe4f991
fix(webui): preserve user scroll ownership near tail (#5193) 2026-07-31 23:37:26 +08:00
shixi-li
dda9b61b1e fix(config): install timezone data on all platforms 2026-07-31 19:55:22 +08:00
chengyongru
6a1a45d07a
feat: preserve Responses reasoning state and compact context (#5172) 2026-07-30 22:39:43 +08:00
Solaris-star
511c764f45 fix(agent): route finish_reason='length' with blank content to length recovery
When an LLM response arrives with finish_reason='length' and has_tool_calls
but blank text content (e.g. the model spent its whole output budget on a
tool call whose closing tag was truncated), the runner dropped the tool
calls and then misrouted the blank response into the empty-response retry
branch. Retrying the same prompt cannot recover from output-budget
exhaustion, so every retry hit the same length ceiling and the turn ended
in the generic apology.

The length-recovery branch was gated on 'finish_reason == length and not
is_blank_text(clean)', so a blank-but-truncated turn could never reach it.

- The empty-response retry branch now excludes finish_reason == 'length'
  (in addition to 'error').
- The length-recovery branch no longer requires non-blank content, so a
  blank-but-truncated turn enters recovery and appends
  build_length_recovery_message (which handles a blank tail safely).

Adds a regression test asserting the length-recovery path is taken; it
fails on the unfixed code and passes with the fix.

Fixes #5133
2026-07-30 19:55:19 +08:00
Xubin Ren
0eac82984c test(mcp): stabilize idle reconnect timing 2026-07-30 19:44:09 +08:00
yu-xin-c
5e67fbf93e fix(exec): bound buffered session output 2026-07-30 19:44:09 +08:00
yu-xin-c
9ec4420104 fix(agent): release idle session locks 2026-07-30 19:17:37 +08:00
KDB
52680dbe19 fix(pairing): keep approvals across transient store read failures
_load() treated any OSError like corruption and returned an empty store. When pairing.json was transiently unreadable, an unapproved DM could deny the sender, generate a pairing code from the empty view, and overwrite the store without its approved senders.

Keep the existing JSONDecodeError reset behavior, but propagate OSError so mutations cannot persist unreadable state. Read-only checks fail closed without writing; mutating /pairing subcommands report temporary unavailability; and the DM pairing path skips one reply instead of crashing the handler.

This mirrors the refuse-to-overwrite strategy used by the cron and trigger stores.
2026-07-30 19:02:37 +08:00
KDB
e633f867e8 fix(session): tolerate invalid idle-compaction timestamps 2026-07-30 18:52:16 +08:00
KDB
07c2677eed fix(webui): drop malformed token-usage day keys
normalize_token_usage_state only length-checked persisted day keys, so a
hand-edited or foreign 10-char key (e.g. "not-a-dat3" or "2026-13-01") in
token-usage.json survived reads and atomic rewrites. token_usage_payload
then parsed every day key with an unguarded datetime.fromisoformat, so one
such key failed every /api/settings and /api/settings/usage request until
the file was repaired by hand.

Validate day keys in normalize_token_usage_state, the shared boundary that
every read, record, and rewrite already funnels through. Malformed keys are
dropped like other malformed rows and scrubbed from the file on the next
write; valid state is unchanged.
2026-07-30 18:41:44 +08:00
ATECHPCS
92361cbeac fix(gitstore): return real git object ids instead of hex-of-hex
`porcelain.commit()` and `repo.refs[...]` hand back object ids as a
40-character hex string that is already encoded to bytes. Calling `.hex()`
on that encodes the ASCII a second time, so every id GitStore produced or
displayed was double-encoded:

    auto_commit()          -> '62623234'
    git log --abbrev=8     -> 'bb244606'

The module is self-consistently wrong, so `/dream-log` and `/dream-restore`
work as long as the id came from nanobot itself. What does not work is
crossing the boundary: ids in logs and commit output match nothing in
`git log`, and an id copied from `git log` cannot be resolved:

    _resolve_sha(own id)      -> b'bb244606d780...'
    _resolve_sha(real git id) -> None

Use `.decode()` at the four sites that consume dulwich object ids. Nothing
persists an id — callers either display it or resolve it live — so there is
no stored state in the old format.

Adds two regression tests: the id returned by `auto_commit` must equal
`git log --abbrev=8`, and a real git id must resolve through `_resolve_sha`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 18:25:56 +08:00
chengyongru
bb2f6cf324 fix(webui): preserve automation source on streamed replies 2026-07-30 17:57:31 +08:00
chengyongru
606ac56e8f
feat(webui): support remote Codex OAuth login (#5174) 2026-07-30 15:06:34 +08:00
chengyongru
e2563e2e74
refactor(cli): split commands into focused modules (#5175) 2026-07-30 15:01:35 +08:00
chengyongru
ad6900e56c
refactor(session): separate persistence behind SessionStore (#5170) 2026-07-30 11:51:13 +08:00
chengyongru
c33c188afb
fix(session): preserve history during idle compaction (#5167) 2026-07-30 10:45:45 +08:00
chengyongru
11fcd9cc5f
fix(webui): prevent redundant thread and media reloads (#5164) 2026-07-30 10:25:22 +08:00
chengyongru
fc73d5ff39 fix(webui): avoid false microphone silence errors 2026-07-30 09:02:40 +08:00
Xubin Ren
0fe3c5aa2c fix(webui): satisfy strict skill marketplace typing 2026-07-30 01:13:37 +08:00
Xubin Ren
c440695aef fix(webui): harden skill marketplace lifecycle 2026-07-30 01:13:37 +08:00
Xubin Ren
8a56eb06ad fix(webui): harden skill management 2026-07-30 01:13:37 +08:00
Xubin Ren
d276fe1386 perf(webui): make marketplace filters instant 2026-07-30 01:13:37 +08:00
Xubin Ren
12ebc7b8e2 style(webui): use package install icon 2026-07-30 01:13:37 +08:00
Xubin Ren
164726c43f style(webui): quiet marketplace install actions 2026-07-30 01:13:37 +08:00
Xubin Ren
62b132b698 style(webui): reduce repeated marketplace labels 2026-07-30 01:13:37 +08:00
Xubin Ren
e66eb204d0 feat(webui): add SkillHub marketplace source 2026-07-30 01:13:37 +08:00
Xubin Ren
42ee34e34d fix(webui): localize skills view tabs 2026-07-30 01:13:37 +08:00
Xubin Ren
73d3a49a27 style(webui): float in conversation highlight 2026-07-30 01:13:37 +08:00
Xubin Ren
0a8bc0ac29 style(webui): slide active conversation highlight 2026-07-30 01:13:37 +08:00
Xubin Ren
962cdb968d style(webui): animate settings section transitions 2026-07-30 01:13:37 +08:00
Xubin Ren
7561d846fa style(webui): highlight selected conversations 2026-07-30 01:13:37 +08:00
Xubin Ren
cad9b07deb fix(webui): render adjacent CJK emphasis 2026-07-30 01:13:37 +08:00
Xubin Ren
9c6e1b073f fix(webui): align ordered list markers 2026-07-30 01:13:37 +08:00
Xubin Ren
9b5a5c5302 style(webui): refine skills mobile experience 2026-07-30 01:13:37 +08:00
Xubin Ren
655cd80651 style(webui): clarify skill row interactions 2026-07-30 01:13:37 +08:00
Xubin Ren
9ceacc540c fix(webui): avoid parsing currency as math 2026-07-30 01:13:37 +08:00
Xubin Ren
ba0ba4749d feat(webui): add skills marketplace 2026-07-30 01:13:37 +08:00
chengyongru
129b74b4cf
feat(webui): track optimistic message delivery status (#5162) 2026-07-29 23:15:45 +08:00
Zhou
5a28a6165c
fix(shell): preserve UTF-8 native input on PowerShell 5 (#5160)
Windows PowerShell 5.1 defaults $OutputEncoding to US-ASCII, corrupting non-ASCII strings piped to native commands. Set it from the console encoding only on legacy versions so PowerShell 7 keeps its defaults.
2026-07-29 21:47:01 +08:00
chengyongru
757ad9c764
refactor: enforce BasedPyright strict type checking (#5158) 2026-07-29 21:37:11 +08:00
chengyongru
e703481755
fix(memory): expose media references to session consolidation (#5157)
Co-authored-by: shakewingo <yaoyingshakewin@gmail.com>
Co-authored-by: bingqilinweimaotai <111987281+bingqilinweimaotai@users.noreply.github.com>
2026-07-29 15:18:44 +08:00
chengyongru
393d429e0a
fix(ci): stabilize and speed up CI (#5145) 2026-07-28 22:55:59 +08:00
chengyongru
9070d7489a fix(ci): scope PR path detection to head changes 2026-07-28 20:24:52 +08:00
chengyongru
019d7816a7
fix(webui): animate reasoning drawer transitions (#5143) 2026-07-28 19:13:16 +08:00
chengyongru
24a392b671
fix(webui): open threads at latest message (#5142) 2026-07-28 18:52:34 +08:00
chengyongru
0c6c0438d4
feat(config): add actionable startup diagnostics and WebUI recovery (#5110) 2026-07-28 18:52:05 +08:00
chengyongru
76ab04ac48
fix(webui): keep streaming tail visible (#5140) 2026-07-28 18:18:44 +08:00
chengyongru
1faf0826f6 fix(webui): keep composer stable while scrolling 2026-07-28 17:13:47 +08:00
chengyongru
ae089aa3ae fix(webui): reconcile threads after browser resume 2026-07-28 16:25:08 +08:00
chengyongru
78cf68c291 fix(agent): snapshot active tasks before cancellation 2026-07-28 15:42:52 +08:00
Xubin Ren
ce3e532643 fix(sdk): use shared runtime event publisher 2026-07-28 15:30:28 +08:00
chengyongru
ae7b4c8792 fix(sdk): narrow persisted turn callback API 2026-07-28 15:30:28 +08:00
chengyongru
fd17c1352a fix(sdk): harden host integration contracts 2026-07-28 15:30:28 +08:00
chengyongru
c050955ae3 feat(sdk): add host integration extension points 2026-07-28 15:30:28 +08:00
chengyongru
12f828ea3d
fix(agent): read document attachments on demand (#5122) 2026-07-28 13:33:06 +08:00
chengyongru
096a86a7f4 docs: move README title above introduction 2026-07-28 13:06:09 +08:00
Xubin Ren
8ef5bc414d docs(readme): preserve Render launch anchor 2026-07-28 12:44:45 +08:00
Xubin Ren
328251289d docs(deploy): explain Render setup and updates 2026-07-28 12:44:45 +08:00
Xubin Ren
7a741e2b50 docs(readme): add one-click deployment section 2026-07-28 12:44:45 +08:00
Xubin Ren
60e67fbe0f docs(readme): surface one-click Render deployment 2026-07-28 12:44:45 +08:00
chengyongru
fa5d27696a fix(webui): rank skill autocomplete results 2026-07-28 11:36:10 +08:00
chengyongru
ef9e687f19
refactor(core): remove redundant runtime scaffolding (#5127) 2026-07-28 11:07:58 +08:00
chengyongru
4c77126b3d docs: improve README landing page 2026-07-28 01:48:34 +08:00
chengyongru
6bc454dab4
fix(webui): prevent composer resize scroll jitter (#5121) 2026-07-28 01:01:49 +08:00
chengyongru
b99e0f937e fix(webui): soften model selector emphasis 2026-07-27 23:13:14 +08:00
chengyongru
f78ad59ed0
fix(memory): preserve Dream input integrity (#5114) 2026-07-27 21:37:13 +08:00
chengyongru
e819b7eea4 fix(webui): stabilize repeated model preset rows 2026-07-27 18:11:10 +08:00
chengyongru
3f808d0a68 docs: improve README discoverability 2026-07-27 15:57:03 +08:00
yu-xin-c
7fd28c9f06 fix(memory): preserve unprocessed dream history 2026-07-27 15:47:21 +08:00
chengyongru
c13df29457
feat(memory): restore Dream model preset override (#5107) 2026-07-27 14:43:25 +08:00
chengyongru
281b4b7f0b
chore: remove expired v0.3.1 compatibility shims (#5106) 2026-07-27 13:53:04 +08:00
chengyongru
39348dfafe refactor(agent): remove dead lifecycle scaffolding 2026-07-27 12:00:06 +08:00
chengyongru
b3d3a3e6c3 fix(image): delegate DNS to explicit proxy 2026-07-27 10:06:19 +08:00
chengyongru
d73794bc68 fix(image): honor provider proxy for URL downloads 2026-07-27 10:06:19 +08:00
Xubin Ren
cc3dbbe804 fix(security): block IPv6 unspecified SSRF targets 2026-07-27 10:06:19 +08:00
Xubin Ren
4408cde019 fix(security): harden generated image downloads 2026-07-27 10:06:19 +08:00
Xubin Ren
cf1e801a29 fix(image): align Gemini hints with model capabilities 2026-07-27 03:07:41 +08:00
Xubin Ren
a8604a3172 fix(image): scope Gemini image sizes by model 2026-07-27 03:07:41 +08:00
stupidloud
ef445cc246 fix(image): narrow Gemini Flash aspect-ratio and image-size scoping
Address review feedback that the capability checks were broader than the
documented per-model matrix:

- Drop the extreme aspect ratios (1:4, 4:1, 1:8, 8:1) from the Flash
  allow-list. They are only documented for 3.1 Flash / Flash Lite, so the
  global set could send an unsupported ratio to 2.5 Flash Image or 3.1 Pro
  Image. Keep the ratios common to every Flash image model.
- Identify imageSize support positively via "gemini-3" instead of excluding
  "2.5". The old predicate also matched gemini-2.0-flash-preview-image-
  generation, which (with the default 1K size) altered that model's request
  shape even though only Gemini 3+ image models accept a configurable size.

Add tests for the gemini-2.0 image-size drop and the extreme-ratio drop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 03:07:41 +08:00
stupidloud
4986590bd7 fix(image): pass aspect ratio and size to Gemini Flash image models
The Gemini Flash image path (`generateContent`) dropped both `aspect_ratio`
and `image_size`: `generate()` never forwarded them and
`_generate_gemini_flash` did not accept them, so every request fell back to
1:1 / input-matched output. The Imagen path was unaffected.

Forward the hints and emit them under
`generationConfig.responseFormat.image` per the current Gemini API. Aspect
ratio is validated against the accepted set; `imageSize` is validated against
{512,1K,2K,4K} and only sent to Gemini 3+ image models, since
`gemini-2.5-flash-image` supports only `aspectRatio`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-27 03:07:41 +08:00
Xubin Ren
b695a7e875 fix(cli): harden quick start OAuth handling 2026-07-27 02:51:04 +08:00
Xubin Ren
a4ec83fb0d fix(cli): scope Codex proxy env resolution 2026-07-27 02:51:04 +08:00
chengyongru
2a1f840ce2 fix(cli): support Codex OAuth in quick start 2026-07-27 02:51:04 +08:00
Xubin Ren
addaf2d3fc fix(dingtalk): harden group reply sender labels 2026-07-27 02:33:41 +08:00
李明振
9f3dee0192 docs(dingtalk): clarify disable_private_chat intent in comments
Addresses automated review: document that the guard is an intentional hard group-only switch (allowlisted DMs blocked by design) and that str() guards a None sender_id. Comment-only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 02:33:41 +08:00
李明振
205889f9e0 feat(dingtalk): prefix group replies with sender mention
In group chats, prefix the outbound markdown reply with an H1 naming the sender (# @<nick>) so the addressed user can spot it in a busy group. Private replies are sent verbatim.

Visual only: DingTalk markdown robot messages do not push real @ notifications (that would require staffId plumbing and a different message type). sender_name is read from OutboundMessage.metadata, which the agent loop already propagates from inbound metadata.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 02:33:41 +08:00
李明振
14e692e40d feat(dingtalk): add disable_private_chat to reject 1:1 DMs
Add a `disable_private_chat` config flag (JSON alias `disablePrivateChat`,
default False) to the DingTalk channel. When enabled, any non-group (1:1)
message is rejected with a Chinese notice directing the user to group chat
("该机器人未开启私聊,请在群聊中与我对话。") before permission/pairing logic
runs, so even allowlisted senders are redirected. Group messages are
unaffected.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 02:33:41 +08:00
Xubin Ren
68717937e8 fix(agent): throttle idle scans by default 2026-07-27 02:15:48 +08:00
Andrew Khmylov
7aab7e8830 feat(agent): make idle compaction scan interval configurable
Before this change, idle compaction is triggered every 1 second
if the incoming message stream is idle.
When triggered, it enumerates all session files, loads and parses them,
and then checks their expiration.

This becomes too CPU-intensive, especially on low-power devices like Raspberry Pi.
It's unlikely that you actually need to compact every second over the long time.

This change adds a configurable throttling for idle-compaction.

Default behavior is unchanged.
2026-07-27 02:15:48 +08:00
Xubin Ren
4e2640f2d2 fix(memory): keep failed Dream batches retryable 2026-07-27 02:00:41 +08:00
shixi-li
15e42059bd fix(memory): progress past completed no-op batches 2026-07-27 02:00:41 +08:00
Xubin Ren
b55b76d755 fix(streaming): preserve recovered segments across channels 2026-07-27 01:39:46 +08:00
chengyongru
e6baecafcd fix(agent): close length recovery lifecycle gaps 2026-07-27 01:39:46 +08:00
chengyongru
27a00c7a4f fix(webui): merge length recovery stream segments 2026-07-27 01:39:46 +08:00
chengyongru
3cc5a98d9f refactor(agent): derive recovery count from segments 2026-07-27 01:39:46 +08:00
chengyongru
1d2ed6e4d2 fix(agent): reset recovery chains across injections
Reset both the recovered segments and retry budget whenever injected input starts a new logical answer. Cover fatal tool-error boundaries and rename the prompt test module so pytest can collect the full suite.
2026-07-27 01:39:46 +08:00
chengyongru
154cbc1974 refactor(agent): trim recovery tail anchor 2026-07-27 01:39:46 +08:00
chengyongru
df2e5b7225 fix(agent): anchor truncated response continuations 2026-07-27 01:39:46 +08:00
chengyongru
b19039f9d0 fix(agent): preserve length-recovered output 2026-07-27 01:39:46 +08:00
Xubin Ren
c1899e2cb4 fix(mcp): decode URI-encoded schema refs 2026-07-27 01:14:41 +08:00
amplifierplus
9aae7485d6 fix(mcp): normalize local schema refs 2026-07-27 01:14:41 +08:00
chengyongru
2e2f15dd0c fix(channels): serialize Feishu connect completion 2026-07-27 01:00:12 +08:00
KDB
4835814746 fix(channels): ignore confirmations after connect cancellation 2026-07-27 01:00:12 +08:00
Xubin Ren
d236883e2d fix(pairing): reject malformed store entries 2026-07-27 00:46:40 +08:00
santhreal
f7bf4c972e fix(pairing): treat null approved/pending maps as empty 2026-07-27 00:46:40 +08:00
Xubin Ren
cf6ca13b6d fix(exec): preserve bwrap workspace masking 2026-07-27 00:31:00 +08:00
yu-xin-c
22e61003f9 test(exec): make bwrap bind tests portable 2026-07-27 00:31:00 +08:00
yu-xin-c
01a11b3980 feat(exec): allow extra bwrap bind roots 2026-07-27 00:31:00 +08:00
Xubin Ren
5d8046deef test(heartbeat): cover ignored unified routes 2026-07-27 00:12:44 +08:00
yu-xin-c
a7a6c26eab fix(heartbeat): route unified sessions to last channel 2026-07-27 00:12:44 +08:00
chengyongru
be43a54570 fix(webui): prevent mobile thread overflow 2026-07-26 23:59:28 +08:00
Xubin Ren
ff379b91cf fix(agent): preserve merged runtime context markers 2026-07-26 23:46:54 +08:00
yu-xin-c
eb93060f95 fix(agent): preserve pending runtime context 2026-07-26 23:46:54 +08:00
santhreal
07c3e02d5c fix(triggers): treat null runHistory as empty when loading triggers 2026-07-26 23:33:28 +08:00
santhreal
aaf2eef568 fix(feishu): tolerate null multi_url and list fields in card extract 2026-07-26 23:19:35 +08:00
santhreal
1e505ff405 fix(triggers): coerce string lastRunAtMs when loading local triggers 2026-07-26 23:05:19 +08:00
Xubin Ren
30750060ce test(feishu): cover null post metadata fields 2026-07-26 22:51:49 +08:00
santhreal
a7cac65c76 fix(feishu): move post extract test import to module top 2026-07-26 22:51:49 +08:00
santhreal
fb88154377 fix(feishu): tolerate null text fields when extracting post content 2026-07-26 22:51:49 +08:00
chengyongru
d576804f23 feat(channels): enable tool hints by default 2026-07-26 21:22:12 +08:00
chengyongru
ee93725e83
fix(webui): restore file edit diff display (#5096) 2026-07-26 19:05:53 +08:00
santhreal
7c94ba9643 fix(session): coerce null session metadata to empty dict 2026-07-26 17:47:16 +08:00
santhreal
745757cc37 fix(memory): skip non-dict history.jsonl lines when reading 2026-07-26 17:45:51 +08:00
santhreal
259d8a018c fix(skills): tolerate null requires/bins/env in skill metadata 2026-07-26 17:44:41 +08:00
chengyongru
55405f6cd6 feat: open WebUI after fresh desktop install 2026-07-26 03:28:15 +08:00
chengyongru
b0ef759e2c
Smooth WebUI streaming with state-driven viewport motion (#4696) 2026-07-26 00:18:24 +08:00
Xubin Ren
9a7debcb48 chore: defer compatibility cleanup to v0.3.1 2026-07-25 21:07:33 +08:00
Xubin Ren
922c49246d docs(readme): streamline quick start workflows 2026-07-25 20:49:22 +08:00
Xubin Ren
df1a0ed889 docs: mark v0.3.0 as latest release 2026-07-25 16:18:53 +08:00
Xubin Ren
3f602fbc8c docs(readme): fold Render into deployment guide 2026-07-25 15:46:46 +08:00
Xubin Ren
d6f6bbddbf docs(readme): preview the agency release 2026-07-25 15:46:46 +08:00
Xubin Ren
ac7b8cf4b4 fix(webui): preserve preset widths while switching 2026-07-25 15:46:46 +08:00
Xubin Ren
88cb22dd79 fix(webui): show full model preset labels 2026-07-25 15:46:46 +08:00
Xubin Ren
5328a95add chore(release): prepare v0.3.0 2026-07-25 15:46:46 +08:00
chengyongru
c6dbeb97d8 feat(brand): migrate README and WebUI assets to SVG 2026-07-24 22:45:13 +08:00
chengyongru
0bbb74b1ee feat(brand): add SVG mark and wordmark 2026-07-24 22:30:42 +08:00
d1ago
944de867a0 Add nanobot logo as SVG
Add a vector (SVG) version of the nanobot logo under images/, alongside
the existing raster logo. SVG scales cleanly at any size for docs, the
webui, and README use.
2026-07-24 22:30:42 +08:00
chengyongru
6e0eb46705
feat: launch first-time setup in webui (#5078) 2026-07-24 21:16:43 +08:00
Xubin Ren
e260d9b31c fix(agent): apply execution policy to existing workspaces 2026-07-24 19:25:52 +08:00
Xubin Ren
51f11a8548 docs(agent): execute authorized tasks through verification 2026-07-24 19:25:52 +08:00
Xubin Ren
7e15c4c447 fix(agent): track inline subagent lifecycle 2026-07-24 19:13:51 +08:00
Xubin Ren
3a400e0207 feat(agent): support inline subagent consultation 2026-07-24 19:13:51 +08:00
Xubin Ren
8e4fe9cfaf fix(providers): preserve non-multimodal tool lists 2026-07-24 18:58:32 +08:00
Xubin Ren
07a81d70be fix(providers): preserve multimodal tool outputs 2026-07-24 18:58:32 +08:00
chengyongru
d3e4b35f2b
fix(webui): honor custom gateway port with Vite (#5076) 2026-07-24 18:40:09 +08:00
chengyongru
5be176a6a0
feat(webui): switch model presets from the composer (#5077) 2026-07-24 17:34:26 +08:00
chengyongru
9aab94c766 Revert "fix: preserve pending message runtime context"
This reverts commit cad368f58512b444a93aaede0c5741a3488c9d98.
2026-07-24 14:47:50 +08:00
chengyongru
9957de5226
fix(webui): show quoted context after follow-up send (#5071) 2026-07-24 14:19:33 +08:00
hamb1y
cad368f585 fix: preserve pending message runtime context 2026-07-24 12:28:13 +08:00
George Pickett
0b38c48399 feat(webui): add Parallel Search MCP preset 2026-07-24 12:26:37 +08:00
chengyongru
6a9157f477 feat(webui): present chats as topics 2026-07-24 10:29:13 +08:00
flyzstu
8bcab8885e
test(agent): use python3 in ExecTool workspace scope tests (#5064)
* test(agent): use python3 in ExecTool workspace scope tests (fixes #5062)

* test(agent): use python on Windows and python3 on POSIX in ExecTool workspace scope tests (fixes #5062)

* test(agent): share Python command fixture

---------

Co-authored-by: chengyongru <chengyongru.ai@gmail.com>
2026-07-24 09:42:38 +08:00
chengyongru
aae259c790
feat(webui): simplify model preset settings (#5061) 2026-07-24 00:55:06 +08:00
chengyongru
d993c81f08 test(webui): cover restricted media previews 2026-07-24 00:30:49 +08:00
seteiro
4490f8cfe4 fix(webui): allow media directory access when restrictToWorkspace is enabled
Add get_media_dir() as an extra allowed root in file_preview path resolution so uploaded images and documents remain previewable even with workspace restrictions on. Closes #5028
2026-07-24 00:30:49 +08:00
AxelRay
78f4c132d9
fix(exec): extract absolute paths after equals sign in shell guard (#4594) 2026-07-23 23:58:01 +08:00
santhreal
7e9426d9bd fix(telegram): hard-cut when fence body cut lands on the prefix
A leading space in the fenced body made the soft cut land at min_code_pos
and re-emit the same fence forever. Require progress past the fence line.
2026-07-23 23:56:18 +08:00
santhreal
98d661775e fix(telegram): hard-cut fence splits when the closer cannot fit
Adaptive HTML limits can shrink max_len to the fence prefix size. Treat
budget <= min_code_pos as a hard cut so the splitter still advances.
2026-07-23 23:56:18 +08:00
santhreal
017a4946e2 fix(telegram): advance markdown split on long single-line fences
When a fenced code body has no interior newlines, the splitter cut at the
opening fence line and re-emitted the same content forever. Prefer breaks
inside the body after the fence so long JSON/minified blocks still split.
2026-07-23 23:56:18 +08:00
KDB
648fc92673 fix(exec): retain stale sessions after cleanup failure
Only remove idle exec sessions after process termination succeeds so later cleanup and shutdown paths can retry failed kills.
2026-07-23 23:55:00 +08:00
KDB
274613f064 fix(session): tolerate files removed during listing 2026-07-23 23:53:56 +08:00
Xubin Ren
754f457a94 fix(webui): keep composer model badge in sync 2026-07-23 23:11:40 +08:00
chengyongru
6c0f151f6e fix(webui): polish responsive layout 2026-07-23 18:22:02 +08:00
chengyongru
4b1547db7d
style(webui): unify settings and dark mode surfaces (#5058) 2026-07-23 17:47:20 +08:00
Xubin Ren
c3ec2e665f test(documents): preserve nested block order 2026-07-23 16:53:59 +08:00
Xubin Ren
911a7e3a82 test(documents): cover vertical merged cells 2026-07-23 16:53:59 +08:00
Xubin Ren
fc9d17eb7b fix(documents): bound nested DOCX table parsing 2026-07-23 16:53:59 +08:00
Xubin Ren
60ab580f8b fix(documents): preserve DOCX table content 2026-07-23 16:53:59 +08:00
chengyongru
96eb965aae
feat(webui): show the actual fallback model (#5017) 2026-07-23 15:57:13 +08:00
chengyongru
4188ffc88d chore: pin migration TODOs to v0.2.4 2026-07-23 15:40:44 +08:00
chengyongru
089216f9c7 chore(session): schedule legacy fallback removal for v0.2.4 2026-07-23 14:53:00 +08:00
axelray-dev
464f71b488 fix(session): fall back to legacy paths in metadata reads
Fixes #4940
2026-07-23 14:53:00 +08:00
chengyongru
15de6be0af fix(providers): fall back on authentication errors 2026-07-23 14:52:04 +08:00
chengyongru
01cdfc8100
fix(telegram): expose proxy setup in WebUI (#5033) 2026-07-23 14:18:49 +08:00
Arthur K.
3647875aba fix: add one second to retry after delays 2026-07-23 14:10:27 +08:00
santhreal
299bcf491b fix(cron): skip null runHistory elements when loading jobs.json
Null entries in state.runHistory raised TypeError and quarantined the
store. Skip non-dict elements like LocalTrigger.from_dict already does.
2026-07-23 14:07:06 +08:00
santhreal
0191c0db73 fix(pairing): treat null approved channel lists as empty
pairing.json with "telegram": null crashed is_approved during load.
Treat non-list channel entries as an empty allow-list.
2026-07-23 14:04:53 +08:00
santhreal
5851bd432a fix(slack): keep fenced markdown tables intact in _to_mrkdwn 2026-07-23 14:03:09 +08:00
santhreal
8195181783 fix(feishu): keep fenced markdown tables out of card tables 2026-07-23 14:02:33 +08:00
chengyongru
9cf2fb19c2
feat(xai): surface hosted X Search activity (#5050) 2026-07-23 13:42:09 +08:00
chengyongru
f3099286ea docs: explain slow optional dependency installs 2026-07-23 13:23:55 +08:00
chengyongru
5f054c0e74 fix(agent): deliver non-streamed finalization responses 2026-07-23 13:16:47 +08:00
Xubin Ren
536e8db324 refactor(webui): remove unused picker styling hooks 2026-07-23 12:42:24 +08:00
Xubin Ren
2f4f00bb9f refactor(image): reuse the model picker 2026-07-23 12:42:24 +08:00
Xubin Ren
8bd951a06f test(image): update model picker assertion 2026-07-23 12:42:24 +08:00
Xubin Ren
e875f29185 fix(image): allow custom model ids 2026-07-23 12:42:24 +08:00
Xubin Ren
1616fa9f14 feat(image): apply generation settings live 2026-07-23 12:42:24 +08:00
chengyongru
c7393c785e
feat(providers): add xAI Grok OAuth with capability-gated X Search (#5035) 2026-07-23 11:55:16 +08:00
chengyongru
c22efb5f7a
feat(agent): make model presets session-scoped (#4866) 2026-07-23 00:38:49 +08:00
chengyongru
66690fdb0c
fix(webui): deliver late subagent results as new turns (#4992) 2026-07-22 23:04:36 +08:00
Xubin Ren
aa8387fb4d feat(webui): polish agent output and app discovery 2026-07-22 22:42:31 +08:00
chengyongru
b189a37648
fix(agent): preserve agent-owned state in project workspaces (#4945) 2026-07-22 17:25:22 +08:00
chengyongru
4cd6eb6c38 fix(webui): avoid mobile welcome composer overlap 2026-07-22 15:55:09 +08:00
chengyongru
80085085d9 fix(exec): retain failed owner session cleanup 2026-07-22 15:28:34 +08:00
yorkhellen
ebf1ef5cab test(subagent): verify cascade exec termination on /stop
- terminate_by_owner kills matching sessions, skips others, handles
  empty owner case
- cancel_by_session calls terminate_by_owner on the session key
2026-07-22 15:28:34 +08:00
yorkhellen
7b1d81a868 fix(subagent): cascade exec session termination on /stop
cancel_by_session() only cancelled asyncio tasks, leaving child
processes from exec sessions orphaned. Since each SubagentManager
now owns a dedicated ExecSessionManager, terminate those sessions
by owner_session_key after cancelling tasks.

Add ExecSessionManager.terminate_by_owner() to kill all sessions
for a given owner, and call it from cancel_by_session().
2026-07-22 15:28:34 +08:00
chengyongru
254497c02e
fix(webui): improve mobile composer layout (#5030) 2026-07-22 14:59:56 +08:00
chengyongru
96abb4d2c4
style(webui): clarify surface hierarchy (#5029) 2026-07-22 14:33:58 +08:00
chengyongru
63bc6e98a7
fix(webui): detect Chrome voice recording support (#5027) 2026-07-22 14:10:09 +08:00
chengyongru
3748f664b2
feat(config): watch runtime configuration changes (#5026) 2026-07-22 13:08:39 +08:00
chengyongru
7bf7469d90
feat(webui): show pin indicators for pinned chats (#5025) 2026-07-22 11:47:45 +08:00
seteiro
79d9455313
fix(providers): add Qwen model-level thinking style mapping (#5023)
Add _QWEN_THINKING_MODELS to _MODEL_THINKING_STYLES with enable_thinking style. Prevents Qwen 3.5/3.6/3.7 models from exposing raw reasoning content in chat responses. Closes #4934
2026-07-22 10:46:22 +08:00
hamb1y
a9867a5a4e fix: quarantine invalid tool results 2026-07-22 01:59:09 +08:00
seteiro
c6a4d46a2a docs(security): recommend env-var references over plaintext API keys
Prefer ${VAR} env references in config over plaintext keys on disk. Closes #4803
2026-07-22 01:45:28 +08:00
yrk
be1cc769d5 docs: refine ModelScope documentation wording 2026-07-22 01:35:20 +08:00
yrk
9abad4746e feat(providers): add ModelScope provider for LLM and image generation 2026-07-22 01:35:20 +08:00
chengyongru
b32d673ead fix(webui): decouple skill reference rendering 2026-07-21 22:56:04 +08:00
chengyongru
79b89f4f4c feat(webui): highlight skill references in sent messages 2026-07-21 22:56:04 +08:00
Kris Lu
89d8c055a8 fix(providers): sanitize UTF-16 surrogates at provider request boundary
Symptom
-------
LLM requests intermittently fail with:
  'utf-8' codec can't encode characters in position N-N+1: surrogates not allowed
when messages contain emoji-heavy content (e.g. HTML with mixed emoji + JSON round-trips).
This blocks the affected session until the session file is quarantined.

Root cause
----------
Surrogate sanitization was only applied at the CLI entry point
(nanobot/cli/commands.py: _sanitize_surrogates). Requests entering
the LLM provider layer through other channels (Feishu, cron, webui,
tool results, memory injection) had no defensive cleaning, so any
message that happened to carry unpaired UTF-16 surrogates (from an
upstream JSON round-trip with ensure_ascii=True on ill-formed input,
memory rehydration, or third-party content) would blow up at
json.dumps -> HTTP encode time inside the provider client.

Fix
---
1. Extract sanitize_surrogates() and sanitize_surrogates_deep() into
   nanobot/utils/helpers.py as the single source of truth. Both use
   utf-16-le round-tripping with errors='surrogatepass' / 'replace',
   so paired surrogates reconstruct back into their real code point
   and lone surrogates collapse to U+FFFD.
2. Make nanobot/cli/commands.py:_sanitize_surrogates a thin wrapper
   that re-exports the shared helper (backward compatible).
3. Add defense-in-depth at the LLM provider boundary in
   nanobot/providers/base.py:_sanitize_empty_content by running
   sanitize_surrogates_deep over each message and its content blocks
   right before requests are serialized to JSON.

Non-goals
---------
- truncate_text() is intentionally left untouched. Python str slicing
  cannot split a single code point into surrogate halves, so it is
  not the source of lone surrogates.
- session/manager storage layer is untouched. Archived sessions
  reproduced the failure only through the request path, not through
  storage.

Verification
------------
- New regression suite tests/providers/test_sanitize_surrogates.py
  covers: paired surrogate reconstruction, lone surrogate replacement,
  identity return on clean input (zero allocation), deep recursion on
  dict/list/tuple, provider _sanitize_empty_content integration, and
  full utf-8 encodability of the sanitized request body.
- 14/14 new tests pass; full existing test module also green.
- Replayed 58 archived real session messages plus adversarial
  lone-surrogate injection through the provider path with no encode
  errors after the fix.

Impact
------
- No behaviour change for clean inputs (sanitize_surrogates_deep is
  an identity return when no surrogate is present).
- Fails-safe: unpaired surrogates degrade to U+FFFD instead of
  aborting the entire request.
2026-07-21 19:17:58 +08:00
santhreal
b81c05581f fix(cron): coerce string schedule/state ms fields from jobs.json
jobs.json can store everyMs/atMs and next/last run timestamps as strings.
Loading left them as str, so _compute_next_run compared str to int and raised TypeError.
Coerce with an optional-int helper at from_store_dict, matching runHistory int() paths.
2026-07-21 19:07:45 +08:00
chengyongru
1d7bad3909 feat(providers): support Codex fast mode 2026-07-21 17:55:17 +08:00
Xubin Ren
b46e7f4377 fix(config): invalidate fields with missing env refs 2026-07-21 17:35:16 +08:00
Ben Lenarts
4cfc99f4b3 fix(transcription): resolve ${VAR} env refs in transcription api_key/api_base
config.loader.load_config() intentionally returns the raw config with ${VAR}
references intact — env interpolation is a separate, explicit step
(resolve_config_env_vars) so that settings read/edit/save paths never
materialize secrets to disk or to the UI.

The transcription config path does not apply that step: both
channels/base.py (channel voice notes) and webui/transcription_ws.py (WebUI
recording) build their effective config via
resolve_transcription_config(load_config()). As a result a configured
api_key of "${GROQ_API_KEY}" (the documented way to reference secrets) is
passed to the provider verbatim, which fails with 401 Invalid API Key. No
amount of rotating the real key helps, because the literal placeholder
string is what gets sent.

Resolve the reference at the single choke point both callers share —
_resolve_transcription_api_key / _resolve_transcription_api_base — using a
new lenient loader.resolve_env_refs() helper (unset var -> empty string, so
a missing variable degrades to "not configured" rather than raising or
leaking). This fixes both entry points at once and cannot drift the way a
per-call-site fix does. Resolving inside load_config() was rejected: the
~20 settings-UI callers depend on it returning raw ${VAR} placeholders.

Literal keys are unaffected; the settings API only reads the derived
`configured` flag (never the key), which now reflects the resolved value.

Claude-Session: https://claude.ai/code/session_01Q3HuVaJAAQJA3kgVQVJ2Zt
2026-07-21 17:35:16 +08:00
Xubin Ren
b2cf37da4a fix(config): preserve permissions during atomic save 2026-07-21 17:33:39 +08:00
santhreal
28102382af fix(config): write config.json atomically via temp+replace
save_config truncated config.json in place on crash mid-write.
Route through _write_text_atomic like the pairing store so a failed write leaves the prior file intact.
2026-07-21 17:33:39 +08:00
chengyongru
93571149db fix(webui): prioritize skill names in autocomplete 2026-07-21 15:19:38 +08:00
chengyongru
052f671b3c fix(webui): keep Markdown table diffs inline 2026-07-21 15:16:12 +08:00
amplifierplus
cdb2df4982 fix(files): reject oversized reads before loading 2026-07-21 15:02:27 +08:00
chengyongru
d5658dbc91 fix: preserve background operator in allowlist segments
Maintainer edit: keep a top-level trailing '&' in the segment being matched so background execution cannot be checked as if the ampersand were absent. Redirection forms like 2>&1 and &> remain untouched.
2026-07-21 13:50:24 +08:00
chengyongru
ab6ceef1a1 refactor: simplify ampersand segment check
Maintainer edit: keep the single-ampersand guard behavior, but fold the redirect exceptions into one condition instead of carrying temporary previous/next character variables.
2026-07-21 13:50:24 +08:00
chengyongru
12c52c11d3 fix: treat single ampersand as shell segment
Maintainer edit: single '&' backgrounds the preceding command and starts another top-level shell segment, so allowPatterns must split it the same way as ';', '|', '&&', and '||'. Keep fd redirections such as 2>&1 and &> intact.
2026-07-21 13:50:24 +08:00
michaelxer
f4a7079e65 fix(security): use re.fullmatch per segment instead of re.search
Fixes chengyongru's review concern: re.search is more permissive
than the original re.fullmatch behavior for single-segment commands.
Using re.fullmatch per segment preserves backward compatibility while
still fixing the chained-command bypass.
2026-07-21 13:50:24 +08:00
michaelxer
bbca32fea9 fix(security): validate each shell segment against exec.allowPatterns
Guard against shell-chain bypass where an attacker appends '&& malicious'
after an allowlisted prefix. The allowlist check now splits the command
on top-level chaining operators (&&, ||, ;, |) and requires every segment
to match at least one allowPattern independently.

Fixes #4521
2026-07-21 13:50:24 +08:00
KDB
8981995474 fix(exec): clean up sessions on shutdown 2026-07-21 13:48:51 +08:00
KDB
7cf3c71e3a fix(session): cap messages at persistence boundary
Bind SessionManager saves to the existing raw archive path so SDK imports and other bypass saves cannot persist more than the file cap without archiving unconsolidated overflow.

Add an SDK regression test that exercises the real ingest path.

Refs #4787
2026-07-21 13:47:18 +08:00
axelray-dev
fde55d06e2 fix(runner): narrow BaseException catch to Exception in tool execution
The tool execution path caught BaseException, which includes
KeyboardInterrupt, SystemExit, MemoryError, and GeneratorExit.
These should never be caught and converted into conversational
error messages. CancelledError is already handled separately.

Change except BaseException to except Exception so fatal signals
propagate instead of being swallowed.

Adds parametrized regression test for KeyboardInterrupt and
SystemExit propagation.

Fixes #4788
2026-07-21 13:46:05 +08:00
santhreal
b6156fdd79 fix(cron): also coerce null createdAtMs/updatedAtMs on load
Same present-null footgun as runHistory; route all required store
ints through _store_int.
2026-07-21 13:45:02 +08:00
santhreal
0b1b02f187 fix(cron): coerce null runHistory ms fields from jobs.json
Explicit JSON null for runAtMs/durationMs bypassed the missing-key
default and raised TypeError on load. Treat null/blank like missing.
2026-07-21 13:45:02 +08:00
chengyongru
dfc3919b52 fix: stop masking runtime failures 2026-07-21 11:44:52 +08:00
chengyongru
afc65c086e refactor(session): simplify directory fsync handling 2026-07-21 10:11:53 +08:00
sunpengcheng05
4a79cbb6e7 fix(session): tolerate unsupported directory fsync 2026-07-21 10:11:53 +08:00
chengyongru
9db0d9f3c9
refactor(agent): unify internal turn lifecycle (#4993) 2026-07-21 00:14:27 +08:00
chengyongru
b67f4b1371 fix(qq): account for SDK reconnect pacing
Use per-session retry deadlines so botpy's post-connect delay counts toward backoff, and keep unexpected failures on the channel logger.
2026-07-20 23:24:03 +08:00
gola
ab0d28103b fix(qq): add exponential backoff to WebSocket reconnect loop
The QQ channel's _run_bot() used a fixed 5-second reconnect interval with
no backoff. When the network is unavailable (e.g., DNS failure), this
produces excessive botpy SDK error tracebacks every 5 seconds, flooding
logs.

botpy's Client.bot_connect() catches ws_connect() exceptions internally
and calls BotWebSocket.on_error(), which logs a full traceback and
immediately re-queues the session. The outer _run_bot() except never
fires for the reported DNS failure path.

Override bot_connect() on the _Bot subclass to:
- Apply exponential backoff (5s -> 300s cap) before re-queuing the session
- Log network errors (ClientConnectorDNSError, ClientConnectorError,
  OSError) compactly without traceback
- Reset backoff on successful connection
- Still call traceback.print_exc() for non-network errors

The outer _run_bot() loop retains exponential backoff as a fallback for
exceptions that escape start() entirely. The botpy library logging
redirect is elevated to ERROR to suppress redundant connection tracebacks.

Consistent with patterns already used in matrix.py and napcat.py.

Add 7 regression tests covering:
- DNS error applies backoff and re-queues session
- No traceback printed for network errors
- ClientConnectorError also triggers backoff
- Backoff doubles and caps at 300s
- Successful connection resets backoff
- Non-network errors still re-queue without backoff
- _is_network_error() classification

Fixes #4767
2026-07-20 23:24:03 +08:00
chengyongru
9d830fb6b6 docs(ollama): explain tool prompt cache reuse 2026-07-20 17:47:04 +08:00
chengyongru
8423cf3eeb
fix(channels): complete dependency manifest migration (#4995)
* fix(channels): complete dependency manifest migration

* docs(docker): clarify custom uid dependency installs

* fix(channels): keep dependency preinstall internal

* refactor(channels): move dependency installer to scripts

* fix(docker): limit runtime write access
2026-07-20 15:24:57 +08:00
chengyongru
76f3eead42
style(webui): simplify Markdown code blocks (#5002) 2026-07-20 14:41:23 +08:00
chengyongru
949cfad548 fix(webui): show copy action on every assistant message 2026-07-20 13:51:53 +08:00
chengyongru
e3de01c9f6 fix(webui): resolve build runner executable 2026-07-19 23:59:34 +08:00
chengyongru
462a0dfb0f
refactor(channels): make built-in channels self-contained (#4908)
* refactor(channels): own setup and instance contracts

* refactor(channels): isolate management contracts

* refactor(channels): normalize activation contracts

* fix(channels): enforce management contracts

* refactor(channels): finish setup ownership migration

* fix(channels): harden management contracts

* fix(channels): enforce lazy loading and runtime ownership

* fix(feishu): make multi-instance startup idempotent

* fix(webui): render channel setup contracts cleanly

* fix(feishu): stop websocket clients cleanly

* fix(channels): enforce persistence and activation gates

* fix(channels): preserve global feature action scope

* fix(channels): apply defaults for single plugins

* fix(channels): enforce management contract boundaries

* refactor(feishu): remove identity helper indirection

* fix(channels): preserve management setup contracts

* refactor(channels): generalize instance settings UI

* refactor(channels): package channel plugins with web UI metadata

* refactor(channels): make built-ins self-contained packages

* test(channels): colocate tests with channel packages

* fix(dingtalk): use official brand icon

* feat(channels): colocate webui translations

* docs(channels): clarify plugin ownership

* test(exec): remove output wait race

* refactor(channels): unify plugin descriptors

* fix(channels): enforce descriptor-owned contracts

* refactor(channels): finish package-owned plugin setup

* refactor(channels): use repository-owned packages only

* fix(channels): self-describe dependencies and runtime state

* fix(channels): warn about legacy entry points
2026-07-19 23:30:49 +08:00
chengyongru
7aaac37bca fix(triggers): require channel enablement predicate 2026-07-19 22:33:29 +08:00
Pei Futong
91514ad0b1 fix(triggers): reject deliveries to disabled channels 2026-07-19 22:33:29 +08:00
chengyongru
a6b68178aa
fix(whatsapp): allow group ids in allowFrom (#4834) 2026-07-19 19:16:37 +08:00
chengyongru
2099cb009e
fix(providers): fail over across provider failure domains 2026-07-19 17:37:55 +08:00
Pei Futong
39a952ecce fix(cli-apps): decode subprocess output as UTF-8 2026-07-19 16:05:26 +08:00
chengyongru
b1232fdaf4 fix(gitstore): preserve staged symlinks 2026-07-19 16:01:11 +08:00
Pei Futong
cea8617096 fix(gitstore): resolve staged paths relative to workspace 2026-07-19 16:01:11 +08:00
chengyongru
ffb7ddfa1e refactor(triggers): clarify stored integer coercion 2026-07-19 15:46:00 +08:00
santhreal
c2071594cf fix(triggers): rename coerce helper to _store_int
Match the cron store-load naming and cover null attempts on deliveries.
2026-07-19 15:46:00 +08:00
santhreal
cf96c4d5e9 fix(triggers): coerce null ms fields when loading local triggers
Explicit JSON null for runAtMs/createdAtMs raised TypeError and could
quarantine triggers.json. Treat null/blank like a missing key (0).
2026-07-19 15:46:00 +08:00
chengyongru
cf00f537bd fix(agent): guide recovery from oversized tool results
Use the existing in-flight context governor to replace tool output that cannot fit the next model request with a bounded, actionable instruction. The model can retry with narrower arguments, use another tool, or explain the context limit without a second recovery state machine.
2026-07-19 01:08:36 +08:00
Ho1yShif
cfa49c6e78 fix(render): issue short-lived WebUI tokens via tokenIssueSecret
Map NANOBOT_WEB_TOKEN to channels.websocket.tokenIssueSecret instead of
the static token, and remove the static token. The gateway now issues
short-lived WebSocket/API tokens rather than accepting a long-lived
credential directly at the handshake, matching the public-WebUI login
flow and documentation. Users still enter the same NANOBOT_WEB_TOKEN,
and websocketRequiresToken remains true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 17:39:59 +08:00
Ho1yShif
c062e1af14 fix(webui): quiet non-WebSocket handshake noise on public port
The WebSocket channel also serves the WebUI over plain HTTP, so on a
public endpoint (e.g. a Render *.onrender.com service) the underlying
websockets library logs a full-traceback ERROR for every request that
isn't a valid GET handshake: HEAD probes ("unsupported HTTP method;
expected GET"), port scanners, uptime monitors, and TLS-to-plain-port
attempts. These are internet background noise, not server faults.

WebSocketHandshakeNoiseFilter already suppressed "opening handshake
failed" records caused by mid-handshake disconnects; widen it to also
suppress records whose exception chain contains websockets'
InvalidMessage, which covers both non-GET methods and malformed/empty
requests. Genuine server-side handshake errors still log.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 17:39:59 +08:00
Ho1yShif
c77379099b refactor(entrypoint): improve privilege dropping and config initialization
- Updated entrypoint.sh to initialize the on-disk config only if it does not already exist, preserving user edits across restarts.
- Enhanced privilege dropping logic to ensure the container does not run as root if the privilege drop fails.
- Clarified comments in Dockerfile and entrypoint.sh for better understanding of the privilege management process.
- Updated README.md to include a note about persistent disks requiring a paid service on Render.
- Adjusted render.yaml to clarify the Docker command behavior and added a note regarding auto-deploy settings.
2026-07-18 17:39:59 +08:00
Ho1yShif
ca873e4d17 fix(README): update Deploy to Render link to point to the correct GitHub repository 2026-07-18 17:39:59 +08:00
Ho1yShif
63895fc101 fix: update Deploy to Render link in README
Changed the repository link for the one-click Deploy to Render button in the README.md file to point to the correct GitHub repository.
2026-07-18 17:39:59 +08:00
Ho1yShif
770d89b430 feat: add one-click Deploy to Render support
Adds a Render Blueprint (render.yaml) and supporting pieces so nanobot can
be deployed to Render in one click, with persistent memory across deploys.

- render.yaml: web service + 1GB persistent disk mounted at
  /home/nanobot/.nanobot. Prompts for ANTHROPIC_API_KEY and
  NANOBOT_WEB_TOKEN at deploy time (sync: false).
- render-config.json: committed gateway config that wires secrets via
  ${VAR} placeholders (resolved at runtime). Nothing secret is committed.
- entrypoint.sh: adds a branch gated on RENDER=true that copies the config
  onto the mounted disk, chowns the root-owned mount, and drops to the
  non-root nanobot user via setpriv. Local (non-Render) path is unchanged.
- Dockerfile: COPY render-config.json; USER nanobot -> USER root so the
  entrypoint can chown the freshly-mounted disk before dropping privileges;
  add PYTHONUNBUFFERED/PYTHONFAULTHANDLER for diagnosable crash output.
- README.md: Deploy to Render button + section.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 17:39:59 +08:00
santhreal
afed32b013 fix(cron): dual-case keys when loading jobs.json
jobs.json hand-edits and asdict-style snake_case for schedule intervals and
runHistory crashed or silently disabled cron. Deserialize via Cron* from_store_dict
and shared get_camel_snake (also used by local triggers).
2026-07-18 17:39:06 +08:00
bingqilinweimaotai
8c68c6fe1e feat: support Kimi K3 2026-07-18 17:38:32 +08:00
Yuxin Lou
b76d54aae1 Harden default Docker Compose security 2026-07-18 17:37:41 +08:00
KDB
d35f99abfc fix(session): bound the in-memory session cache
Keep only 128 recently used sessions strongly cached while retaining weak references to evicted sessions still owned by active callers. This bounds idle memory growth without allowing duplicate live Session objects or skipping shutdown flushes.

Add LRU, lifecycle, SDK, and flush regression coverage.

Refs #4786
2026-07-18 17:37:07 +08:00
adabarbulescu
d4f5abe004 fix: preserve real cancellation in MCP paths 2026-07-18 17:36:35 +08:00
yu-xin-c
07ad0bafa8 test(exec): relax wait-for timing on Windows 2026-07-18 17:35:56 +08:00
yu-xin-c
995cc44e89 fix(exec): isolate exec session managers 2026-07-18 17:35:56 +08:00
santhreal
7ac9a46978 fix(utils): avoid hang in split_message when max_len <= 0
When max_len is 0 or negative the cut pointer never advances, so the loop hangs. Return the content unsplit, matching truncate_text_to_tokens for non-positive budgets.
2026-07-18 17:24:12 +08:00
santhreal
fe0e65928d fix(utils): coerce Tavily usage counters to int
JSON APIs sometimes return numeric fields as strings; subtracting them raised TypeError and /status dropped the usage block.
2026-07-18 17:23:26 +08:00
santhreal
85097aa143 fix(utils): handle empty commit messages in CommitInfo.format
Empty git commit messages made splitlines()[0] raise IndexError in format() and /dream-restore list rendering.
2026-07-18 17:22:16 +08:00
Peter Dave Hello
6de5a0c5ca Improve zh-TW Traditional Chinese locale 2026-07-18 00:59:05 +08:00
bingqilinweimaotai
8a48af7c74 fix(providers): omit Kimi K2.5/K2.6 temperature 2026-07-17 22:39:33 +08:00
Xubin Ren
b4adb29c2b feat(webui): support native folder picker bridges 2026-07-17 12:26:03 +08:00
Xubin Ren
6519737860 docs(readme): reflect community maintenance 2026-07-16 13:52:12 +08:00
chengyongru
d4e0294734 fix(gateway): stop channels before draining tasks 2026-07-15 22:25:08 +08:00
chengyongru
681edfa6f3 fix(providers): honor Codex proxy config consistently 2026-07-15 20:01:48 +08:00
chengyongru
ba86dccc8d
fix(webui): correct activity timer duration (#4649) 2026-07-15 16:46:05 +08:00
chengyongru
5ed28a6744
fix(webui): validate inferred file paths before preview (#4935) 2026-07-15 10:45:40 +08:00
chengyongru
aa70aa48f9
fix(cli): point onboarding to the WebUI launcher (#4938) 2026-07-15 10:34:46 +08:00
chengyongru
0fd4d0ab29 fix(prompts): handle undecodable overrides 2026-07-15 01:19:48 +08:00
chengyongru
c1fd76add3 refactor(prompts): share workspace override handling 2026-07-15 01:19:48 +08:00
chengyongru
63a6d5d07d fix(heartbeat): retain history after empty runs 2026-07-15 01:19:48 +08:00
Arthur K.
dcb37259fa feat(heartbeat): custom evaluator prompt 2026-07-15 01:19:48 +08:00
chengyongru
88c38e9b38
fix(restart): deliver completion after channel reconnects (#4931) 2026-07-15 01:08:39 +08:00
chengyongru
37165b0db0
feat(webui): highlight slash commands and app mentions (#4933) 2026-07-15 00:34:22 +08:00
chengyongru
2116e32013 test: speed up CI and harden the suite 2026-07-15 00:18:37 +08:00
Olu B
06f47fa540 fix: standardize --config help text across CLI commands 2026-07-14 23:51:39 +08:00
chengyongru
905da8e34a ci(webui): verify npm and bun lockfiles 2026-07-14 23:16:21 +08:00
Elias
5365bab088 fix(webui): sync package-lock.json for qrcode dependency
fe0717b3 ("feat(webui): add guided setup flows") added qrcode and
@types/qrcode to webui/package.json without regenerating the lockfile,
so `npm ci` fails in the Docker build with EUSAGE (Missing: qrcode,
@types/qrcode, dijkstrajs, pngjs, yargs, ...).

Regenerate the lockfile so it matches package.json again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:16:21 +08:00
chengyongru
f718c69b2d feat(webui): add copy action to user messages 2026-07-14 22:41:17 +08:00
chengyongru
07f54c25e3
chore(codex): identify failing request stage (#4929) 2026-07-14 22:40:10 +08:00
chengyongru
1a1e666625 fix: install timezone data on Windows 2026-07-14 17:30:16 +08:00
chengyongru
297a9e5939 test: cover MCP cleanup cancellation paths 2026-07-14 16:17:35 +08:00
Brian Noah
86f6558707 fix: catch asyncio.CancelledError in close_mcp shutdown
When an MCP server (e.g. stdio browser-agent subprocess) does not
terminate within the AsyncExitStack.aclose() timeout, asyncio raises
CancelledError. The existing exception handler only caught RuntimeError
and BaseExceptionGroup, so CancelledError escaped the except block and
crashed nanobot with exit code 1 on every shutdown.

Add asyncio.CancelledError to the caught exception tuple so the error
is logged at debug level and shutdown completes cleanly.

Stack trace from the crash:

Traceback (most recent call last):
  File ".../nanobot/agent/loop.py", line 1194, in close_mcp
    await stack.aclose()
asyncio.exceptions.CancelledError

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File ".../asyncio/__main__.py", line ?, in <module>
  File ".../nanobot/agent/loop.py", line ?, in close_mcp
    ...
RuntimeError: ... (or BaseExceptionGroup) not caught
SystemExit: 1
2026-07-14 16:17:35 +08:00
chengyongru
4916fc07ab fix(agent): close reasoning on stream timeout 2026-07-14 15:51:22 +08:00
WK Wong
11eb9d8cc8 fix(agent): add wall-clock timeout for streaming LLM requests 2026-07-14 15:51:22 +08:00
chengyongru
6c9e3a2cc3
refactor(webui): centralize native runtime access (#4769) 2026-07-14 15:00:30 +08:00
chengyongru
b7048cf76a
feat(webui): support document attachments with ingress safeguards (#4771)
* feat: support document attachments in webui

* fix(webui): normalize document attachment MIME

* refactor(webui): move attachment policy out of channel

* fix(webui): reject oversized attachments before send

* fix(webui): align Portuguese attachment errors

* refactor(webui): separate ingress and transport limits

* fix(webui): reject malformed attachment payloads
2026-07-14 14:47:42 +08:00
chengyongru
b2759e8a6b
docs: reorganize documentation around user workflows (#4916) 2026-07-14 13:59:47 +08:00
chengyongru
1643aa7ef5 fix(shell): narrow PowerShell UTF-8 configuration
Avoid changing native pipeline input encoding and consolidate real PowerShell checks to reduce Windows runner startup flakiness.
2026-07-14 13:50:02 +08:00
chengyongru
9f8c2cb1bf test(shell): wait for Windows PowerShell sessions 2026-07-14 13:50:02 +08:00
chengyongru
61afbffc89 fix(shell): configure PowerShell UTF-8 output 2026-07-14 13:50:02 +08:00
adabarbulescu
9cdf17f5d5 Fix Windows exec UTF-16 output decoding 2026-07-14 13:50:02 +08:00
chengyongru
3b14d59dcd fix(telegram): fall back on overflow HTML rejection 2026-07-14 12:25:28 +08:00
chengyongru
a335ce07db fix(telegram): bound streamed HTML overflow chunks 2026-07-14 12:25:28 +08:00
sanasif786ka
87478b6e92 fix(telegram): keep raw markdown in overflow stream buffer tail
Send the overflow tail message with HTML parse_mode, but persist the
unrendered markdown chunk in buf.text so later deltas and stream_end
re-render correctly via _split_telegram_markdown_html().
2026-07-14 12:25:28 +08:00
sanasif786ka
67648774e2 fix(telegram): preserve HTML formatting in stream overflow chunks
Apply parse_mode=HTML to overflow intermediate chunks so markdown
formatting is not lost when long streamed messages are split.

Fixes #4637
2026-07-14 12:25:28 +08:00
chengyongru
6e462e62a6 fix(dream): limit newline normalization to CRLF 2026-07-14 00:30:50 +08:00
bingqilinweimaotai
ed47cf1562 fix(dream): ignore line-ending-only memory diffs 2026-07-14 00:30:50 +08:00
chengyongru
6b5820ea89 fix(webui): sync localized preboot copy 2026-07-13 23:28:19 +08:00
bill-kopp-ai-dev
1e7518a207 feat(webui): add Brazilian Portuguese (pt-BR) locale
Translate the WebUI common.json to pt-BR, register the locale in
config.ts (with normalizeLocale rules for "pt", "pt-BR", "pt-PT", ...)
and index.ts resources, and add a Brazilian Portuguese overview
assertion in i18n.test.tsx.

Notes:
- Generic "pt" and any "pt-*" variant resolve to pt-BR. A future
  pt-PT locale can be added without affecting existing users.
- Universally borrowed technical terms (nanobot, BYOK, MCP, JSON,
  URL, Web, Apps when used as a product name, Skills) are kept in
  English, matching the pattern used by es/fr/ja/ko/vi/id.
- Empty-thread greetings, quick-action prompts, and slash command
  descriptions are translated; quick-action prompts are intentionally
  phrased so the agent still receives a clear task spec.

Verified with bun run test (536 passing), bun run lint (clean), and
bun run build (7.18s).
2026-07-13 23:28:19 +08:00
chengyongru
4a3818e03c
docs: update recent changes through July 12 (#4913) 2026-07-13 22:50:22 +08:00
chengyongru
276cbd947f docs: remove broken Star History embed 2026-07-13 18:28:22 +08:00
chengyongru
234e895e5a
fix(codex): align OAuth defaults and setup docs (#4910)
* fix(codex): align OAuth defaults and setup docs

* docs(codex): clarify provider settings key

* docs(codex): simplify OAuth setup guidance
2026-07-13 17:04:33 +08:00
chengyongru
8c9110fee3
fix(dream): filter non-Dream history commits (#4905)
* fix(dream): filter non-Dream history commits

* fix(dream): resolve filtered commit diffs
2026-07-13 17:04:12 +08:00
chengyongru
e864fba522
fix: align optional dependency contracts (#4907)
* fix: align optional dependency contracts

* ci: avoid duplicate test suite

* test: drop removed langsmith assertions

* test: rely on generic extra coverage

* test: restore existing plugin coverage
2026-07-13 16:58:19 +08:00
Xubin Ren
a65a6f334c test(gateway): cover slow health clients 2026-07-13 15:16:51 +08:00
Xubin Ren
3824206ab2 fix(gateway): harden health endpoint exposure 2026-07-13 15:16:51 +08:00
Xubin Ren
2c78976728
fix(cli): restore enter before webui prompts 2026-07-13 13:32:29 +08:00
Xubin Ren
fe0717b385
feat(webui): add guided setup flows
* feat(channels): add guided setup flows

* test(channels): preserve setup config values

* fix(channels): reflect saved setup state

* refactor(channels): simplify setup state metadata

* fix(channels): harden setup lifecycle

* refactor(channels): centralize setup contracts

* fix(channels): route setup actions through webui shim

* fix(channels): adapt settings for compact screens

* fix(models): preserve default preset display

* feat(models): add curated Codex catalog

* fix(webui): stop attached gateway on interrupt

* fix(webui): simplify apps catalog

* docs(webui): clarify apps and runtime features

* feat(settings): add guided capability setup

* fix(webui): harden setup and managed services

* test: keep managed runtime checks portable

* test: scope POSIX runtime coverage

* fix(webui): simplify file settings

* feat(files): bundle document reading

* fix(webui): harden setup request boundaries

* fix(webui): prevent channel setup status squeeze

* fix(settings): group provider compatibility aliases

* refactor(settings): remove redundant setup surfaces

* fix(webui): harden guided setup lifecycle

* fix(webui): preserve channel setup compatibility
2026-07-13 13:11:46 +08:00
chengyongru
791c7fd505 fix(memory): prune encoded Dream sessions 2026-07-13 12:07:29 +08:00
chengyongru
6d406d93c9 fix(discord): route unauthorized DMs to pairing 2026-07-13 12:05:04 +08:00
Arthur K.
053722a2a2 fix(heartbeat): rewrite prompt to execute tasks instead of reporting 2026-07-13 12:02:37 +08:00
Xubin Ren
45d1caba1d fix(webui): prevent stale workspace scope restore 2026-07-12 22:08:27 +08:00
Xubin Ren
89acea6fe1 fix(webui): allow remote workspace access reduction 2026-07-12 22:08:27 +08:00
Xubin Ren
01e4f3762a fix(agent): keep runtime context within its lifecycle 2026-07-12 00:35:17 +08:00
chengyongru
c339ce8bba refactor(agent): remove obsolete MCP prompt annotations 2026-07-12 00:35:17 +08:00
chengyongru
12b88d3872 docs(agent): summarize full my tool capability 2026-07-12 00:35:17 +08:00
chengyongru
8ccb2a451b docs(agent): advertise request metadata in my skill 2026-07-12 00:35:17 +08:00
chengyongru
20748bc4e3 perf(agent): load my skill on demand 2026-07-12 00:35:17 +08:00
chengyongru
93de413432 feat(agent): expose request routing metadata through my 2026-07-12 00:35:17 +08:00
chengyongru
d349b65713 test(agent): remove obsolete runtime context assertions 2026-07-12 00:35:17 +08:00
chengyongru
54b250430d fix(session): stop synthesizing MCP preset prompt context 2026-07-12 00:35:17 +08:00
chengyongru
5794d481c3 refactor(agent): remove MCP runtime context provider 2026-07-12 00:35:17 +08:00
chengyongru
e206ee4e70 refactor(agent): remove legacy image mode prompt injection 2026-07-12 00:35:17 +08:00
chengyongru
2eb7398f34 fix(agent): close runtime context persistence gaps 2026-07-12 00:35:17 +08:00
chengyongru
f75d3519db feat(agent): add persistent runtime context providers 2026-07-12 00:35:17 +08:00
chengyongru
7f8c3453e1 refactor(agent): gate sustained goals behind explicit /goal
Replace the legacy long-goal skill contract with command-scoped goal tools and runtime guidance. Keep goal state durable across continuations while restricting create and replace mutations to explicit user /goal turns.
2026-07-12 00:35:17 +08:00
Xubin Ren
edf78e7054 fix(mcp): keep transport cleanup in owner tasks 2026-07-11 11:45:20 +08:00
chengyongru
bd0dd85f44 test: isolate MCP reconnect DNS transport
Maintainer edit: the real MCP reconnect regression test can bind the class-level PinnedDNSAsyncTransport lock to its pytest event loop. Patch the MCP module to use a test-local transport subclass so later async tests do not inherit that loop-bound lock.
2026-07-11 11:45:20 +08:00
chengyongru
469d004773 test: add MCP reconnect crash regression
maintainer edit: port the real streamable-http idle-timeout reproduction from #4764 so this PR carries regression coverage for #4302's gateway crash path.

Co-authored-by: tjc0726 <49085201+tjc0726@users.noreply.github.com>
2026-07-11 11:45:20 +08:00
flyzstu
f3d1b9ca2d fix(mcp): defer stale stack cleanup during reconnect 2026-07-11 11:45:20 +08:00
Xubin Ren
c111aaa7ee fix(dream): gate periodic commits on content changes 2026-07-11 11:16:59 +08:00
Aleksander W. Oleszkiewicz (Alek)
9adcd3d923 adding tests to prevent regression 2026-07-11 11:16:59 +08:00
Aleksander W. Oleszkiewicz (Alek)
0a1c98c142 Dream jobs don't create git commits if there were no changes
fixes #4872
2026-07-11 11:16:59 +08:00
chengyongru
7675364eae fix(tools): enforce edit_file line hints 2026-07-11 01:14:50 +08:00
chengyongru
052fdc132d feat(tools): guard edit_file target lines 2026-07-11 01:14:50 +08:00
chengyongru
47d498bab0 fix(webui): disarm queued prompt for voice shortcut 2026-07-11 01:02:14 +08:00
chengyongru
04328f8129 feat(webui): guide queued prompt with second Enter 2026-07-11 01:02:14 +08:00
chengyongru
137fbf875e fix(webui): keep syntax highlighting chunks acyclic 2026-07-11 00:57:37 +08:00
chengyongru
1ee4349a08 feat(webui): highlight file previews and diffs 2026-07-11 00:57:37 +08:00
Eric Yang
9a1d1e64c7 fix(shell): harden kill path and add zombie reap tests
Skip process.kill() when returncode is already set so generic exception
handlers after a successful communicate() cannot raise ProcessLookupError.
Suppress race kill failures and still run the safety-net reap.

Add unit and integration coverage for owned-PID reaping on normal exit,
timeout, exception, and exec-session kill/poll paths.
2026-07-10 20:19:44 +08:00
Eric Yang
a1fbfd9f7b fix(shell): drop gateway-wide waitpid(-1) zombie reaper
A global reaper can race asyncio's child watcher for just-exited
create_subprocess_exec children, causing Process returncode 255 instead
of the real command status. Keep only owned-PID reaps after wait/communicate
/kill paths in shell and exec_session.

Addresses review feedback on #4840.
2026-07-10 20:19:44 +08:00
Eric Yang
bda0c099ab fix(shell): guard _reap_pid on os.waitpid availability
Windows CI runs Unix-path unit tests by patching _IS_WINDOWS=False while
still on win32, where os.WNOHANG/os.waitpid do not exist. Use capability
checks so reaping is a no-op on platforms without waitpid rather than
trusting the (mockable) platform flag.
2026-07-10 20:19:44 +08:00
Eric Yang
ef14d1ea92 fix(shell): remove unused os import in exec_session
Ruff F401 failed CI on the zombie-reap PR; reaping uses shell._reap_pid
so os is not needed in this module.
2026-07-10 20:19:44 +08:00
Eric Yang
c9e014fdea fix(shell): reap zombie processes on all subprocess exit paths
The previous fix (dbcc7cb5) only added os.waitpid() to _kill_process(),
covering the timeout/cancel path of one-shot exec. Zombies continued to
accumulate because several other exit paths never reaped children:

- _ExecSession.kill(): sent SIGKILL + process.wait(5s) but had no
  os.waitpid() fallback if the wait timed out
- ExecTool.execute() generic exception handler: leaked the subprocess
  if communicate() raised an unexpected error
- Normal completion paths: relied entirely on asyncio's child-watcher,
  which can miss exits inside Docker containers (pidfd/SIGCHLD gaps)

Changes:
- Extract _reap_pid() helper for consistent, safe os.waitpid(WNOHANG)
- Add _reap_pid() fallback to _ExecSession.kill() via try/finally
- Add _reap_pid() safety-net after normal process exit in both
  ExecTool.execute() and _ExecSession.poll()
- Kill + reap subprocess in the generic except Exception handler
- Add periodic zombie reaper background task (every 30s) in the
  gateway as a last line of defense
2026-07-10 20:19:44 +08:00
sid
d32f8961b1 fix(webui): bind landing first message to created chat 2026-07-10 20:19:10 +08:00
chengyongru
45a6466c2c style: trim CSI-u shortcut comments
Maintainer edit: simplify the explanatory comments for the CSI-u shortcut while preserving the existing behavior and regression coverage.
2026-07-10 19:32:47 +08:00
wangjunwei
99e04c2da2 fix(cli): handle CSI-u Shift+Enter instead of dumping raw escapes
Terminals speaking the CSI-u (kitty / fixterms) keyboard protocol -- kitty,
Ghostty, WezTerm, and terminal panes that default to it -- encode Shift+Enter
as the escape sequence "\x1b[13;2u". prompt_toolkit 3.0 has no support for
that protocol and no default mapping for the sequence, so its Vt100Parser
fails to recognise it and dumps the raw bytes ("^[[13;2u") straight into the
prompt buffer. The multiline-input work kept only Alt+Enter, so on these
terminals Shift+Enter now leaks visible escape garbage into the input.

Register "\x1b[13;2u" (absent from prompt_toolkit's default ANSI_SEQUENCES,
so setdefault() overrides nothing) and bind it to insert a newline, matching
the Alt+Enter behaviour. This is best-effort: prompt_toolkit cannot negotiate
the protocol, so we only react to a CSI-u sequence a terminal already emits;
terminals that collapse Shift+Enter into plain Enter fall back to Alt+Enter,
which stays the primary shortcut.

Add a real-PromptSession regression test asserting the sequence inserts a
newline rather than leaking raw escape bytes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 19:32:47 +08:00
NOKIAO
87602a74ea narrower cert path 2026-07-10 19:32:23 +08:00
NOKIAO
ad55f3ce37 添加红帽系Linux的证书路径支持 2026-07-10 19:32:23 +08:00
Siddhant Londhe
e284592649 fix(webui): sync package-lock.json to resolve docker build failure 2026-07-10 19:31:58 +08:00
chengyongru
2154dc51d0 docs: add automation guide 2026-07-10 19:31:33 +08:00
chengyongru
fe7d94359b fix(agent): preserve runtime compatibility contracts 2026-07-10 17:54:34 +08:00
chengyongru
10930f3902 Revert "test(agent): probe runtime change amplification"
This reverts commit 85c73779f9cb6c4cd707fb51f80cb4e363861861.
2026-07-10 17:54:34 +08:00
chengyongru
6b7470646f test(agent): probe runtime change amplification 2026-07-10 17:54:34 +08:00
chengyongru
21f58cbabf refactor(agent): make resolver sole runtime owner 2026-07-10 17:54:34 +08:00
chengyongru
c9d3e74342 refactor(agent): require runtime for consolidation 2026-07-10 17:54:34 +08:00
chengyongru
5bd3d1e0af refactor(agent): centralize runtime default mutations 2026-07-10 17:54:34 +08:00
chengyongru
af85c356b8 refactor(agent): capture subagent runtime before spawn 2026-07-10 17:54:34 +08:00
chengyongru
198fd9f869 refactor(agent): capture runtime at turn admission 2026-07-10 17:54:34 +08:00
chengyongru
45ace23580 test(agent): assert subagent runtime generation 2026-07-10 17:54:34 +08:00
chengyongru
b4f069800e refactor(agent): make runner consume required runtime 2026-07-10 17:54:34 +08:00
chengyongru
3f8170e835 test(agent): characterize active runner provider drift 2026-07-10 17:54:34 +08:00
chengyongru
cb03d2c748 refactor(agent): snapshot generation without provider mutation 2026-07-10 17:54:34 +08:00
chengyongru
bd94fefd1a refactor(agent): introduce immutable model runtime resolver 2026-07-10 17:54:34 +08:00
chengyongru
42d7ad34a4 refactor(agent): unify request context routing 2026-07-10 17:54:34 +08:00
chengyongru
bb3b449e09 refactor(agent): capture original user text per turn 2026-07-10 17:54:34 +08:00
chengyongru
55317094b6 test(agent): characterize request context isolation 2026-07-10 17:54:34 +08:00
David Jimenez
6313ae9f4f add Dockerfile arg to override optional Python dependencies to install at build time 2026-07-09 23:58:59 +08:00
chengyongru
4137be62a1 fix(matrix): preserve mxc markdown image sources 2026-07-09 23:58:26 +08:00
chengyongru
0b9ca744c2 docs(webui): mention file edit previews in guide 2026-07-09 10:42:43 +08:00
chengyongru
7685a89dcc docs(webui): describe file edit diffs 2026-07-09 10:42:43 +08:00
chengyongru
bbd4f4054b fix(webui): respect full access in file preview 2026-07-09 10:42:43 +08:00
chengyongru
0ae28571e7 fix(agent): close file edit activity on cancellation 2026-07-09 10:42:43 +08:00
chengyongru
6851a6ebd4 fix(webui): sync file edit display preference 2026-07-09 10:42:43 +08:00
chengyongru
7768672c5b feat: add file edit diff progress view
Capture file edit snapshots through runner tool lifecycle hooks and render unified diffs in the WebUI with folding and truncation controls.
2026-07-09 10:42:43 +08:00
chengyongru
207813d3b5 fix(webui): tighten localhost bootstrap check 2026-07-09 10:42:00 +08:00
chengyongru
e0c2d28f90 fix(webui): issue localhost bootstrap api tokens 2026-07-09 10:42:00 +08:00
chengyongru
8559458258 refactor(agent): add turn hook factories 2026-07-08 21:02:12 +08:00
chengyongru
04dbf17426 test(agent): cover turn hook ordering 2026-07-08 21:02:12 +08:00
chengyongru
a52a88455b refactor(agent): extract turn hook assembly 2026-07-08 21:02:12 +08:00
Xubin Ren
3ab09075c5 test(webui): tolerate wrapped bootstrap CLI output 2026-07-08 21:01:48 +08:00
chengyongru
4ddd639e67 fix(webui): route missing API bootstrap tokens to auth
maintainer edit: handle review feedback by treating bootstrap responses without api_token as auth-required, and remove the obsolete issue_token(api_token=...) compatibility path now that API tokens are issued separately.
2026-07-08 21:01:48 +08:00
chengyongru
444f488563 fix(webui): repair bootstrap secret launch paths 2026-07-08 21:01:48 +08:00
chengyongru
88143a8bf0 fix(webui): gate bootstrap API token issuance 2026-07-08 21:01:48 +08:00
chengyongru
7204d88a4c docs: add high-usage chat app guides 2026-07-08 20:56:27 +08:00
chengyongru
1a21542d11 docs: address search entry review feedback 2026-07-08 20:56:27 +08:00
chengyongru
f531f1ce38 docs: improve search entry pages 2026-07-08 20:56:27 +08:00
chengyongru
941a2541eb docs: document onboard refresh flag
maintainer edit: add CLI, quick start, and troubleshooting docs for the non-interactive config refresh flow introduced by this PR.
2026-07-08 20:56:06 +08:00
Aleksander W. Oleszkiewicz (Alek)
be8ac1e484 removed duplicated code 2026-07-08 20:56:06 +08:00
Aleksander W. Oleszkiewicz (Alek)
f074aa7d80 feat: add --refresh flag to onboard command for non-interactive config updates 2026-07-08 20:56:06 +08:00
chengyongru
ea7f4679f1 fix(webui): keep prompt rail at original gutter offset 2026-07-08 12:16:47 +08:00
chengyongru
c188e96f4e refactor(webui): use container query for prompt rail layout 2026-07-08 12:16:47 +08:00
chengyongru
815f15993c test(webui): dedupe prompt rail viewport setup 2026-07-08 12:16:47 +08:00
chengyongru
4333d6f103 fix(webui): keep prompt rail out of narrow chat columns 2026-07-08 12:16:47 +08:00
Aleksander W. Oleszkiewicz (auticon)
65d32ffd6c Fix dependency assertions in tests
Added a new `aiohttp` dependency for Slack
2026-07-08 12:16:30 +08:00
Aleksander W. Oleszkiewicz (auticon)
379785d365 Fix missing aiohttp slack dependency in pyproject.toml
Added `"aiohttp>=3.9.0,<4.0.0"` to the dependencies for **slack**
2026-07-08 12:16:30 +08:00
chengyongru
883776358e fix: keep local api serve unauthenticated
maintainer edit: Align OpenAI-compatible API auth with the WebSocket channel boundary: loopback serve remains usable without a key, while wildcard binds still fail before agent initialization unless api.api_key is configured.
2026-07-08 12:16:12 +08:00
chengyongru
28141ce20b docs: update serve api key requirement
maintainer edit: Align OpenAI-compatible API docs and examples with the new fail-closed api.api_key requirement while keeping /health documented as unauthenticated.
2026-07-08 12:16:12 +08:00
chengyongru
460c62c0b0 fix: validate api key before serve setup
maintainer edit: Check api.api_key before template sync and AgentLoop construction so missing-key startup errors are not hidden by provider or workspace initialization failures.
2026-07-08 12:16:12 +08:00
hamb1y
e86133c434 fix: require api auth in server factory 2026-07-08 12:16:12 +08:00
hamb1y
6c59332a8a fix: require api key for serve 2026-07-08 12:16:12 +08:00
chengyongru
a7b8a9ed46 fix(webui): clarify new chat command text 2026-07-07 15:42:04 +08:00
chengyongru
7e135b45f3 docs(webui): document slash command lifecycles 2026-07-07 15:42:04 +08:00
chengyongru
fa73448f6f fix(webui): drive slash command routing from metadata 2026-07-07 15:42:04 +08:00
chengyongru
8a231b6e4d fix(webui): finalize turn-ending slash commands
maintainer edit: keep /new and manually submitted /stop from leaving stale WebUI streaming state after they cancel or reset the active turn.
2026-07-07 15:42:04 +08:00
chengyongru
ef5318ebdc fix(webui): classify builtin slash commands without metadata
maintainer edit: keep builtin shortcut commands on the side-channel path before async command metadata loads, while preserving /goal task text as a normal agent turn.
2026-07-07 15:42:04 +08:00
chengyongru
8f68040f05 fix(webui): keep slash commands out of streaming state 2026-07-07 15:42:04 +08:00
chengyongru
0f88927364 fix(webui): show generic tool arguments in activity 2026-07-07 15:41:45 +08:00
chengyongru
3f33ff3143 chore: remove unused dead code 2026-07-07 15:41:27 +08:00
chengyongru
29e99d3742 docs: document Alt+Enter multiline input
maintainer edit: document the supported interactive CLI multiline shortcut and trim comments left after removing Shift+Enter support.
2026-07-07 15:41:08 +08:00
chengyongru
01a0f5aaf3 fix: remove unreliable Shift+Enter shortcut
maintainer edit: keep Alt+Enter as the supported multiline input path and remove the terminal-dependent Shift+Enter ANSI sequence patch.
2026-07-07 15:41:08 +08:00
wangjunwei
77a6003255 fix(cli): make Alt+Enter insert a newline on LF-as-Enter terminals
On terminals that send a bare LF for plain Enter (WSL is the case
prompt_toolkit itself calls out), Alt+Enter arrives as ESC + LF
("\x1b\x0a" = Escape + ControlJ), not the ESC + CR the existing
"escape","enter" binding matches. The Escape was swallowed and the bare
LF hit prompt_toolkit's default submit, so the documented "universally
supported" Alt+Enter newline fallback failed on exactly the terminal path
plain Enter is preserved for.

Bind ESC + ControlJ to insert a newline too, and add a real-PromptSession
regression test covering the WSL Alt+Enter path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:41:08 +08:00
wangjunwei
25a477050d fix(cli): make the xterm modifyOtherKeys Shift+Enter encoding insert a newline
"\x1b[27;2;13~" (the older xterm modifyOtherKeys / rxvt encoding of
Shift+Enter) is already registered by prompt_toolkit by default -- as
Keys.ControlM, i.e. plain Enter/submit. The previous `setdefault()` call was
therefore a silent no-op against it: this Shift+Enter variant kept behaving
like a submit instead of inserting a newline, even after the ControlJ/WSL
fix, since setdefault only sets missing keys.

Assign directly to override that default for both known Shift+Enter
sequences, since inserting a newline is the whole point of the binding.
Add a regression test driving a real PromptSession/parser with this exact
sequence, since a mocked-buffer test can't observe prompt_toolkit's default
ANSI_SEQUENCES entries taking priority.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:41:08 +08:00
wangjunwei
ea0516e655 fix(cli): stop hijacking ControlJ for Shift+Enter, it breaks Enter on WSL
Keys.ControlJ is also the literal LF byte ("\x0a") that some terminals send
for a plain Enter keypress -- prompt_toolkit's own default bindings handle
this by re-feeding it as ControlM/submit, and calls out WSL by name as the
case that needs it. Binding our Shift+Enter handler to ControlJ shadowed
that default, so on any terminal sending LF for Enter, pressing Enter only
ever inserted a newline and the prompt could never be submitted.

Register the CSI-u Shift+Enter sequences against Keys.ControlF3 instead: an
enum member prompt_toolkit declares but never wires to a default ANSI
sequence or key binding, so it's only reachable through our own mapping.

Also add a regression test that drives a real PromptSession/Vt100Parser
with a raw LF byte -- the existing key-binding test invoked handlers
directly against a mocked buffer, which exercises the handler logic but not
prompt_toolkit's key-resolution precedence, so it couldn't have caught this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:41:08 +08:00
wangjunwei
18a230de75 feat(cli): support multiline input via Shift+Enter / Alt+Enter
The interactive prompt used a single-line buffer (multiline=False), so
there was no way to compose a multi-line message before submitting.

Switch to a multiline-capable buffer with custom key bindings: Enter still
submits (unchanged feel), Alt+Enter always inserts a newline, and
Shift+Enter inserts a newline on terminals that emit a distinguishable
CSI-u sequence for it (kitty, iTerm2 with the fixterms protocol) by mapping
those sequences onto the otherwise-unused ControlJ key.
2026-07-07 15:41:08 +08:00
chengyongru
c5e053f83b fix: pin validated DNS for SSRF-safe fetches
maintainer edit: keep MCP HTTP SSRF checks strict, pin validated DNS for direct web_fetch and HTTP/SSE MCP requests, preserve explicit and environment proxy compatibility, and cover the proxy/redirect/rebinding cases with tests.
2026-07-07 15:40:53 +08:00
hamb1y
b68ae4f9bc fix: reject proxied pinned web fetches 2026-07-07 15:40:53 +08:00
hamb1y
97e3b360c2 fix: serialize pinned dns web fetches 2026-07-07 15:40:53 +08:00
hamb1y
4353f4680b fix: allow local mcp urls with pinned dns 2026-07-07 15:40:53 +08:00
hamb1y
73bf299a59 fix: pin validated dns for ssrf checks 2026-07-07 15:40:53 +08:00
Xubin Ren
d04ad1a5b4 fix(gateway): resolve runtime config path for state refresh 2026-07-06 15:55:15 +08:00
dajiaohuang
67b56cba74 @
fix(gateway): self-heal state file PID on server startup

After /restart on Windows, the gateway process gets a new PID (os.execv
creates a new process on Windows), but the state file at
run/gateway.<suffix>.json still contains the old PID from the initial
background spawn.  Nothing rewrites it, leaving state inconsistent.

Add GatewayRuntime.refresh_state_pid() — a classmethod that reads the
existing state file, updates the PID and identity to the current
process, and writes atomically.  Call it early in _run_gateway() so the
state file is always correct regardless of how the process was started
(initial spawn, os.execv, or subprocess.Popen).

On POSIX os.execv preserves the PID, so this is a no-op in normal
operation there, but still beneficial after any unusual restart path.

Fixes #4511
@
2026-07-06 15:29:11 +08:00
Kenneth Zhao
105230cc34 fix(cli): print response text when streaming fails in interactive mode 2026-07-06 15:28:10 +08:00
chengyongru
dd014b50a7 docs(mattermost): finish channel ordering cleanup
Maintainer edit: move remaining Mattermost documentation mentions to the end of named channel lists.
2026-07-06 12:14:57 +08:00
chengyongru
595d789c6f docs(mattermost): list new channel last
Maintainer edit: keep Mattermost at the end of documented channel lists so existing channel order remains first-come-first-served.
2026-07-06 12:14:57 +08:00
chengyongru
cf35238834 fix(mattermost): harden channel lifecycle and streaming
Maintainer edit: keep the Mattermost adapter running under the gateway, fail closed when team filtering cannot verify the team, isolate new thread sessions immediately, and make buffered stream finalization retry-safe.
2026-07-06 12:14:57 +08:00
Kenneth Zhao
ef9780719d style: fix import ordering in mattermost tests 2026-07-06 12:14:57 +08:00
Kenneth Zhao
cc70a2a79f fix(mattermost): fix file download paths and mobile streaming
- Use Mattermost file metadata/download API paths that work with current servers
- Buffer streamed content and post the final reply once for mobile clients that ignore post edits
- Keep attachment upload behavior on the first split chunk
2026-07-06 12:14:57 +08:00
Kenneth Zhao
f9806cc60f fix(mattermost): address second round of review feedback
- Send pairing code response (not empty message) for denied DMs
- Resolve actual channel type in action events before permission check
- Use word-boundary regex in _is_mentioned to avoid partial matches
- Use safe_filename for download path sanitization
- Add tests: denied DM pairing, denied action event, is_mentioned boundary
2026-07-06 12:14:57 +08:00
Kenneth Zhao
76877036f7 mattermost: remove unused _BOT_MENTION_RE 2026-07-06 12:14:57 +08:00
Kenneth Zhao
a710a7d6f7 fix(mattermost): address review comments
- Attachments now only attached to first chunk when message is split
- Filename sanitized with Path(name).name to prevent path traversal
- Updated streaming tests to match buffer-and-post-at-end pattern
  (iOS compatibility)
2026-07-06 12:14:57 +08:00
Kenneth Zhao
fff38f11a7 feat: add Mattermost channel support 2026-07-06 12:14:57 +08:00
chengyongru
5e51c5014f feat(feishu): render new session divider
maintainer edit: remove out-of-scope reasoning panel changes and keep this PR focused on the /new session divider.
2026-07-06 12:14:54 +08:00
Xubin Ren
937f04ac86 docs(config): document canonical OpenCode provider 2026-07-06 12:13:00 +08:00
hamb1y
9eade9be5f test: update quick start provider expectations 2026-07-06 12:13:00 +08:00
hamb1y
400369f0b0 feat: support canonical opencode provider 2026-07-06 12:13:00 +08:00
Kenneth Zhao
f0c989ba2d
fix(dream): ground memory audit records in the real git diff (#4673)
* fix(dream): ground commit messages and cursor advance in the real git diff

Dream consolidation could emit a /dream-log audit record that did not match
the actual file changes: build_dream_commit_message appended the LLM's
unverified resp.content, dream_run_completed only checked the stop reason, and
file contents were deliberately omitted from the prompt. The combination let a
single-turn self-report become the durable audit record.

- gitstore: add summarize_working_tree() — a structured, machine-derived
  summary (per-file +N/-M, totals, capped unified diff) of working-tree
  changes vs HEAD. Pure filesystem/git ground truth, never LLM narrative.
- memory: build_dream_commit_message now takes the diff body instead of resp;
  dream_content_diff() exposes the real delta over SOUL/USER/MEMORY.md only
  (excludes .dream_cursor so cursor writes aren't mistaken for edits);
  build_dream_prompt embeds current file contents so the model edits reality,
  not a stale mental model.
- builtin/cli: both Dream paths now compute the diff, gate cursor advance on
  a non-empty delta (no-op runs no longer swallow history), and commit with
  the diff-grounded message. Non-git workspaces fall back to the completion
  check.
- dream.md: document that contents are embedded, and add a chain-of-
  verification guardrail so the model's summary cannot claim unmade edits.

A regression test proves a lying resp.content never reaches the audit log
while the real diff does.

* fix(dream): mark non-UTF-8 memory files as binary in diff summary

Address review feedback (Q1 on PR #4673): summarize_working_tree read
working-tree files with errors="replace", which would emit U+FFFD
replacement chars into the audit record if a memory file ever held
invalid UTF-8 — misrepresenting the diff it is meant to make truthful.

Switch to errors="strict" and catch UnicodeDecodeError: a non-UTF-8
(or binary/corrupt) file is now recorded as "{path}: binary or
non-UTF-8 file changed" and omitted from the unified diff, so the
audit record stays honest. An empty diff block is also suppressed when
all changes are binary.

Adds a defensive regression test asserting no replacement char leaks.
2026-07-06 12:12:55 +08:00
hata
70505bd1fc fix(webui): finish mobile containment for inline code, tables, chips, user bubbles
57f9ec0 added a CSS containment safety net for .markdown-content, but
several rendering paths still overflowed or truncated on narrow viewports
because they bypass those rules or actively disable wrapping:

- Inline code longer than 120 chars renders as `block whitespace-pre`
  with no scroll container, so it bled past the viewport (white-space:pre
  defeats overflow-wrap). It now scrolls inside its own box via
  max-w-full overflow-x-auto.
- Wide markdown tables never actually scrolled: Tailwind Typography forces
  width:100% + table-layout:auto on .prose table, so they shrank to fit
  instead of overflowing. Switched to a wrapper-<div> pattern (a custom
  `table` component, the approach used by DeepSeek/others) with min-w-max
  so wide tables keep natural column widths and scroll inside the
  conversation column; the old display:block rule on .markdown-content
  table is removed.
- File-reference chips and inline link-preview cards used `truncate`, so
  long filenames/labels were ellipsized to one line. Now
  [overflow-wrap:anywhere] sm:truncate — wrap on mobile, truncate on
  desktop.
- User-authored message bubbles don't pass through .markdown-content, so
  a long URL in the user's own message still burst the screen: under flex
  min-width:auto, break-words cannot reduce the item's min-content width.
  Added max-w-full min-w-0 + [overflow-wrap:anywhere].

No JS/agent/provider behavior touched. Desktop layout is unchanged — the
new rules only engage when content would otherwise overflow.

Verified: bun run lint (clean), bun run test (438 passed), bun run build.

Refs #4693

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:11:43 +08:00
hata
162b6ee5bd fix(webui): keep chat viewport and composer inside narrow viewports
On mobile-width browsers the conversation column and bottom composer could
be forced wider than 100vw: long markdown (URLs, branch names, file paths),
fenced code, and tables stretched the prose container, and the thread scroll
area let the overflow bleed horizontally (#4693).

Add a pure-CSS containment safety net in globals.css:
- .markdown-content gets min-width:0, max-width:100%, overflow-wrap:anywhere
  so long tokens wrap instead of stretching the column.
- long links and inline code break rather than overflow.
- fenced/pre blocks and markdown tables scroll inside their own box.
- the thread scroll column clips residual horizontal bleed (overflow-x:hidden)
  while vertical scroll and internal code-block scroll are unaffected.

No component, JS, or agent-loop behavior touched; desktop layout unchanged.

Fixes #4693

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-06 12:11:43 +08:00
Xubin Ren
b1d29ede7d test(mcp): cover limited enabledTools names 2026-07-06 12:11:37 +08:00
ThomasZP Yang
800d51ecac fix: align MCP tool matching with limited names 2026-07-06 12:11:37 +08:00
ThomasZP Yang
3f9fb63d4c fix: limit long MCP-derived tool names 2026-07-06 12:11:37 +08:00
Xubin Ren
1cfa48ad4a fix(web-search): return structured Serper errors 2026-07-06 12:11:31 +08:00
franciscomaestre
8a42a9c73a feat(web-search): add Serper.dev (Google Search API) provider
Add 'serper' as a web search backend, following the existing provider
pattern (keenable/exa): POST to https://google.serper.dev/search with the
X-API-KEY header, map the 'organic' results into the shared result format,
and fall back to DuckDuckGo when no key is configured.

- key resolved from config.api_key or SERPER_API_KEY env var
- 429 handled with a rate-limit message; other HTTP errors surfaced
- tests cover success, env-key, no-key fallback, HTTP error and rate limit
- docs: add Serper config example and list it in tools.web.search providers
2026-07-06 12:11:31 +08:00
chengyongru
72c8a47ac9 docs: update Windows exec shell gotcha
Maintainer edit: document that Windows exec defaults to PowerShell and cmd.exe syntax now requires shell='cmd'.
2026-07-06 12:11:25 +08:00
chengyongru
122cf4213b fix: harden Windows exec shell edge cases
Maintainer edit: preserve raw cmd.exe quoting for shell='cmd', propagate native exit codes through the default PowerShell path, and keep quoted Windows executable paths invokable under PowerShell.
2026-07-06 12:11:25 +08:00
chengyongru
8c4a74ee3c fix: honor explicit Windows cmd shell path
Maintainer edit: launch explicit cmd shells as cmd.exe /c so COMSPEC or absolute cmd.exe paths are actually used.
2026-07-06 12:11:25 +08:00
chengyongru
4ec111c1a9 docs: clarify exec shell override guidance
Maintainer edit: make the shell parameter description explain defaults and when to override instead of only listing shell names.
2026-07-06 12:11:25 +08:00
chengyongru
8222f3c85f docs: tailor exec shell guidance by platform
Maintainer edit: keep Windows PowerShell guidance out of Unix tool descriptions and cover the exposed schema text.
2026-07-06 12:11:25 +08:00
chengyongru
d76493a63a docs: clarify Windows exec shell syntax
Maintainer edit: document that Windows exec commands use PowerShell syntax by default and shell='cmd' is the cmd-specific escape hatch.
2026-07-06 12:11:25 +08:00
chengyongru
220de320fb fix: harden Windows exec shell default
Maintainer edit: prefer pwsh when available so single-line commands support modern PowerShell operators, add -NonInteractive, and parse explicit cmd.exe paths with Windows path semantics for cross-platform tests.
2026-07-06 12:11:25 +08:00
dajiaohuang
33b1c6f601 @
fix(exec): default Windows commands to PowerShell and allow shell parameter

Single-line commands on Windows were routed through cmd.exe
(asyncio.create_subprocess_shell) while multi-line commands used
PowerShell. This caused cross-drive cd failures, missing $VAR expansion,
and inconsistent behavior depending on whether a command contained a
newline. The shell parameter was also rejected on Windows.

- Route all Windows commands through PowerShell by default so single-line
  and multi-line commands share the same shell semantics.
- Allow the shell parameter on Windows: accepts powershell, pwsh, or cmd.
- cmd.exe remains reachable via shell="cmd" as an explicit escape hatch.
- Update tests to cover the new default and shell-parameter paths.

Fixes #4544
@
2026-07-06 12:11:25 +08:00
hamb1y
10b52cfb3a fix: resolve builtin skill reads 2026-07-06 12:11:20 +08:00
hamb1y
5d034dc79c fix: isolate matrix stream buffers 2026-07-06 12:11:14 +08:00
hamb1y
b8d7708171 fix: normalize text tool call markup 2026-07-06 12:11:08 +08:00
Xubin Ren
e725649146 test(webui): cover OAuth kit missing errors 2026-07-06 12:11:03 +08:00
axelray-dev
6a95877196 fix(providers): standardize oauth_cli_kit error messages across CLI and WebUI 2026-07-06 12:11:03 +08:00
Xubin Ren
83e4bae7db fix(gateway): skip ctrl-break wait when signal is rejected 2026-07-04 21:25:46 +08:00
xcao
58b5bb9204 fix(gateway): handle Windows stop fallback 2026-07-04 21:25:46 +08:00
chengyongru
c579551bb1 fix(dingtalk): stop stream task on shutdown 2026-07-04 21:19:25 +08:00
yorkhellen
8b645135bc fix(pairing): restore durable atomic writes 2026-07-04 21:19:19 +08:00
axelray-dev
28011413bc fix(copilot): guard token refresh with asyncio.Lock to prevent race condition
_get_copilot_access_token had a check-then-act race: concurrent chat()
calls after token expiry both fetched new tokens and clobbered each other.
Add asyncio.Lock with double-checked locking so only one fetch happens
per expiry window.

Closes #4677
2026-07-04 21:19:12 +08:00
chengyongru
614ea86a81 test: align MCP transient reconnect coverage
maintainer edit: update the existing transient retry tests for reconnect-first behavior and keep structured retry-failure coverage in the focused MCP transient suite.
2026-07-04 21:15:18 +08:00
chengyongru
6d28db3248 fix: reconnect MCP sessions on transient stream failures
maintainer edit: treat transient MCP stream failures as dead sessions so the existing reconnect handler can refresh the session before retrying. Also cover retry failure as a structured tool error.
2026-07-04 21:15:18 +08:00
Yuxin Lou
0d1221bece fix(mcp): contain malformed tool results 2026-07-04 21:15:18 +08:00
Xubin Ren
8b9f93d7d1 test(config): lock model presets alias serialization 2026-07-04 11:44:28 +08:00
Yuxin Lou
a119c35b1e fix(config): serialize model presets as camelCase 2026-07-04 11:44:28 +08:00
Xubin Ren
067e0c4a40
feat(cli): add safe WebUI first-run launcher (#4688) 2026-07-03 18:58:04 +08:00
chengyongru
5283ceae85
Add optional Nanobot plugin controls (#4396)
* feat: add optional nanobot features

* test: update azure install hint expectation

* fix: validate optional feature extras

maintainer edit: verify requested dependency extras before treating optional features as installed, propagate restart state from feature enablement, and align docs with the new plugins enable command.

* fix: bound optional feature installs

maintainer edit: make optional feature installs time out as a normal install failure instead of leaving the WebUI or CLI action waiting indefinitely.

* feat: slim optional channel dependencies

* fix: log optional install commands

* fix(webui): gate remote feature installs

* docs: clarify webhook plugin example

* fix(webui): harden optional feature installs

* fix: install optional deps without package fallback

* fix(cli): refine plugin feature controls

* fix(webui): count enabled nanobot features

* fix(webui): allow slow feature install routes

* fix(webui): allow disabling websocket channel

* fix(plugins): simplify optional feature controls

* fix(webui): polish apps catalog states

* fix(webui): confirm nanobot support installs

* fix(webui): polish nanobot install dialog

* fix(webui): suppress empty websocket handshakes

* fix(webui): clarify apps plugin summary

* fix(webui): localize workspace access copy

* fix(plugins): polish optional feature controls (#4691)

---------

Co-authored-by: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
2026-07-03 18:17:52 +08:00
Hamb_y
00cc0da530
fix(providers): omit temperature for sonnet 5
Add sonnet-5 to the Anthropic omit-temperature model families and cover adaptive, enabled, and non-thinking request paths.

Fixes #4683
2026-07-03 15:48:46 +08:00
LILAC
b19a744110
fix(providers): update Anthropic default model to claude-sonnet-4-6
Update the Anthropic provider default model and matching docs/tests from claude-sonnet-4-20250514 to claude-sonnet-4-6.

Fixes #4675
2026-07-03 15:46:19 +08:00
Xubin Ren
c9c69e4316 fix(memory): cap workspace Dream prompt overrides 2026-07-03 00:41:51 +08:00
chengyongru
8a79eb1aaa fix(memory): clarify Dream prompt init UX 2026-07-03 00:41:51 +08:00
chengyongru
979f038ded fix(channels): align Dream prompt Telegram copy 2026-07-03 00:41:51 +08:00
chengyongru
f38fd7d5d3 feat(memory): add workspace Dream prompt override 2026-07-03 00:41:51 +08:00
chengyongru
5af22042ec fix(trigger): keep local worker alive on turn cancel 2026-07-03 00:36:29 +08:00
chengyongru
aecb5fbc33 fix(agent): avoid tool compaction echo loops 2026-07-03 00:36:22 +08:00
chengyongru
34535b4e7c fix(webui): hide subagent backfill payloads 2026-07-02 14:36:23 +08:00
Stellar鱼
5abe06f808 test: cover runner blocked tool-call finish reasons 2026-07-02 14:36:18 +08:00
yu-xin-c
64c7ff5fdc test(cron): cover stale instance mutation consistency 2026-07-02 14:36:12 +08:00
chengyongru
54bcdb5a62 fix(exec): return early when session command exits 2026-07-02 14:36:06 +08:00
Xubin Ren
ffdf05a603 fix(trigger): cap local trigger audit records 2026-07-02 13:46:27 +08:00
Xubin Ren
fd9e57703c fix(trigger): tolerate unsupported directory fsync 2026-07-02 13:32:46 +08:00
chengyongru
661ab00656 feat(trigger): add local trigger run audit records 2026-07-02 13:32:46 +08:00
chengyongru
b941233138 fix(trigger): clean up deleted trigger deliveries 2026-07-02 13:32:46 +08:00
chengyongru
acb0e853ff refactor(trigger): share automation turn delivery 2026-07-02 13:32:46 +08:00
chengyongru
afef27dd6c fix(webui): show pending local triggers 2026-07-02 13:32:46 +08:00
chengyongru
f32007c83f fix(trigger): defer local triggers until session idle 2026-07-02 13:32:46 +08:00
chengyongru
09bde468eb fix(webui): narrow local trigger source label
maintainer edit: avoid optional source access after extracting automation source kind for TypeScript build.
2026-07-02 13:32:46 +08:00
chengyongru
2ebf5c4972 refactor(trigger): name CLI trigger source as local
maintainer edit: cron is also a trigger source, so keep the new CLI-delivered source explicitly named as local trigger across backend, WebUI, docs, and tests.
2026-07-02 13:32:46 +08:00
chengyongru
1ed2c9a213 fix(trigger): recover interrupted deliveries 2026-07-02 13:32:46 +08:00
chengyongru
55b550ee01 fix(trigger): hide external trigger inputs 2026-07-02 13:32:46 +08:00
chengyongru
b690a48336 fix(trigger): require names for trigger creation 2026-07-02 13:32:46 +08:00
chengyongru
7178ea3f13 docs: explain local triggers 2026-07-02 13:32:46 +08:00
chengyongru
2a0cd19a74 feat(trigger): add session-bound local triggers 2026-07-02 13:32:46 +08:00
Xubin Ren
c78421cf16 fix(bus): preserve legacy outbound metadata events 2026-07-01 20:17:00 +08:00
Xubin Ren
03be51ade5 fix(channels): preserve legacy stream hook signatures 2026-07-01 20:17:00 +08:00
chengyongru
c757c5466c docs: update channel plugin runtime event contract 2026-07-01 20:17:00 +08:00
chengyongru
5f4cfbcb16 refactor(bus): type outbound runtime events 2026-07-01 20:17:00 +08:00
chengyongru
f6d1dba32a fix(cron): tolerate unsupported directory fsync 2026-07-01 19:51:43 +08:00
chengyongru
2ec4044217 feat(webui): add dollar skill shortcuts
Add a WebUI-only $<skill> completion shortcut without changing slash command behavior.

Keep slash autocomplete command-only and allow dollar skill shortcuts anywhere in the composer.

Co-authored-by: Alan Chen <zc2610@nyu.edu>
2026-07-01 19:51:01 +08:00
Xubin Ren
a6d5e4f3b5 docs(api): document wildcard bind authentication 2026-07-01 13:09:49 +08:00
chengyongru
ed48325346 fix: cover API auth guard regressions
Maintainer edit: restore CI by updating serve/onboard tests, add auth/config coverage, and keep auth failures on the OpenAI-compatible error shape.
2026-07-01 13:09:49 +08:00
dajiaohuang
56443ac6e2 @
feat(api): require api_key when binding to all interfaces (parity with WS gateway)

The OpenAI-compatible API server had no authentication option, unlike the
WebSocket gateway which already refuses wildcard binds without a token.
When bound to 0.0.0.0, any caller who could reach the port could drive
the agent with its default tool posture.

- Add api_key field to ApiConfig (schema.py).
- Add wildcard_host_requires_auth validator that rejects wildcard binds
  without api_key, mirroring the WS gateway pattern.
- Add Bearer-token auth middleware to the API server (server.py).
  /health remains unauthenticated.
- Replace the wildcard-host CLI warning with a hard error when api_key
  is unset, and pass api_key to create_app.

Fixes #4490
@
2026-07-01 13:09:49 +08:00
chengyongru
21aa900d64 fix: honor MCP tool error results 2026-07-01 13:03:47 +08:00
chengyongru
b0258e8b20 fix: preserve legacy plugin tool errors 2026-07-01 13:03:47 +08:00
chengyongru
8493560976 refactor(tools): use structured tool error results 2026-07-01 13:03:47 +08:00
chengyongru
8d2c31eb6a refactor(webui): derive provider model catalog kind 2026-07-01 12:59:06 +08:00
chengyongru
a6a489e0fa refactor: tighten session recency cleanup
maintainer edit: remove defensive branches that normal session storage cannot produce and keep the idle-expiry helper direct.
2026-06-30 23:38:32 +08:00
chengyongru
840ba5af33 fix: simplify session recency activity tracking
maintainer edit: remove the _last_compacted_at maintenance state, gate idle compaction on whether a session still has a removable tail, and sort WebUI sessions by the latest visible transcript activity.
2026-06-30 23:38:32 +08:00
chengyongru
3403b87641 fix(webui): keep idle compaction out of session recency 2026-06-30 23:38:32 +08:00
hamb1y
bfbae5a7b3 fix(cli): refresh oauth provider default models 2026-06-30 23:02:42 +08:00
hamb1y
58cce14a07 fix(cli): allow oauth login to set main provider 2026-06-30 23:02:42 +08:00
Xubin Ren
f9b02496c8 fix(mcp): redact URL paths in logs 2026-06-30 22:43:12 +08:00
Xubin Ren
bfc2a74e4f fix(mcp): preserve IPv6 brackets when redacting URLs 2026-06-30 22:43:12 +08:00
xiaweiwei67-stack
780093d037 fix(mcp): redact credentials from URLs before logging
MCP server URLs can carry secrets in userinfo
(`https://user:token@host/sse`) or a query string (`?token=...`). A few
connect/validate paths logged the raw `cfg.url` / `request.url`, so those
secrets could land in log files that are often shared or aggregated.

Add a small `_redact_url()` helper that keeps only scheme/host/port/path
and use it at the four sites that log a server or request URL. Logging
only; no other behavior changes.
2026-06-30 22:43:12 +08:00
Xubin Ren
1873e948c3 test(weixin): cover streamed reply retry buffer 2026-06-30 22:43:03 +08:00
wangjunwei
735a243849 fix(weixin): keep stream buffer until send succeeds so retries can re-deliver
send_delta popped the buffer before self.send ran, so a transient WeChat
send failure dropped the completed streamed reply: ChannelManager
_send_with_retry re-invokes the same _stream_end message, but the buffer
was already gone, so the retry sent empty content and returned — turning a
delivery retry into silent message loss.

Build `full` from the buffer without popping, send, then clear only after a
successful send. The _stream_end message's own content (set when the manager
coalesces deltas into the end message) is folded into `full` via addition
rather than appended to the buffer, so a retry recomputes the same `full`
from an unchanged buffer instead of double-counting it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:43:03 +08:00
wangjunwei
edada598c8 fix(weixin): stream LLM calls + buffer reply delivery to dodge non-stream relay bug
WeixinConfig lacked a streaming field, so channels.weixin.streaming was
silently dropped by pydantic and supports_streaming stayed False, forcing the
non-streaming Messages API. Some upstream Anthropic relays drop tool_use
id/name/input on the non-stream path (but handle SSE fine), breaking WeChat
tool calls.

Two parts:
1. Add a streaming field (default True) so WeChat routes LLM calls through the
   streaming API. WeChat iLink has no native incremental delivery, so this is
   user-invisible — it only changes how the LLM is called.
2. WeChat send_delta previously dropped content, and the manager bypasses send
   for the _streamed final answer, so a streamed reply never reached the user.
   send_delta now buffers content deltas and flushes the full reply in one shot
   at _stream_end (also stopping the typing indicator via send).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 22:43:03 +08:00
yu-xin-c
2527ce5de9 test(exec): cover bwrap sandbox mounts 2026-06-30 22:34:43 +08:00
chengyongru
44a5ed1bc0 feat(providers): support provider-scoped proxy config 2026-06-30 17:33:36 +08:00
chengyongru
4c0e9b9f46 test: cover WhatsApp read receipts
maintainer edit: add focused coverage for the new best-effort mark_read path and remove an unused helper argument.
2026-06-30 15:21:25 +08:00
franciscomaestre
839d1ecfb1 feat(whatsapp): send read receipts (blue double-check) for incoming messages
Mark each processed incoming WhatsApp message as read via neonize's
mark_read(receipt=ReceiptType.READ), so senders see the blue double-check.

The receipt is sent right after the message passes dedup and is best-effort:
any failure is logged at debug level and swallowed, so it never blocks or
breaks message handling.
2026-06-30 15:21:25 +08:00
chengyongru
593b328dbb test: cover Copilot enterprise overrides
Maintainer edit: add mocked coverage for the enterprise endpoint and client ID override paths, and document the environment variables users must set before OAuth login.
2026-06-30 15:21:19 +08:00
04cb
4beca25ceb feat(providers): allow GitHub Copilot endpoint overrides for enterprise/GHE (#4220) 2026-06-30 15:21:19 +08:00
chengyongru
82ffce1474 docs: move restart mode docs to gateway config 2026-06-30 15:21:14 +08:00
chengyongru
4726ca0478 fix(restart): add explicit restart mode 2026-06-30 15:21:14 +08:00
chengyongru
d979597361 fix(install): skip wizard without an interactive terminal 2026-06-30 15:21:08 +08:00
axelray-dev
070aed8ade fix(streaming): skip non-file-edit tools in apply_final_call_ids to prevent id corruption
apply_final_call_ids iterated over all final tool calls, including
non-file-edit tools like read_file. The greedy path-match in
matches_final_tool_call could overwrite a correct unique id with a
stale one from a different streaming state, producing duplicate
tool_use ids that poison the persisted session.

Guard the loop with is_file_edit_tool() so only tracked file-edit
tools (write_file, edit_file, apply_patch) are subject to canonical
id remapping. Non-file-edit tools keep their authoritative id from
get_final_message().

Fixes #4595
2026-06-30 15:21:02 +08:00
Xubin Ren
8df100203c
feat(webui): refine prompt rail minimap 2026-06-30 10:01:53 +08:00
axelray-dev
8fa9eed6a8 refactor(session): trim RetentionResult to only fields callers read
Remove retained and new_last_consolidated from RetentionResult.
Both were populated but never read by any caller. The authoritative
state remains self.messages and self.last_consolidated, which the
method mutates in place. Update docstring accordingly.
2026-06-29 14:24:00 +08:00
axelray-dev
5692f7a68a refactor(session): return RetentionResult instead of bare tuple
Replace the tuple(list[dict], int) return of
Session.retain_recent_legal_suffix with a named RetentionResult
dataclass that exposes retained, dropped,
already_consolidated_count, and new_last_consolidated fields.

The tuple return was easy to misuse because the second value only
made sense relative to the first and the old last_consolidated
cursor. The named fields make the archive-skip semantics explicit
at every call site.

No behavior change. All existing tests pass unchanged in semantics.

Refs #4136

Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>
2026-06-29 14:24:00 +08:00
chengyongru
57f0c859fc refactor(context): trim replay cap plumbing 2026-06-29 14:23:55 +08:00
chengyongru
40282e3b74 fix(context): scale replay cap with context window 2026-06-29 14:23:55 +08:00
chengyongru
dacc699293 fix(config): retire max messages setting 2026-06-29 14:23:55 +08:00
chengyongru
c8638dee46 fix(context): raise max messages fallback cap
Treat max_messages as a last-resort replay guard now that consolidation and idle auto-compact own normal history reduction. Raising the default avoids frequent sliding-window prefix churn in moderate conversations without adding a new cache-policy knob.
2026-06-29 14:23:55 +08:00
Xubin Ren
7dc45ff94e chore: tighten malformed tool-call guard wording 2026-06-28 19:46:59 +08:00
wangjunwei
8248d075db fix(agent): harden tool-call handling against malformed upstream relays
Combine malformed tool-call handling with placeholder filtering and a
no-tools fallback so a relay that returns tool_use blocks with null
id/name/input can no longer crash a turn or permanently wedge a session.

Adapted to the ContextGovernor architecture (context governance now lives
in nanobot/agent/context_governance.py, not runner.py):

- ToolCallRequest.has_valid_name(): single source of truth for "usable
  name" (non-empty string).
- tool_hints.format_tool_hints(): skip tool calls with a non-string/empty
  name instead of raising AttributeError on the whole turn.
- ContextGovernor.strip_placeholder_assistant_messages() and
  strip_malformed_tool_calls() (plus the _tool_call_name_is_valid helper):
  history-cleaning staticmethods invoked at the START of
  prepare_for_model() — strip_placeholder, then strip_malformed, then the
  existing drop_orphan/backfill chain. Both only repair the model-facing
  copy and leave persisted history untouched (return a copy, or the same
  list when nothing changes). Also wired into runner's minimal-repair path.
- AgentRunner._drop_malformed_tool_calls(): returns
  (dropped, all_dropped, original_finish_reason); clears finish_reason to
  "stop" when all calls are dropped.
- AgentRunner._malformed_tool_call_retry_messages() + _request_model
  malformed_retry flag: when an all-dropped tool_calls response comes back,
  retry once with a corrective note; if the retry STILL comes back
  all-dropped, fall back to _request_no_tools for graceful text degradation.

Tests for the history-cleaning methods live with ContextGovernor in
tests/agent/test_runner_governance.py; response-layer and tool-hint tests
stay on AgentRunner / tool_hints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:46:59 +08:00
Xubin Ren
d7152cdbdd style: format legacy session repair test 2026-06-28 19:46:55 +08:00
axelray-dev
89dc34df88 fix(session): repair corrupt legacy-stem files in list_sessions
list_sessions() silently dropped corrupt session files whose filename
stem was a legacy non-base64 name (e.g. telegram_12345.jsonl from the
old lossy path scheme). The repair path called _repair(fallback_key),
but _repair re-encodes the key via _storage_key(), producing a
different base64 filename that never matches the actual file on disk.

Add an optional path parameter to _repair so callers can pass the
actual file path directly, bypassing the key-to-filename round trip.

Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>
2026-06-28 19:46:55 +08:00
codedragon
67ce6822ca feat(mcp): deliver image content from MCP tools as artifacts
MCPToolWrapper.execute only handled TextContent; every other block was
rendered with str(block). An MCP ImageContent block therefore became a
large base64 string embedded in the tool result, which (a) was truncated
by max_tool_result_chars, corrupting the data, and (b) could never reach a
channel because it was plain text, not an image artifact.

Decode ImageContent (and EmbeddedResource blobs with an image/* MIME type)
and persist them via store_generated_image_artifact, returning the same
compact {artifacts, next_step} JSON the built-in image_generation tool
produces. The base64 stays out of the model context; the model delivers the
saved file via the message tool's media parameter.
2026-06-28 19:46:51 +08:00
axelray-dev
194e9d5f5f fix(webui): clear stale run status on reconnect 2026-06-28 19:46:47 +08:00
axelray-dev
5005bca353 fix(webui): clear stuck streaming after reconnect and improve stop reliability
After a gateway restart or websocket reconnect, the UI stays stuck in
processing state because reconnecting clients only replay running status
when a turn is active, never push idle when no turn is running.

Fix _hydrate_after_subscribe to always push goal_status (running with
started_at when turn is active, idle when no turn is running) so the
frontend can reset its processing indicator on reconnect.

Also fix cmd_stop reporting 'No active task to stop' when a task is
actually processing by draining the pending injection queue in addition
to cancelling active tasks. This prevents mid-turn injection deadlocks
and gives accurate task counts.
2026-06-28 19:46:47 +08:00
yorkhellen
e5dbb15c34 fix(cron): guard public APIs against unavailable store 2026-06-28 19:46:44 +08:00
Xubin Ren
c90e433057 fix(session): guard lossy migration by stored key 2026-06-27 16:52:54 +08:00
axelray-dev
3ce77633c0 fix(session): add _decode_storage_key for corrupt-file repair in list_sessions 2026-06-27 16:52:54 +08:00
axelray-dev
00a907c493 fix(session): split safe_key and _storage_key to fix WebUI coupling (#4533) 2026-06-27 16:52:54 +08:00
axelray-dev
cf2f589615 fix(session): prevent save from writing to legacy lossy path, add collision tests (#4533) 2026-06-27 16:52:54 +08:00
axelray-dev
463f536750 fix: prevent session key collision on disk (#4057)
safe_key() replaces ':' with '_', causing collisions between distinct
keys (e.g. telegram:a_b vs telegram🅰️b both become telegram_a_b).

Use base64url (no padding) for collision-resistant encoding while
maintaining backward compatibility: _get_session_path and
_get_legacy_session_path check the new path first, then fall back
to the old lossy encoding for existing session files.
2026-06-27 16:52:54 +08:00
Xubin Ren
00a7de0171 fix: stringify Anthropic typeless blocks as JSON 2026-06-27 16:47:59 +08:00
axelray-dev
efb792ff24 fix: validate content block type in Anthropic assistant blocks (#4060)
_assistant_blocks appends dict items from content lists directly
without checking for the required 'type' field. A block like
{'text': 'hi'} reaches the Anthropic payload without a 'type',
causing a 400 rejection.

Add the same missing-type check that _convert_user_content already has,
so bare dicts in assistant content lists are coerced to text blocks
instead of triggering API validation errors.

Co-authored-by: nanobot-issues <issues@nanobot.dev>
2026-06-27 16:47:59 +08:00
Xubin Ren
d8601478db test: cover stream-id delta coalescing 2026-06-27 16:47:53 +08:00
axelray-dev
66fc54421c fix: include _stream_id in stream delta coalescing key (#4063)
ChannelManager coalesces _stream_delta messages by (channel, chat_id)
only. Overlapping streams in the same chat can be merged incorrectly
because deltas from distinct _stream_id values share one buffer.

Include _stream_id in the coalescing key so distinct streams in the
same channel and chat are delivered separately.
2026-06-27 16:47:53 +08:00
Xubin Ren
6a27c26257 test: cover non-stream duplicate tool call ids 2026-06-27 16:47:48 +08:00
axelray-dev
3ca82ea880 fix: deduplicate tool call IDs in non-stream parser (#4059)
Duplicate tool call ID normalization exists in the streaming parser path
but is not shared with the non-stream parser. Non-stream parsing appends
raw provider IDs into ToolCallRequest objects without deduplication.

Some OpenAI-compatible providers reuse the same tool_call_id for parallel
tool calls in non-streaming responses. Without dedup, runner executes
both tools with the same ID, producing duplicate tool results with the
same tool_call_id, which can fail strict provider validation.

Add the same _seen_tc_ids dedup pattern used in _parse_chunks to the
_parse method so both paths handle duplicate IDs consistently.
2026-06-27 16:47:48 +08:00
r4sk1n
47dcc61e9b test(agent): fix flaky test_keeps_n_most_recent by ensuring sequential mtimes 2026-06-27 16:29:31 +08:00
axelray-dev
2bf111f456 fix(exec): remove ad-hoc shell comment stripping from _guard_command
- Removes match_text regex that stripped # comments before pattern matching
(broke on quoted # inside strings)
- allow_patterns now run re.fullmatch against the full lowercased command
- deny_patterns search the original lowercased command
- Replaces comment-stripping test with comment-tail bypass regression
(touch canary # echo allowlisted must be blocked)
- Adds Re-bin regression for quoted hash + blocked command
(echo "#" followed by blocked command must be caught)
- All 10 tests pass

Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>
2026-06-27 11:11:46 +08:00
axelray-dev
aa6c1bf300 fix(exec): prevent allowPatterns bypass via chained commands and shell comments 2026-06-27 11:11:46 +08:00
chengyongru
5281e67222 fix(docker): repair whatsapp image build 2026-06-27 11:05:03 +08:00
chengyongru
dbb53109f4 build(docker): remove node from runtime image 2026-06-27 11:05:03 +08:00
chengyongru
b015515f30 docs(security): remove whatsapp bridge wording 2026-06-27 11:05:03 +08:00
chengyongru
be88e14424 docs(security): trim whatsapp migration note 2026-06-27 11:05:03 +08:00
chengyongru
9e490ef473 docs(readme): restore changelog wording 2026-06-27 11:05:03 +08:00
chengyongru
bfb4246659 fix(docker): limit whatsapp bridge removal 2026-06-27 11:05:03 +08:00
chengyongru
fbf96a3502 fix(whatsapp): add bridge migration compatibility 2026-06-27 11:05:03 +08:00
chengyongru
2a9e288dfe refactor(whatsapp): replace bridge with neonize 2026-06-27 11:05:03 +08:00
chengyongru
3460ca3cb9 fix(agent): gate microcompaction on context pressure
Extract model-facing context governance from AgentRunner.

Only compact in-flight tool results when the model request is over budget, keep compacted IDs stable within a turn, and allow the newest result to be compacted as a last resort when it is the remaining source of overflow.
2026-06-27 11:04:41 +08:00
chengyongru
9b45fc1172 fix(session): remove message time replay prefixes 2026-06-27 11:04:36 +08:00
chengyongru
8656549129 test: cover exec login default public path
maintainer edit: add a regression test for the public ExecTool.execute path so omitted login stays non-login by default, and update the Unix environment docstring to match the new explicit login behavior.
2026-06-27 11:04:31 +08:00
axelray-dev
4c1f127549 test: update login-shell assertion for new default=False 2026-06-27 11:04:31 +08:00
axelray-dev
13c951aa41 fix: change exec login-shell default from true to false (#4518)
The exec tool defaults login=True for bash/zsh, which causes the shell
to source ~/.bash_profile and similar startup files. This reintroduces
secrets from shell startup files into the exec environment, even though
_build_env() intentionally starts with a curated environment.

Change the default to login=False in both _prepare_command() and _spawn(),
and update the schema default accordingly.
2026-06-27 11:04:31 +08:00
chengyongru
cd1fb61eb5 ci: relax webui install lock check 2026-06-27 11:04:11 +08:00
chengyongru
e79cb816e3 ci: pin bun for webui job 2026-06-27 11:04:11 +08:00
chengyongru
64901be67f test: harden webui and gateway checks 2026-06-27 11:04:11 +08:00
chengyongru
9ce9d2235a docs: clarify heartbeat versus cron delivery 2026-06-27 11:04:08 +08:00
Xubin Ren
06d5495b60 docs: document subagent tool error behavior 2026-06-25 22:53:36 +08:00
axelray-dev
851a0ff50c fix: make subagent fail_on_tool_error configurable (#4198)
Add fail_on_tool_error to AgentDefaults and wire it through
AgentLoop -> SubagentManager -> AgentRunSpec.

Previously hardcoded to True in SubagentManager._run_subagent.
Now configurable via config.json with default True for backward
compatibility. When set to False, subagents can retry on minor
tool errors instead of immediately failing.

Changes:
- nanobot/config/schema.py: add fail_on_tool_error field (default True)
- nanobot/agent/subagent.py: accept and forward fail_on_tool_error
- nanobot/agent/loop.py: pass config through to SubagentManager
- tests/agent/test_subagent.py: add regression test

Signed-off-by: axelray-dev <110029405+axelray-dev@users.noreply.github.com>
2026-06-25 22:53:36 +08:00
Xubin Ren
3596ccf828 docs: explain custom provider thinking style 2026-06-25 22:53:15 +08:00
axelray-dev
d1ae73a8a8 fix: add clear error message for invalid thinking_style values
Widen thinking_style from Literal to str | None and add a
@field_validator that produces a helpful error message listing
valid options when an invalid value is provided.

Addresses the review feedback on #4482.
2026-06-25 22:53:15 +08:00
axelray-dev
c661012754 fix: coalesce None thinking_style to empty string in provider creation
ProviderConfig.thinking_style defaults to None (Optional field), but
create_dynamic_spec expects a string. Coalesce None to "" at all call
sites (factory.py, settings_api.py) and fix the test assertion to
expect None from the config default.
2026-06-25 22:53:15 +08:00
axelray-dev
0e19ea3062 fix: validate thinking_style against known values at config load time 2026-06-25 22:53:15 +08:00
axelray-dev
ceae6d7b61 fix: allow custom provider to configure thinking style (#4429) 2026-06-25 22:53:15 +08:00
Xubin Ren
34f776b48b test(cli): lock disabled dream cursor advancement 2026-06-25 22:53:10 +08:00
axelray-dev
f7b027a295 fix: only advance dream cursor when behind latest (#4242)
Address review: avoid overwriting cursor on every restart when Dream
is disabled. Now only advances if current cursor is behind the latest
position, so repeated restarts don't permanently skip entries.
2026-06-25 22:53:10 +08:00
axelray-dev
6c880a6691 fix: advance dream cursor when Dream is disabled to prevent prompt bloat (#4242)
When dream.enabled is false, the Dream cron job never runs, so the
dream cursor (.dream_cursor) stays at its initial value (0). This
causes read_recent_history_for_prompt() to treat every history entry
as unprocessed, injecting the full chat history into every system
prompt and growing without bound.

Fix: fast-forward the dream cursor to the latest history entry at
gateway startup when Dream is disabled.
2026-06-25 22:53:10 +08:00
Xubin Ren
4636c78100 test(webui): cover xiaomi mimo wav recording path 2026-06-25 22:52:55 +08:00
zpljd258
28c8c89a42 fix(webui): convert WebM to WAV for Xiaomi MiMo ASR transcription
MiMo ASR (mimo-v2.5-asr) only accepts audio/wav, audio/mp3, and
audio/mpeg formats. Web browsers record in WebM/Opus by default,
causing the API to reject the payload with a transcription error.

This change adds a frontend WebM→WAV converter using the Web Audio API
(DecodeAudioData + PCM encoding) that activates only when the
configured transcription provider is 'xiaomi_mimo'. Other providers
are unaffected — they continue to receive the original browser format.

Tested and confirmed working on WebUI.
2026-06-25 22:52:55 +08:00
Ilya Gusev
7899857201 refactor(cli): simplify onboard search-provider dispatch
- Extract _set_field_from_choices for the shared pick-and-set tail used by
  both the LLM and search provider handlers.
- Replace the single-entry _TYPED_FIELD_HANDLERS registry with a direct
  isinstance check in _resolve_field_handler (robust to renames, no
  class-name strings).
- Drop a redundant str() in the search default computation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:52:50 +08:00
Ilya Gusev
9354b80a6e fix(cli): show search engines (incl. Keenable) in onboard wizard
The onboard wizard dispatched field handlers by bare field name, so
WebSearchConfig.provider was hijacked by the LLM-provider handler and
showed LLM providers instead of search engines. Keenable was also never
wired into the CLI wizard when it landed in the WebUI.

- Add a single source of truth for selectable search providers
  (SEARCH_PROVIDER_OPTIONS in web.py); WebUI settings now import it.
- Add a WebSearchConfig-aware search-provider picker to the wizard and
  resolve handlers by (model type, field name) so the LLM and search
  provider fields no longer collide.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 22:52:50 +08:00
Max Hsu
638af123ba fix(dingtalk): preserve richText formatting and set HTTP client timeout
richText messages only kept rich_text_list items whose type was
"text", so bold/italic/inlineCode/pre segments were silently dropped;
a message made entirely of formatted segments produced empty content
and fell through to the "unsupported message type: richText" warning.
Now any item carrying text is kept (matching the SDK's own
get_text_list, which keys off the "text" field) and its type is mapped
to Markdown so the formatting survives into the agent's context.

Text and downloadCode within a rich-text item are handled
independently (the SDK treats them as separate via get_text_list /
get_image_list), so an item carrying both a caption and an attachment
no longer drops the file.

The shared httpx.AsyncClient was created without a timeout, so all
requests (including large file/image downloads) used httpx's 5s
default and hit ConnectTimeout/ReadTimeout on uploads. Set an explicit
httpx.Timeout (connect=10s, read/write=30s).

Adds regression tests for formatted-segment preservation, the
all-formatted no-drop case, the text+downloadCode item, and the client
timeout configuration.

Closes #4497
2026-06-25 22:52:45 +08:00
michaelxer
42aa37cfc0 docs: update enabledTools docs and schema comment to reflect resource/prompt gating
The enabledTools gate now also controls MCP resource and prompt
registration (not just tools). Update the configuration docs and
schema field comment to document this behavioral change.

Refs: #4435, #4436
2026-06-25 16:10:37 +08:00
michaelxer
03302c751f log info when resources/prompts skipped due to enabledTools gate
Address chengyongru review: bump skip message from silent to logger.info
so operators get a visible trace when resources/prompts are not registered.
2026-06-25 16:10:37 +08:00
michaelxer
246ea8ef61 fix(tools): gate MCP resource and prompt registration behind enabledTools
The enabledTools allowlist was only enforced for MCP tools returned by
session.list_tools(). Resources and prompts from session.list_resources()
and session.list_prompts() were registered unconditionally, allowing a
deny-all or restrictive enabledTools config to leak resource and prompt
capabilities to the model.

Now resources and prompts are only registered when allow_all_tools is
true (default ["*"] wildcard). Any explicit tool restriction — including
enabledTools: [] (deny-all) or a list of specific tool names — also
blocks resource and prompt registration from that server.

Fixes #4435
2026-06-25 16:10:37 +08:00
chengyongru
f60b3c7920 docs: explain Telegram rich messages opt-in
maintainer edit: document that richMessages defaults to false, when to enable it, and why Telegram Web users should leave it disabled.
2026-06-25 16:10:33 +08:00
chengyongru
e92899607a fix: make Telegram rich messages opt in
maintainer edit: Telegram Web cannot render sendRichMessage payloads, so keep the rich path available only for explicit opt-in instead of enabling it by default.
2026-06-25 16:10:33 +08:00
axelray-dev
c930aa3713 fix: add rich_messages config to disable sendRichMessage for Telegram Web (#4488) 2026-06-25 16:10:33 +08:00
chengyongru
4378944459 test: speed up test suite 2026-06-25 16:10:28 +08:00
chengyongru
123384975e fix(webui): restore code block copy fallback 2026-06-25 16:10:23 +08:00
chengyongru
943191f0c0 fix(webui): keep multi-file apply_patch edits 2026-06-24 20:04:39 +08:00
Xubin Ren
c915e98c15 test: cover archived heartbeat target selection 2026-06-24 15:45:49 +08:00
Heng Wei Bin
de4009efbd fix: exclude archived keys in heartbeat & fallback missing session timestamps 2026-06-24 15:45:49 +08:00
hyoukadev
9c6eaf0bed test: deduplicate proxy value and construct tool via constructor
- Use a local variable for the proxy URL instead of hardcoding it twice
- Pass proxy through the WebSearchTool constructor instead of mutating
  after instantiation (matches real usage path)
- Add assertion that timeout is still forwarded correctly
- Use generic mock data instead of test-specific strings
2026-06-24 15:45:44 +08:00
hyoukadev
c66a0217d2 fix(web): pass proxy to DDGS client
The DuckDuckGo search provider instantiated DDGS(timeout=10) without
passing the configured proxy, making web_search unusable in environments
that require a proxy (e.g. behind GFW). DDGS supports a proxy parameter
and the proxy value is already available as self.proxy — it was simply
not forwarded.

Add a test verifying the proxy kwarg is forwarded to DDGS.
2026-06-24 15:45:44 +08:00
yorkhellen
319791cd10 fix(config): preserve dream cron when saving config 2026-06-24 15:45:39 +08:00
chengyongru
a584ffe92d refactor: trim thinking tag helper setup
maintainer edit: remove unused self-closing tag derivation and duplicate reasoning partial cleanup after ponytail review.
2026-06-24 15:45:34 +08:00
chengyongru
1e22932313 refactor: centralize thinking tag patterns
maintainer edit: derive thinking tag regexes and streaming partial prefixes from one tag list so future aliases only need one entry while preserving legacy self-closing think/thought behavior.
2026-06-24 15:45:34 +08:00
chengyongru
98dd883ce8 fix: buffer split reasoning wrapper deltas
maintainer edit: native reasoning streams can split <thinking> wrapper tags across chunks. Buffer the stream and emit only cleaned incremental reasoning so raw partial tags do not reach WebUI.
2026-06-24 15:45:34 +08:00
Zhou
35bd1be109 fix: ignore non-string reasoning wrappers 2026-06-24 15:45:34 +08:00
Zhou
596bf5398c test: cover empty thinking marker streaming 2026-06-24 15:45:34 +08:00
Zhou
523bb928bf fix: normalize thinking tags in reasoning output 2026-06-24 15:45:34 +08:00
Xubin Ren
f9afc9389b fix(providers): apply Kimi Coding default headers 2026-06-24 10:43:16 +08:00
chengyongru
9d6c606cc9 docs: document kimi coding provider
Maintainer edit: add the provider reference row and a pasteable cookbook recipe so users know to select kimi_coding and set the required User-Agent header.
2026-06-24 10:43:16 +08:00
NanoBot
44817b75c6 feat(provider): add kimi_coding provider for Kimi Coding Plan
Add a dedicated provider entry for the Kimi Coding Plan endpoint
(api.kimi.com/coding) using the Anthropic Messages API transport.

- Register kimi_coding with backend=anthropic
- Use KIMI_CODING_API_KEY env key to avoid clashing with MOONSHOT_API_KEY
- Default api_base set to https://api.kimi.com/coding/v1 so that
  AnthropicProvider._normalize_base_url() + the SDK produce the correct
  /coding/v1/messages request path
- Keywords include kimi-coding, kimi_coding and kimi-for-coding

Closes HKUDS/nanobot#4463
2026-06-24 10:43:16 +08:00
chengyongru
c55f7ec5bb style: trim pairing sender-id comments
maintainer edit: remove redundant explanatory comments from the focused sender-id normalization tests and store change without changing behavior.
2026-06-24 10:29:58 +08:00
w.antar
d7f868b832 fix(pairing): also coerce sender_id in approve_code() 2026-06-24 10:29:58 +08:00
w.antar
d481d5fb1a fix(pairing): normalize sender IDs to str in the pairing store 2026-06-24 10:29:58 +08:00
chengyongru
bc1df49201 fix(gateway): handle lifecycle edge cases 2026-06-24 10:29:08 +08:00
chengyongru
7826f8f89c docs: document runtime environment variables 2026-06-24 10:28:15 +08:00
chengyongru
b14f82f408 fix(webui): prevent iOS Safari composer zoom 2026-06-24 10:26:14 +08:00
chengyongru
6d989de336 docs: document OpenCode provider setup
Maintainer edit: document OpenCode Zen and Go configuration, keep their registry entries with gateway providers, and add focused provider registration tests.
2026-06-24 10:25:12 +08:00
zpljd258
ddad6c5a7c feat(providers): add OpenCode Zen and OpenCode Go providers 2026-06-24 10:25:12 +08:00
David Jimenez
4b1decdb95 chore: bump to node 24 2026-06-24 10:23:07 +08:00
chengyongru
23dc253f89 refactor: simplify Anthropic tool id remapping
maintainer edit: remove duplicate counter state and use the seen id set directly when choosing duplicate suffixes.
2026-06-24 10:21:48 +08:00
chengyongru
0e9861558a fix: keep duplicate id repair in Anthropic provider
maintainer edit: move duplicate tool_use history repair out of AgentRunner and into Anthropic message conversion, reusing the OpenAI-compatible queue-mapping approach locally without broadening the shared runner path.
2026-06-24 10:21:48 +08:00
chengyongru
853aecdb97 fix: preserve duplicate-id tool calls
maintainer edit: remap duplicate tool_use/tool_call ids instead of dropping later calls, so Anthropic-compatible providers that reuse ids for distinct parallel tool calls keep all requested work while still sending unique ids.
2026-06-24 10:21:48 +08:00
Teddy Yan
6b8e832ba5 fix: address PR review comments - rename _dedup to _dedupe and fix ID storage consistency 2026-06-24 10:21:48 +08:00
Teddy Yan
6689e2d377 fix(providers): dedupe tool_use ids to prevent Anthropic 400s
Anthropic rejects any request where two tool_use blocks share an id
("messages.N.content.M: tool_use ids must be unique"). A mis-assembled
stream could surface the same tool_use block twice in one assistant turn;
the runner persisted it verbatim, so the malformed message was re-sent on
every subsequent turn and permanently bricked the session — the agent
silently stopped replying.

Fix at two layers:
- AnthropicProvider._parse_response: drop duplicate tool_use ids (keep
  first) as the response enters nanobot, so corruption is never persisted.
- AgentRunner._dedup_tool_calls: a new context-governance pass that dedupes
  assistant tool_calls and tool results by id before each send, healing any
  history that was already corrupted.

Add regression tests covering both the dedup and the no-op fast path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:21:48 +08:00
axelray-dev
160cec2396 fix: skip sendRichMessage when streaming preview exists (#4470)
When _stream_end fires and a streaming preview already exists, the
sendRichMessage path deletes the preview and sends a fresh message.
This causes line break loss and visible flickering. Gate the rich
path on not buf.message_id so existing previews use the legacy
edit_message_text path instead.
2026-06-24 10:19:14 +08:00
Xubin Ren
d2da6df14e docs: align release news dates 2026-06-23 09:50:44 +08:00
Xubin Ren
701ae5563d docs: add v0.2.2 release news 2026-06-23 09:47:23 +08:00
1019 changed files with 147896 additions and 27264 deletions

View File

@ -24,6 +24,14 @@ Fix bugs by changing only what is necessary. Do not bundle unrelated refactors o
A bugfix should make the protected invariant clear, change the smallest surface that enforces it, and add only the closest regression test. If a diff starts changing ownership boundaries or mixing behavior changes with clean-up, split it before it becomes hard to review. A bugfix should make the protected invariant clear, change the smallest surface that enforces it, and add only the closest regression test. If a diff starts changing ownership boundaries or mixing behavior changes with clean-up, split it before it becomes hard to review.
## Type dynamic boundaries at the edge
Wire payloads, persisted records, and third-party SDK objects are untrusted dynamic boundaries. Prefer a parser or small normalizer at the owning edge, and use `TypedDict` for stable dictionary shapes, so validation happens once and internal code receives a concrete type. Do not spread raw dynamic dictionaries or SDK objects through the core.
Stable first-party dependencies must be typed where they are stored or passed. Do not declare an internal service, context field, or callback result as `Any` and then recover its real type with consumer-side casts. Use the concrete type or a narrow `Protocol`; reserve `Any` for genuinely dynamic boundaries.
`typing.cast` performs no runtime validation. Every new cast must be supported by a runtime check on the same path or by an explicit invariant that is clear from construction and control flow (and documented locally when it is not obvious). If input can violate the claimed type, handle that invalid case before casting; never use `cast` only to silence BasedPyright.
## Explicit over magical ## Explicit over magical
Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class. Configuration must be declared explicitly in `config/schema.py` Pydantic models. Error handling should raise clear exceptions rather than silently correcting bad input. Provider auto-detection exists, but every resolution path must be traceable from the factory to the concrete provider class.

View File

@ -16,7 +16,7 @@ Example valid usage:
## Windows Compatibility ## Windows Compatibility
nanobot explicitly supports Windows. Key differences to keep in mind: nanobot explicitly supports Windows. Key differences to keep in mind:
- `ExecTool` uses `cmd /c` on Windows instead of `sh -c` (`shell.py`). - `ExecTool` defaults to PowerShell on Windows (`pwsh` when available, otherwise Windows PowerShell); pass `shell="cmd"` for cmd.exe syntax or cmd built-ins (`shell.py`).
- `cli/commands.py` forces `sys.stdout`/`stderr` to UTF-8 on startup to handle emoji and multilingual input. - `cli/commands.py` forces `sys.stdout`/`stderr` to UTF-8 on startup to handle emoji and multilingual input.
- MCP stdio server commands are normalized for Windows path separators (`mcp.py`). - MCP stdio server commands are normalized for Windows path separators (`mcp.py`).
- Always use `pathlib.Path` for path manipulation; do not assume `/` separators. - Always use `pathlib.Path` for path manipulation; do not assume `/` separators.

View File

@ -14,9 +14,9 @@ Shell execution (`ExecTool`, `agent/tools/shell.py`) also respects `restrict_to_
## SSRF Protection ## SSRF Protection
All outbound HTTP requests from agent tools must pass through `validate_url_target` (`security/network.py`). By default it blocks loopback, RFC1918 private addresses, CGNAT ranges, link-local ranges, and cloud metadata endpoints (including `169.254.169.254`). All outbound HTTP requests from agent tools must pass through the shared URL guards in `security/network.py` (`validate_url_target` or `resolve_url_target`). By default they block loopback, RFC1918 private addresses, CGNAT ranges, link-local ranges, and cloud metadata endpoints (including `169.254.169.254`).
The only escape hatch is `configure_ssrf_whitelist(cidrs)`, which reads from `config.tools.ssrf_whitelist` at load time. For direct requests, the only escape hatch is `configure_ssrf_whitelist(cidrs)`, which reads from `config.tools.ssrf_whitelist` at load time. An explicitly configured `providers.<name>.proxy` is a separate user-authorized trust boundary for provider requests and provider-returned image URL downloads. Those downloads still reject malformed URLs and locally identifiable private/internal targets on every redirect, but hostnames unavailable to local DNS are delegated to the trusted proxy. The user-selected proxy owns final DNS resolution and network egress policy.
HTTP/SSE MCP transports are part of this boundary: validate configured MCP URLs before probing or constructing clients, and validate each outgoing HTTP request before redirects are followed. Local/private HTTP MCP endpoints are allowed only through the explicit SSRF whitelist. Stdio MCP servers are not part of the HTTP SSRF path. HTTP/SSE MCP transports are part of this boundary: validate configured MCP URLs before probing or constructing clients, and validate each outgoing HTTP request before redirects are followed. Local/private HTTP MCP endpoints are allowed only through the explicit SSRF whitelist. Stdio MCP servers are not part of the HTTP SSRF path.

View File

@ -5,10 +5,28 @@ on:
branches: [main] branches: [main]
paths-ignore: paths-ignore:
- docs/** - docs/**
- .agent/**
- .github/ISSUE_TEMPLATE/**
- AGENTS.md
- CLAUDE.md
- COMMUNICATION.md
- CONTRIBUTING.md
- README.md
- SECURITY.md
- webui/README.md
pull_request: pull_request:
branches: [main] branches: [main]
paths-ignore: paths-ignore:
- docs/** - docs/**
- .agent/**
- .github/ISSUE_TEMPLATE/**
- AGENTS.md
- CLAUDE.md
- COMMUNICATION.md
- CONTRIBUTING.md
- README.md
- SECURITY.md
- webui/README.md
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@ -18,15 +36,68 @@ permissions:
contents: read contents: read
jobs: jobs:
changes:
name: Detect changes
runs-on: ubuntu-latest
outputs:
python_required: ${{ steps.paths.outputs.python_required }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect Python-relevant changes
id: paths
shell: bash
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
run: |
python_required=true
if [[ "$EVENT_NAME" == "pull_request" ]]; then
diff_range="${BASE_SHA}...${HEAD_SHA}"
else
diff_range="${BASE_SHA}..${HEAD_SHA}"
fi
if git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null &&
changed_files="$(git diff --name-only --no-renames "$diff_range")" &&
[[ -n "$changed_files" ]] &&
! grep -qvE '^(webui/|nanobot/channels/[^/]+/webui/|docs/)' <<< "$changed_files"; then
python_required=false
fi
echo "python_required=$python_required" >> "$GITHUB_OUTPUT"
test: test:
name: Python (${{ matrix.name }})
needs: changes
if: needs.changes.outputs.python_required == 'true'
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 20 timeout-minutes: 20
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
os: ${{ fromJSON('["ubuntu-latest","windows-latest"]') }} include:
# CI concentrates on newer runtimes (3.11/3.12 still supported per pyproject requires-python). - name: minimum, 3.11
python-version: ${{ fromJSON('["3.13","3.14"]') }} os: ubuntu-latest
python-version: "3.11"
coverage: false
pytest_args: ""
- name: latest, 3.14 + coverage
os: ubuntu-latest
python-version: "3.14"
coverage: true
pytest_args: ""
- name: Windows, 3.14
os: windows-latest
python-version: "3.14"
coverage: false
# Keep each test file in one worker while using both hosted-runner cores.
pytest_args: "-n 2 --dist loadfile"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -44,10 +115,85 @@ jobs:
run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential run: sudo apt-get update && sudo apt-get install -y libolm-dev build-essential
- name: Install dependencies - name: Install dependencies
run: uv sync --all-extras run: uv sync --all-extras --dev
- name: Install channel dependencies
run: uv run --no-sync python -m scripts.install_channel_dependencies --all-channels
- name: Verify dependency consistency
run: uv pip check
# Channel requirements live in manifests rather than uv.lock. Avoid a
# later uv run sync pruning the packages installed by the previous step.
- name: Lint with ruff - name: Lint with ruff
run: uv run ruff check nanobot --select F if: matrix.coverage
run: uv run --no-sync ruff check nanobot tests conftest.py
- name: Run tests - name: Type check with BasedPyright (strict)
run: uv run pytest tests/ if: matrix.coverage
run: uv run --no-sync basedpyright
- name: Run tests with coverage
if: matrix.coverage
run: >-
uv run --no-sync python -m pytest
--cov=nanobot --cov-report=term-missing:skip-covered
--durations=25 --durations-min=1.0
- name: Run compatibility tests
if: ${{ !matrix.coverage }}
run: >-
uv run --no-sync python -m pytest
${{ matrix.pytest_args }}
--durations=25 --durations-min=1.0
webui:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Set up Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.6
- name: Verify npm lockfile
working-directory: webui
run: npm ci --ignore-scripts --dry-run
- name: Install WebUI dependencies
working-directory: webui
run: bun install --frozen-lockfile
- name: Lint WebUI
working-directory: webui
run: bun run lint
- name: Test WebUI
working-directory: webui
run: bun run test
- name: Build WebUI
working-directory: webui
run: bun run build
docker:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Build image with default channel dependencies
run: docker build -t nanobot:test .
- name: Verify default WhatsApp dependencies
run: docker run --rm --entrypoint python nanobot:test -c "import neonize, segno"
- name: Verify runtime dependency permissions
run: >-
docker run --rm --user 1000:1000 --entrypoint sh nanobot:test -c
'test -w /app/.venv && test ! -w /app && test ! -w /app/nanobot &&
python -m scripts.install_channel_dependencies discord && python -c "import discord"'

1
.gitignore vendored
View File

@ -100,3 +100,4 @@ temp/
exp/ exp/
.playwright-mcp/ .playwright-mcp/
bridge/node_modules/ bridge/node_modules/
webui/.verify-*

View File

@ -11,6 +11,11 @@ nanobot is a lightweight, open-source AI agent framework written in Python with
pytest tests/test_openai_api.py::test_function -v pytest tests/test_openai_api.py::test_function -v
ruff check nanobot/ ruff check nanobot/
# Strict type checking (matches CI)
uv sync --all-extras --dev
uv run --no-sync python -m scripts.install_channel_dependencies --all-channels
uv run --no-sync basedpyright
# WebUI: dev server (proxies API/WS to gateway :8765), build, test # WebUI: dev server (proxies API/WS to gateway :8765), build, test
# Build outputs to ../nanobot/web/dist (bundled into the Python wheel) # Build outputs to ../nanobot/web/dist (bundled into the Python wheel)
cd webui && bun run dev # or NANOBOT_API_URL=... bun run dev cd webui && bun run dev # or NANOBOT_API_URL=... bun run dev
@ -36,18 +41,17 @@ Messages flow through an async `MessageBus` (`nanobot/bus/queue.py`) that decoup
- **Agent Loop** (`nanobot/agent/loop.py`, `runner.py`): The core processing engine. `AgentLoop` manages session keys, hooks, and context building. `AgentRunner` executes the multi-turn LLM conversation with tool execution. - **Agent Loop** (`nanobot/agent/loop.py`, `runner.py`): The core processing engine. `AgentLoop` manages session keys, hooks, and context building. `AgentRunner` executes the multi-turn LLM conversation with tool execution.
- **LLM Providers** (`nanobot/providers/`): Provider implementations (Anthropic, OpenAI-compatible, OpenAI Responses API, Azure, Bedrock, GitHub Copilot, OpenAI Codex, etc.) built on a common base (`base.py`). Includes image generation (`image_generation.py`) and audio transcription (`transcription.py`). `factory.py` and `registry.py` handle instantiation and model discovery. - **LLM Providers** (`nanobot/providers/`): Provider implementations (Anthropic, OpenAI-compatible, OpenAI Responses API, Azure, Bedrock, GitHub Copilot, OpenAI Codex, etc.) built on a common base (`base.py`). Includes image generation (`image_generation.py`) and audio transcription (`transcription.py`). `factory.py` and `registry.py` handle instantiation and model discovery.
- **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket). `manager.py` discovers and coordinates them. Channels are auto-discovered via `pkgutil` scan + entry-point plugins. - **Channels** (`nanobot/channels/`): Platform integrations (Telegram, Discord, Slack, Feishu, Matrix, WhatsApp, QQ, WeChat, WeCom, DingTalk, Email, MoChat, MS Teams, WebSocket, Mattermost). `manager.py` discovers and coordinates them. Channels are self-contained packages auto-discovered via `pkgutil` scanning.
- **Tools** (`nanobot/agent/tools/`): Agent capabilities exposed to the LLM: filesystem (read/write/edit/list), shell execution (with sandbox backends), web search/fetch, MCP servers, cron, notebook editing, subagent spawning, long-running tasks / sustained goals (`long_task.py`), image generation, and self-modification. Tools are auto-discovered via `pkgutil` scan + entry-point plugins. - **Tools** (`nanobot/agent/tools/`): Agent capabilities exposed to the LLM: filesystem (read/write/edit/list), shell execution (with sandbox backends), web search/fetch, MCP servers, cron, notebook editing, subagent spawning, long-running tasks / sustained goals (`long_task.py`), image generation, and self-modification. Tools are auto-discovered via `pkgutil` scan + entry-point plugins.
- **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability. - **Memory** (`nanobot/agent/memory.py`): Session history persistence with Dream two-phase memory consolidation. Uses atomic writes with fsync for durability.
- **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`). - **Session Management** (`nanobot/session/`): Per-session history, context compaction, TTL-based auto-compaction (`manager.py`), and sustained goal state tracking (`goal_state.py`).
- **Config** (`nanobot/config/schema.py`, `loader.py`): Pydantic-based configuration loaded from `~/.nanobot/config.json`. Supports camelCase aliases for JSON compatibility. - **Config** (`nanobot/config/schema.py`, `loader.py`): Pydantic-based configuration loaded from `~/.nanobot/config.json`. Supports camelCase aliases for JSON compatibility.
- **Bridge** (`bridge/`): TypeScript services (e.g. WhatsApp bridge) bundled into the wheel via `pyproject.toml` `force-include`.
- **WebUI** (`webui/`): Vite-based React SPA that talks to the gateway over a WebSocket multiplex protocol. The dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to the gateway. - **WebUI** (`webui/`): Vite-based React SPA that talks to the gateway over a WebSocket multiplex protocol. The dev server proxies `/api`, `/webui`, `/auth`, and WebSocket traffic to the gateway.
- **API Server** (`nanobot/api/server.py`): OpenAI-compatible HTTP API (`/v1/chat/completions`, `/v1/models`) for programmatic access. - **API Server** (`nanobot/api/server.py`): OpenAI-compatible HTTP API (`/v1/chat/completions`, `/v1/models`) for programmatic access.
- **Command Router** (`nanobot/command/`): Slash command routing and built-in command handlers. - **Command Router** (`nanobot/command/`): Slash command routing and built-in command handlers.
- **Heartbeat** (`nanobot/templates/HEARTBEAT.md`): Periodic task list checked via `cron` jobs (legacy dedicated service removed). - **Heartbeat** (`nanobot/templates/HEARTBEAT.md`): Periodic task list checked via `cron` jobs (legacy dedicated service removed).
- **Pairing** (`nanobot/pairing/`): DM sender approval store with persistent pairing codes per channel. - **Pairing** (`nanobot/pairing/`): DM sender approval store with persistent pairing codes per channel.
- **Skills** (`nanobot/skills/`): Built-in skill definitions (long-goal, cron, github, image-generation, etc.) loaded into agent context. - **Skills** (`nanobot/skills/`): Built-in skill definitions (cron, github, image-generation, etc.) loaded into agent context.
- **Security** (`nanobot/security/`): PTH file guard and other security measures activated at CLI entry. - **Security** (`nanobot/security/`): PTH file guard and other security measures activated at CLI entry.
### Entry Points ### Entry Points

View File

@ -78,6 +78,20 @@ ruff check nanobot/
ruff format <files-you-changed> ruff format <files-you-changed>
``` ```
### Strict Type Checking
Strict type checking covers optional providers and channels. Reproduce the CI environment
with the same dependency sources and commands:
```bash
uv sync --all-extras --dev
uv run --no-sync python -m scripts.install_channel_dependencies --all-channels
uv run --no-sync basedpyright
```
Keep `--no-sync` on the final commands: channel dependencies come from their package
manifests and are installed explicitly by the setup step.
## Contribution License ## Contribution License
By submitting a contribution, you confirm that you have the right to submit it By submitting a contribution, you confirm that you have the right to submit it

View File

@ -1,49 +1,77 @@
FROM node:24-bookworm-slim AS webui-builder
WORKDIR /app
COPY webui/package.json webui/package-lock.json ./webui/
WORKDIR /app/webui
RUN npm ci
COPY webui/ ./
RUN mkdir -p /app/nanobot/web && npm run build
FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim
# Install Node.js 20 for the WhatsApp bridge
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates gnupg git bubblewrap openssh-client && \ apt-get install -y --no-install-recommends ca-certificates git bubblewrap openssh-client libmagic1 && \
mkdir -p /etc/apt/keyrings && \
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" > /etc/apt/sources.list.d/nodesource.list && \
apt-get update && \
apt-get install -y --no-install-recommends nodejs && \
apt-get purge -y gnupg && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
# Keep the runtime environment writable by the non-root nanobot user. Enabled
# channels may install their manifest-declared dependencies at startup.
ENV VIRTUAL_ENV=/app/.venv
ENV PATH="/app/.venv/bin:$PATH"
RUN uv venv --seed "$VIRTUAL_ENV"
# Install Python dependencies first (cached layer). Hatch reads the custom build # Install Python dependencies first (cached layer). Hatch reads the custom build
# hook from hatch_build.py even for this metadata-only install. # hook from hatch_build.py even for this metadata-only install.
ARG NANOBOT_EXTRAS=
COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./ COPY pyproject.toml README.md LICENSE THIRD_PARTY_NOTICES.md hatch_build.py ./
RUN mkdir -p nanobot bridge && touch nanobot/__init__.py && \ RUN mkdir -p nanobot && touch nanobot/__init__.py && \
uv pip install --system --no-cache . && \ if [ -n "$NANOBOT_EXTRAS" ]; then \
rm -rf nanobot bridge NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install \
--python "$VIRTUAL_ENV/bin/python" --no-cache ".[${NANOBOT_EXTRAS}]"; \
else \
NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install \
--python "$VIRTUAL_ENV/bin/python" --no-cache .; \
fi && \
rm -rf nanobot
# Copy the full source and install # Copy the full source and install
COPY nanobot/ nanobot/ COPY nanobot/ nanobot/
COPY bridge/ bridge/ COPY scripts/install_channel_dependencies.py scripts/
COPY webui/ webui/ COPY --from=webui-builder /app/nanobot/web/dist/ nanobot/web/dist/
RUN NANOBOT_FORCE_WEBUI_BUILD=1 uv pip install --system --no-cache . RUN NANOBOT_SKIP_WEBUI_BUILD=1 uv pip install --python "$VIRTUAL_ENV/bin/python" --no-cache .
# Build the WhatsApp bridge # Preinstall selected channel dependencies from their manifests. A comma-separated
WORKDIR /app/bridge # list keeps the image configurable while preserving WhatsApp in the default image.
RUN git config --global --add url."https://github.com/".insteadOf ssh://git@github.com/ && \ ARG NANOBOT_CHANNELS=whatsapp
git config --global --add url."https://github.com/".insteadOf git@github.com: && \ RUN for channel in $(printf '%s' "$NANOBOT_CHANNELS" | tr ',' ' '); do \
npm install && npm run build python -m scripts.install_channel_dependencies "$channel"; \
WORKDIR /app done
# Create non-root user and config directory # Render deploy template (see render.yaml): committed gateway config that wires
# secrets through ${ANTHROPIC_API_KEY} / ${NANOBOT_WEB_TOKEN} env vars (resolved
# at startup). Lives in the code dir (/app), not the data dir, so a mounted disk
# won't shadow it. Only used when RENDER=true; ignored by local runs.
COPY render-config.json ./
# Create the non-root user and hand ownership of the writable virtualenv to it.
RUN useradd -m -u 1000 -s /bin/bash nanobot && \ RUN useradd -m -u 1000 -s /bin/bash nanobot && \
mkdir -p /home/nanobot/.nanobot && \ mkdir -p /home/nanobot/.nanobot && \
chown -R nanobot:nanobot /home/nanobot /app chown -R nanobot:nanobot /home/nanobot /app/.venv
COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh && chmod +x /usr/local/bin/entrypoint.sh
USER nanobot # Start as root so the entrypoint can chown the data dir (on Render, the
# freshly-mounted root-owned persistent disk) before dropping to the non-root
# nanobot user via setpriv. The entrypoint drops privileges on every root start
# and fails closed if it cannot, so the agent never runs as root (see
# entrypoint.sh).
USER root
ENV HOME=/home/nanobot ENV HOME=/home/nanobot
# Ensure crash output reaches Render logs (app output is otherwise swallowed on
# non-graceful exit).
ENV PYTHONUNBUFFERED=1 PYTHONFAULTHANDLER=1
# Gateway health endpoint and optional WebUI/WebSocket channel ports # Gateway health endpoint and optional WebUI/WebSocket channel ports
EXPOSE 18790 8765 EXPOSE 18790 8765

450
README.md
View File

@ -1,6 +1,6 @@
<picture> <picture>
<source media="(prefers-color-scheme: dark)" srcset="./images/readme-cover-dark.png"> <source media="(prefers-color-scheme: dark)" srcset="./images/readme-cover-dark.svg">
<img alt="nanobot README cover" src="./images/readme-cover-light.png"> <img alt="nanobot README cover" src="./images/readme-cover-light.svg">
</picture> </picture>
<div align="center"> <div align="center">
@ -17,24 +17,24 @@
<a href="https://nanobot.wiki/vi/docs/latest/getting-started/nanobot-overview">Tiếng Việt</a> <a href="https://nanobot.wiki/vi/docs/latest/getting-started/nanobot-overview">Tiếng Việt</a>
</p> </p>
<p> <p>
<a href="https://pypi.org/project/nanobot-ai/"><img src="https://img.shields.io/pypi/v/nanobot-ai" alt="PyPI"></a> <a href="https://github.com/HKUDS/nanobot"><img src="https://img.shields.io/github/stars/HKUDS/nanobot?style=flat&logo=github" alt="GitHub stars"></a>
<a href="https://pepy.tech/project/nanobot-ai"><img src="https://static.pepy.tech/badge/nanobot-ai" alt="Downloads"></a> <a href="https://pypi.org/project/nanobot-ai/"><img src="https://img.shields.io/pypi/v/nanobot-ai" alt="PyPI version"></a>
<img src="https://img.shields.io/badge/python-≥3.11-blue" alt="Python"> <a href="https://pepy.tech/project/nanobot-ai"><img src="https://static.pepy.tech/badge/nanobot-ai" alt="PyPI downloads"></a>
<img src="https://img.shields.io/badge/license-MIT-green" alt="License"> <a href="https://github.com/HKUDS/nanobot/actions/workflows/ci.yml"><img src="https://github.com/HKUDS/nanobot/actions/workflows/ci.yml/badge.svg?branch=main" alt="Test Suite"></a>
<a href="https://github.com/HKUDS/nanobot/graphs/commit-activity" target="_blank"> <a href="https://pypi.org/project/nanobot-ai/"><img src="https://img.shields.io/badge/python-%3E%3D3.11-blue" alt="Python 3.11 or newer"></a>
<img alt="Commits last month" src="https://img.shields.io/github/commit-activity/m/HKUDS/nanobot?labelColor=%20%2332b583&color=%20%2312b76a"></a> <a href="./LICENSE"><img src="https://img.shields.io/github/license/HKUDS/nanobot" alt="MIT License"></a>
<a href="https://github.com/HKUDS/nanobot/issues?q=is%3Aissue%20is%3Aclosed" target="_blank"> <a href="https://nanobot.wiki/docs/latest/getting-started/nanobot-overview"><img src="https://img.shields.io/badge/docs-nanobot.wiki-blue" alt="nanobot documentation"></a>
<img alt="Issues closed" src="https://img.shields.io/github/issues-search?query=repo%3AHKUDS%2Fnanobot%20is%3Aissue%20is%3Aclosed&label=issues%20closed&labelColor=%20%237d89b0&color=%20%235d6b98"></a> </p>
<a href="https://twitter.com/intent/follow?screen_name=nanobot_project" target="_blank"> <p>
<img src="https://img.shields.io/twitter/follow/nanobot_project?logo=X&color=%20%23f5f5f5" alt="follow on X(Twitter)"></a> <a href="https://discord.gg/MnCvHqpUGB">Discord</a> ·
<a href="https://nanobot.wiki/docs/latest/getting-started/nanobot-overview"><img src="https://img.shields.io/badge/Docs-nanobot.wiki-blue?style=flat&logo=readthedocs&logoColor=white" alt="Docs"></a> <a href="https://x.com/nanobot_project">X</a> ·
<a href="./COMMUNICATION.md"><img src="https://img.shields.io/badge/Feishu-Group-E9DBFC?style=flat&logo=feishu&logoColor=white" alt="Feishu"></a> <a href="./COMMUNICATION.md">WeChat / Feishu</a>
<a href="./COMMUNICATION.md"><img src="https://img.shields.io/badge/WeChat-Group-C5EAB4?style=flat&logo=wechat&logoColor=white" alt="WeChat"></a>
<a href="https://discord.gg/MnCvHqpUGB"><img src="https://img.shields.io/badge/Discord-Community-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
</p> </p>
</div> </div>
🐈 **nanobot** is an open-source, ultra-lightweight personal AI agent you can truly own. It keeps the agent core small and readable while giving you the practical pieces for real long-running work: WebUI, chat channels, tools, memory, MCP, model routing, automation, and deployment. # nanobot
🐈 **nanobot** is an ultra-lightweight, open-source, self-hosted personal AI agent framework written in Python. It runs in a WebUI, terminal, or chat apps and combines tools, long-term memory, MCP integrations, model routing, multi-agent delegation, scheduled automation, and an OpenAI-compatible API in a small, readable core.
## Start Here ## Start Here
@ -42,168 +42,28 @@
|---|---| |---|---|
| Install nanobot with no terminal/config background | [Start Without Technical Background](./docs/start-without-technical-background.md) | | Install nanobot with no terminal/config background | [Start Without Technical Background](./docs/start-without-technical-background.md) |
| Install quickly and get one CLI reply | [Install](#-install) and [Quick Start](#-quick-start) | | Install quickly and get one CLI reply | [Install](#-install) and [Quick Start](#-quick-start) |
| Open the bundled browser UI after the CLI works | [WebUI](#-webui) | | Open the bundled browser UI | [WebUI](#-webui) |
| Connect Telegram, Discord, WeChat, Slack, Email, or another chat app | [Chat Apps](./docs/chat-apps.md) | | Connect Telegram, Discord, WeChat, Slack, Email, Mattermost, or another chat app | [Chat Apps](./docs/chat-apps.md) |
| Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) | | Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) |
| Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) | | Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) |
| Deploy to the cloud or keep nanobot running as a service | [Deployment](./docs/deployment.md) |
## Open Source Partners ## What can nanobot do?
<p align="center"> nanobot is a self-hosted personal AI agent runtime. It can:
<a href="https://platform.kimi.com?aff=nanobot"><picture><source media="(prefers-color-scheme: dark)" srcset="https://kimi-file.moonshot.cn/prod-chat-kimi/kfs/4/1/2026-06-05/1d8h69mt3v89kkekg24gg"><img alt="Kimi Open Source Friends" height="44" src="https://kimi-file.moonshot.cn/prod-chat-kimi/kfs/4/1/2026-06-05/1d8h69fudcmosb3pipls0"></picture></a>
<a href="https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link"><img alt="MiniMax" height="40" src="https://mintcdn.com/minimax-zh/1UjvBcdoC6r0UeyA/logo/light.svg?fit=max&auto=format&n=1UjvBcdoC6r0UeyA&q=85&s=672d724b639b2d88d0702fae329ea4f8"></a>
</p>
## 📢 News
- **2026-06-20** 💬 Telegram rich messages, safer SDK concurrency, smoother Quick Start.
- **2026-06-19** 🔎 Firecrawl app, OpenAI image edits, safer session deletion.
- **2026-06-18** 💬 Feishu recovery, Keenable search, Mistral polish, workspace-aware git.
- **2026-06-17** 🧠 Default idle auto-compact, clearer `/dream`, macOS installer fixes.
- **2026-06-16** 🎯 Fresher goal context, Kimi K2.7 thinking, cleaner API retries.
- **2026-06-15** 📱 Mobile WebUI polish, optional file tools, real API usage.
- **2026-06-14** 🖼️ Themed cover, partner links, stronger Codex image streaming.
- **2026-06-13** 🗓️ Session-bound automations, sturdier WhatsApp, faster WebUI startup.
- **2026-06-12** 💬 Slack allowlisted channels can require mentions.
- **2026-06-11** ✂️ Fenced-code message splitting.
<details>
<summary>Earlier news</summary>
- **2026-06-10** 📜 Segmented transcripts, Exa/Bocha search, StepFun/SiliconFlow ASR.
- **2026-06-09** 🎙️ Shared voice input, more STT providers, TeX and email polish.
- **2026-06-08** 🧮 Token heatmap fix, safer MCP HTTP probing, docs cleanup.
- **2026-06-06** 🧰 SDK MCP cleanup, removable OpenAI image defaults.
- **2026-06-05** 🖼️ Azure AAD, custom image providers, `/skill`, steadier pairing.
- **2026-06-04** 🔌 MCP reconnects, `uv pip` install fallback, QQ pairing.
- **2026-06-03** 🧠 Hidden-history recovery, quieter email progress handling.
- **2026-06-02** 📬 Email attachments, Napcat QQ, Volcengine search, simpler Dream.
- **2026-06-01** 🚀 Released **v0.2.1****The Workbench Release** turns the packaged WebUI into a daily agent workbench: clearer Thought/response timelines, live file-edit activity, project workspaces, model and context controls, steadier sustained goals, CLI Apps + MCP extensions, and broader provider/channel support. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.1) for details.
- **2026-05-30** 🔐 Safer Matrix verification, bounded media downloads, clearer WebUI model timeline.
- **2026-05-29** 🧩 Extension registry, context-window tuning, document extraction controls.
- **2026-05-28** 🗂️ Project workspaces, access controls, steadier goals and streaming.
- **2026-05-27** ⏱️ Codex streams respect idle timeouts during long runs.
- **2026-05-26** 📡 Telegram webhooks, refreshed Kagi search, cleaner transport errors.
- **2026-05-25** 🔌 Unified CLI Apps and MCP, Step Plan support, steadier sustained goals.
- **2026-05-24** 🧰 MCP presets, richer slash actions, configurable OpenAI-compatible requests.
- **2026-05-23** 🖼️ Zhipu image generation, longer exec windows, cleaner transcription config.
- **2026-05-22** 🛠️ CLI Apps, more image providers, safer web redirects and edits.
- **2026-05-21** ⚡ Novita provider, faster sidebar, smoother coding tools and Weixin replies.
- **2026-05-20** 📶 Signal channel, faster gateway startup, multilingual README links.
- **2026-05-19** 🎨 Image provider registry, StepFun and Skywork, stronger WebUI controls.
- **2026-05-18** 🖌️ Gemini and MiniMax images, Ant Ling, live file-edit activity.
- **2026-05-17** 🌊 Smoother WebUI streaming, AutoCompact fixes, buffered CLI reasoning.
- **2026-05-16** 🧠 Atomic Chat provider, goal-aware timeouts, safer exec URL handling.
- **2026-05-15** 🚀 Released **v0.2.0****`/goal`** holds sustained objectives across turns, WebUI now ships inside the wheel, image generation end to end, 5 new providers with `fallback_models`, and a real agent-loop refactor. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.0) for details.
- **2026-05-14** 🎯 **`/goal`** for long-term objectives, visible multi-step progress, long-horizon missions in chat.
- **2026-05-13** 🧠 Streaming reasoning before answers, automatic backup models, smoother plug-in reconnects.
- **2026-05-12** 🎛️ Saved model presets with WebUI badge, simpler plug-in tools, quieter Feishu topic threads.
- **2026-05-11** 🖥️ NVIDIA NIM support, terminal bot name and icon, streamed reasoning and MiMo toggle clarity.
- **2026-05-09** 🖼️ Sharper image replay, BYO web-search keys in Settings, Feishu threads routed cleanly.
- **2026-05-08** ✨ Inline chat image, redesigned Settings and keys, Dream memory aligned with visible history.
- **2026-05-07** 📜 Locale-aware slash palette in WebUI, LAN login, faithful HTTP streaming responses.
- **2026-05-06** 🧩 Tunable tool hint, steadier voice and plug-in startups, schedules and reminders that stick.
- **2026-05-05** 🛡️ Quiet deny for unknown Telegram chats, Dream cleanup, fuller automation summaries.
- **2026-05-04** 🔐 Safer DingTalk outbound media links, durable cron persistence, DeepSeek polish.
- **2026-05-03** ⚙️ Predictable shell allow-list behavior, isolated chats mid-reply, cleaner interactive retries.
- **2026-05-02** 🐈 LongCat support, smarter token sizing hints, clearer bundled upgrade guidance.
- **2026-05-01** ☁️ Native AWS Bedrock provider, tighter helper handoffs and scoped session files.
- **2026-04-30** 💬 Feishu threads that honor replies and topics, WhatsApp bridge refresh on source edits.
- **2026-04-29** 🚀 Released **v0.1.5.post3** — Smarter threads on Feishu, Discord, Slack, and Teams; **DeepSeek-V4**; Hugging Face & Olostep; choices, `/history`, and steadier long chats. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post3) for details.
- **2026-04-28** 🌐 Olostep web search, Hugging Face provider, safer workspace-tool interruptions.
- **2026-04-27** 💬 `/history` command, smarter session replay caps, smoother Discord / Slack threads.
- **2026-04-26** 🧭 Natural cron reminders, thread-aware restarts, safer local provider and shell behavior.
- **2026-04-25** 🧩 `ask_user` choices, macOS LaunchAgent deployment, MSTeams stale-reference cleanup.
- **2026-04-24** 🎥 Video attachments for channels, DeepSeek thinking control, faster document startup.
- **2026-04-23** 🧵 Discord thread sessions, Telegram inline buttons, structured tool progress updates.
- **2026-04-22** 🔎 GitHub Copilot GPT-5 / o-series support, configurable web fetch, WebUI image uploads.
- **2026-04-21** 🚀 Released **v0.1.5.post2** — Windows & Python 3.14 support, Office document reading, SSE streaming for the OpenAI-compatible API, and stronger reliability across sessions, memory, and channels. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post2) for details.
- **2026-04-20** 🎨 Kimi K2.6 support, Telegram long-message split, WebUI typography & dark-mode polish.
- **2026-04-19** 🌐 WebUI i18n locale switcher, atomic session writes with auto-repair.
- **2026-04-18** 🧪 Initial WebUI chat, smarter setup wizard menus, WebSocket multi-chat multiplexing.
- **2026-04-17** 🪟 Windows & Python 3.14 CI, Dream line-age memory, email self-loop guard.
- **2026-04-16** 📡 SSE streaming for OpenAI-compatible API, Discord channel allow-list.
- **2026-04-15** 🎛️ LM Studio & nullable API keys, MiniMax thinking endpoint, runtime SelfTool.
- **2026-04-14** 🚀 Released **v0.1.5.post1** — Dream skill discovery, mid-turn follow-up injection, WebSocket channel, and deeper channel integrations. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post1) for details.
- **2026-04-13** 🛡️ Agent turn hardened — user messages persisted early, auto-compact skips active tasks.
- **2026-04-12** 🔒 Lark global domain support, Dream learns discovered skills, shell sandbox tightened.
- **2026-04-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media.
- **2026-04-10** 📓 Multiple MCP servers, Feishu streaming & done-emoji.
- **2026-04-09** 🔌 WebSocket channel, unified cross-channel session, `disabled_skills` config.
- **2026-04-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback.
- **2026-04-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools.
- **2026-04-06** 🛰️ Langfuse observability, unified Whisper transcription, email attachments.
- **2026-04-05** 🚀 Released **v0.1.5** — sturdier long-running tasks, Dream two-stage memory, production-ready sandboxing and programming Agent SDK. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5) for details.
- **2026-04-04** 🚀 Jinja2 response templates, Dream memory hardened, smarter retry handling.
- **2026-04-03** 🧠 Xiaomi MiMo provider, chain-of-thought reasoning visible, Telegram UX polish.
- **2026-04-02** 🧱 Long-running tasks run more reliably — core runtime hardening.
- **2026-04-01** 🔑 GitHub Copilot auth restored; stricter workspace paths; OpenRouter Claude caching fix.
- **2026-03-31** 🛰️ WeChat multimodal alignment, Discord/Matrix polish, Python SDK facade, MCP and tool fixes.
- **2026-03-30** 🧩 OpenAI-compatible API tightened; composable agent lifecycle hooks.
- **2026-03-29** 💬 WeChat voice, typing, QR/media resilience; fixed-session OpenAI-compatible API.
- **2026-03-28** 📚 Provider docs refresh; skill template wording fix.
- **2026-03-27** 🚀 Released **v0.1.4.post6** — architecture decoupling, litellm removal, end-to-end streaming, WeChat channel, and a security fix. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post6) for details.
- **2026-03-26** 🏗️ Agent runner extracted and lifecycle hooks unified; stream delta coalescing at boundaries.
- **2026-03-25** 🌏 StepFun provider, configurable timezone, Gemini thought signatures.
- **2026-03-24** 🔧 WeChat compatibility, Feishu CardKit streaming, test suite restructured.
- **2026-03-23** 🔧 Command routing refactored for plugins, WhatsApp/WeChat media, unified channel login CLI.
- **2026-03-22** ⚡ End-to-end streaming, WeChat channel, Anthropic cache optimization, `/status` command.
- **2026-03-21** 🔒 Replace `litellm` with native `openai` + `anthropic` SDKs. Please see [commit](https://github.com/HKUDS/nanobot/commit/3dfdab7).
- **2026-03-20** 🧙 Interactive setup wizard — pick your provider, model autocomplete, and you're good to go.
- **2026-03-19** 💬 Telegram gets more resilient under load; Feishu now renders code blocks properly.
- **2026-03-18** 📷 Telegram can now send media via URL. Cron schedules show human-readable details.
- **2026-03-17** ✨ Feishu formatting glow-up, Slack reacts when done, custom endpoints support extra headers, and image handling is more reliable.
- **2026-03-16** 🚀 Released **v0.1.4.post5** — a refinement-focused release with stronger reliability and channel support, and a more dependable day-to-day experience. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post5) for details.
- **2026-03-15** 🧩 DingTalk rich media, smarter built-in skills, and cleaner model compatibility.
- **2026-03-14** 💬 Channel plugins, Feishu replies, and steadier MCP, QQ, and media handling.
- **2026-03-13** 🌐 Multi-provider web search, LangSmith, and broader reliability improvements.
- **2026-03-12** 🚀 VolcEngine support, Telegram reply context, `/restart`, and sturdier memory.
- **2026-03-11** 🔌 WeCom, Ollama, cleaner discovery, and safer tool behavior.
- **2026-03-10** 🧠 Token-based memory, shared retries, and cleaner gateway and Telegram behavior.
- **2026-03-09** 💬 Slack thread polish and better Feishu audio compatibility.
- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
- **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish.
- **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility.
- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes.
- **2026-03-04** 🛠️ Dependency cleanup, safer file reads, and another round of test and Cron fixes.
- **2026-03-03** 🧠 Cleaner user-message merging, safer multimodal saves, and stronger Cron guards.
- **2026-03-02** 🛡️ Safer default access control, sturdier Cron reloads, and cleaner Matrix media handling.
- **2026-03-01** 🌐 Web proxy support, smarter Cron reminders, and Feishu rich-text parsing improvements.
- **2026-02-28** 🚀 Released **v0.1.4.post3** — cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details.
- **2026-02-27** 🧠 Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes.
- **2026-02-26** 🛡️ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility.
- **2026-02-25** 🧹 New Matrix channel, cleaner session context, auto workspace template sync.
- **2026-02-24** 🚀 Released **v0.1.4.post2** — a reliability-focused release with a redesigned heartbeat, prompt cache optimization, and hardened provider & channel stability. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post2) for details.
- **2026-02-23** 🔧 Virtual tool-call heartbeat, prompt cache optimization, Slack mrkdwn fixes.
- **2026-02-22** 🛡️ Slack thread isolation, Discord typing fix, agent reliability improvements.
- **2026-02-21** 🎉 Released **v0.1.4.post1** — new providers, media support across channels, and major stability improvements. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post1) for details.
- **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood.
- **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode.
- **2026-02-18** ⚡️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching.
- **2026-02-17** 🎉 Released **v0.1.4** — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details.
- **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills.
- **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support.
- **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](./docs/configuration.md#mcp-model-context-protocol) for details.
- **2026-02-13** 🎉 Released **v0.1.3.post7** — includes security hardening and multiple improvements. **Please upgrade to the latest version to address security issues**. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
- **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support!
- **2026-02-10** 🎉 Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](./docs/configuration.md#providers).
- **2026-02-07** 🚀 Released **v0.1.3.post5** with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
- **2026-02-04** 🚀 Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
- **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling!
- **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot!
</details>
- run in a browser WebUI or terminal
- connect to Telegram, Discord, Slack, WeChat, Email, Mattermost, and other chat apps
- use tools such as files, shell, web search, web fetch, MCP, cron, image generation, and subagents
- keep session history and long-term memory through Dream
- run long-horizon goals and scheduled automations
- expose a Python SDK and OpenAI-compatible API for integrations
- deploy as a long-running local or server-side agent gateway
## 💡 Why nanobot ## 💡 Why nanobot
- **Persistent workflows**: goals, memory, tools, and chat context survive long-running work. - **Persistent workflows**: goals, memory, tools, and chat context survive long-running work.
- **Chat-native reach**: WebUI, API, Telegram, Feishu, Slack, Discord, Teams, and email. - **Chat-native reach**: WebUI, API, Telegram, Feishu, Slack, Discord, Teams, email, and Mattermost.
- **Model freedom**: OpenAI-compatible APIs, local LLMs, image generation, search, and fallbacks. - **Model freedom**: OpenAI-compatible APIs, local LLMs, image generation, search, and fallbacks.
- **Small core**: readable internals with MCP, memory, deployment, and automation built in. - **Small core**: readable internals with MCP, memory, deployment, and automation built in.
- **Own your stack**: inspect, customize, self-host, and extend without a giant platform. - **Own your stack**: inspect, customize, self-host, and extend without a giant platform.
@ -217,7 +77,7 @@
Pick **one** install method: Pick **one** install method:
Prerequisites: Python 3.11 or newer. Git is only needed for a source install; Node.js/Bun are only needed if you are developing the WebUI itself. Prerequisites: Python 3.11 or newer. Git is only needed for a source install. Published packages already include the WebUI; a current-source install needs `bun` or `npm` to build it.
If terminals, API keys, or config files are new to you, use the guided zero-background walkthrough in [Start Without Technical Background](./docs/start-without-technical-background.md) instead of this compact README path. If terminals, API keys, or config files are new to you, use the guided zero-background walkthrough in [Start Without Technical Background](./docs/start-without-technical-background.md) instead of this compact README path.
@ -235,7 +95,7 @@ Windows PowerShell:
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
``` ```
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes and you enabled the WebSocket channel, skip the manual initialize/configure steps below and go straight to **Open the WebUI**. The default command installs or upgrades `nanobot-ai` from PyPI. On a fresh local desktop, it then starts `nanobot webui` so you can configure the first provider and model in **Settings → Models**. SSH, headless, existing-config, and older-release paths keep the terminal setup wizard. The installer avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. It also prints the exact command it used to run nanobot; reuse that full command below if `nanobot` is not on `PATH`.
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install. To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install.
@ -275,112 +135,86 @@ If pip reports `externally-managed-environment` on macOS or Linux, use the one-c
**Install from source** **Install from source**
`bun` or `npm` must be available. From an activated virtual environment:
```bash ```bash
git clone https://github.com/HKUDS/nanobot.git git clone https://github.com/HKUDS/nanobot.git
cd nanobot cd nanobot
python -m pip install -e . python -m pip install .
``` ```
On Windows, if pip reports that it cannot launch `npm`, run `cd webui`, `npm.cmd install --package-lock=false`, `npm.cmd run build`, and `cd ..` in order, then retry the install. Contributors who need an editable checkout should follow [`CONTRIBUTING.md`](./CONTRIBUTING.md) and [`webui/README.md`](./webui/README.md).
Verify the install: Verify the install:
```bash ```bash
nanobot --version nanobot --version
``` ```
If `nanobot` is not on `PATH`, invoke it through the method that installed it: reuse the recommended installer's command, use `uv tool run --from nanobot-ai nanobot ...` or `pipx run --spec nanobot-ai nanobot ...`, or use the Python executable from the environment where pip installed the package.
## 🚀 Quick Start ## 🚀 Quick Start
**1. Initialize** **Open nanobot in your browser**
Skip this step if the one-command setup already started the wizard and Quick Start finished there.
```bash ```bash
nanobot onboard nanobot webui
``` ```
Use `nanobot onboard --wizard` if you prefer an interactive setup. This is the recommended first run. The launcher creates the config and workspace when needed, safely enables the local WebSocket channel after confirmation, starts the gateway, and opens [`http://127.0.0.1:8765`](http://127.0.0.1:8765). A fresh install can open before a model is configured, so setup continues in the browser instead of beginning in a JSON file. The first-run WebUI binds to localhost by default and is not exposed to your LAN.
**2. Configure** (`~/.nanobot/config.json`) **Your first three steps**
Skip this step if you already configured provider and model settings in the wizard. 1. Open **Settings → Models** and choose a provider, credential, and model.
2. Start a new topic and send `Hello!` to verify the connection.
3. Before project work, choose the intended workspace and access mode from the composer.
`nanobot onboard` creates `~/.nanobot/config.json` and `~/.nanobot/workspace/`. Configure these **two parts** in the config file. Add or merge the following blocks into the existing file instead of replacing the whole file. Any normal reply means the provider, model, workspace, and browser gateway are working together.
The example below uses a generic OpenAI-compatible `custom` provider so the compact path does not recommend one hosted service. Provider examples are recipes, not rankings or endorsements. For copyable provider-specific setup, see [Provider Cookbook](./docs/provider-cookbook.md). **Keep nanobot running after you close the terminal**
*Set your API key*: ```bash
nanobot webui --background
```json
{
"providers": {
"custom": {
"apiKey": "your-api-key",
"apiBase": "https://api.example.com/v1"
}
}
}
``` ```
*Set a model preset and make it active*: This starts the same full gateway as `nanobot webui`, opens the browser, and leaves channels and automations running after the launcher exits. Complete first-time model setup with foreground `nanobot webui` before switching to background mode.
```json ```bash
{ nanobot gateway status
"modelPresets": { nanobot gateway logs
"primary": { nanobot gateway restart
"label": "Primary", nanobot gateway stop
"provider": "custom",
"model": "model-id-from-your-provider",
"maxTokens": 8192,
"contextWindowTokens": 200000,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
``` ```
Direct `agents.defaults.provider` and `agents.defaults.model` still work for existing configs, but named presets are the recommended path because they also power `/model` switching and `fallbackModels`. **Prefer a gateway-first workflow?**
For another provider, the same config shape still applies:
| Replace | Where |
|---|---|
| Provider config key | `providers.<provider>` |
| API key | `providers.<provider>.apiKey` |
| Preset provider name | `modelPresets.primary.provider` |
| Model ID | `modelPresets.primary.model` |
| Endpoint URL, only when needed | `providers.<provider>.apiBase` |
**3. Open the WebUI**
If Quick Start enabled the WebSocket channel, start the gateway:
```bash ```bash
nanobot gateway nanobot gateway
``` ```
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password you set in the wizard, then send your first message there. This skips WebUI setup and browser opening, then runs the same complete gateway in the current terminal. It is the familiar entry point if you are coming from OpenClaw or already operate agents as long-lived services. The WebUI remains available when its channel is configured; open it manually when needed.
Prefer not to keep a terminal open? Use `nanobot gateway --background`, then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
For manual or terminal-only setup, test one CLI message: Use `nanobot gateway --background` for the same direct entry point without keeping the terminal attached. For automatic startup and supervision by the operating system, see [Deployment](./docs/deployment.md).
```bash **Prefer to work entirely in the terminal?**
nanobot status
nanobot agent -m "Hello!"
```
In `nanobot status`, it is normal for most providers to say `not set`. The active preset's provider should be configured, and `Config` plus `Workspace` should show check marks.
If that works, start an interactive chat:
```bash ```bash
nanobot agent nanobot agent
``` ```
Need help with `PATH`, API keys, provider/model matching, or JSON errors? See the fuller [Install and Quick Start](./docs/quick-start.md) and [Troubleshooting](./docs/troubleshooting.md). This opens an interactive terminal chat with the same configured model, workspace, and tools while keeping its own CLI session history. It does not open a browser or keep chat channels and automations running after you exit. Type `exit` or press `Ctrl+C` when you are done.
For one request and an immediate exit, use:
```bash
nanobot agent -m "Hello!"
```
The one-shot form is useful for a quick provider check, shell scripts, and local automation. If you have not configured a model yet, run `nanobot webui` and open **Settings → Models** first.
Need manual JSON, another device on your LAN, or help with provider/model matching? Continue with [Install and Quick Start](./docs/quick-start.md), [WebUI](./docs/webui.md), or [Troubleshooting](./docs/troubleshooting.md).
If nanobot worked for you, a star on GitHub is the simplest way to support the project.
- Want a pasteable provider setup? See [Provider Cookbook](./docs/provider-cookbook.md) - Want a pasteable provider setup? See [Provider Cookbook](./docs/provider-cookbook.md)
- Want to understand provider/model matching? See [Providers and Models](./docs/providers.md) - Want to understand provider/model matching? See [Providers and Models](./docs/providers.md)
@ -389,46 +223,38 @@ Need help with `PATH`, API keys, provider/model matching, or JSON errors? See th
- Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md) - Want to run nanobot in chat apps like Telegram, Discord, WeChat or Feishu? See [Chat Apps](./docs/chat-apps.md)
- Want Docker or Linux service deployment? See [Deployment](./docs/deployment.md) - Want Docker or Linux service deployment? See [Deployment](./docs/deployment.md)
<a id="deploy-to-render"></a>
## ☁️ Deploy
**Render — one click**
Deploy nanobot's gateway and bundled WebUI from the repository's ready-to-use Blueprint:
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/HKUDS/nanobot)
Render will ask for `ANTHROPIC_API_KEY` and a private `NANOBOT_WEB_TOKEN`, then provision persistent storage for sessions, memory, and WebUI history. Persistent disks require a paid Render service.
**Self-host**
Prefer your own infrastructure? Follow the [deployment guide](./docs/deployment.md) for Docker, Docker Compose, Linux services, and macOS LaunchAgent setup.
## 🌐 WebUI ## 🌐 WebUI
The WebUI ships **inside the published wheel** — no extra build step. It is the browser workbench for chat sessions, workspace controls, Apps, Skills, Automations, and settings. For the full user guide, see [`docs/webui.md`](./docs/webui.md). The WebUI ships **inside the published wheel** with no separate frontend build. It is the browser workbench for persistent topics, visible agent activity, workspace controls, Apps, Skills, Automations, and settings.
<p align="center"> <p align="center">
<img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900"> <img src="images/nanobot_webui.png" alt="nanobot webui preview" width="900">
</p> </p>
**1. Enable the WebSocket channel in `~/.nanobot/config.json`** Use it to:
Merge this block into your existing config: - keep separate topics for different tasks and projects;
- inspect reasoning, tool calls, file edits, diffs, command output, and generated artifacts;
- switch models and workspaces without leaving the conversation;
- configure providers, chat channels, Apps, Skills, and Automations from one place.
```json See the [WebUI guide](./docs/webui.md) for LAN access, background operation, workspace controls, and the full feature tour. Working on the frontend itself? Use [`webui/README.md`](./webui/README.md).
{
"channels": {
"websocket": {
"enabled": true,
"tokenIssueSecret": "your-webui-password",
"websocketRequiresToken": true
}
}
}
```
**2. Start the gateway**
```bash
nanobot gateway
```
Use `nanobot gateway --background` for a local background process you can manage later with `nanobot gateway status`, `logs`, `restart`, and `stop`.
**3. Open the WebUI**
Visit [`http://127.0.0.1:8765`](http://127.0.0.1:8765) in your browser. To open it from another device on your LAN, see [WebUI docs -> LAN access](./docs/webui.md#lan-access).
The WebUI is served by the WebSocket channel on port `8765` by default. The gateway's `18790` port is for the health endpoint, not the browser UI.
> [!TIP]
> Working on the WebUI itself? Check out [`webui/README.md`](./webui/README.md) for the source-tree, Vite dev server, build, and test workflow.
## 🏗️ Architecture ## 🏗️ Architecture
@ -438,33 +264,11 @@ The WebUI is served by the WebSocket channel on port `8765` by default. The gate
🐈 nanobot stays lightweight by centering everything around a small agent loop: messages come in from chat apps, the LLM decides when tools are needed, and memory or skills are pulled in only as context instead of becoming a heavy orchestration layer. That keeps the core path readable and easy to extend, while still letting you add channels, tools, memory, and deployment options without turning the system into a monolith. 🐈 nanobot stays lightweight by centering everything around a small agent loop: messages come in from chat apps, the LLM decides when tools are needed, and memory or skills are pulled in only as context instead of becoming a heavy orchestration layer. That keeps the core path readable and easy to extend, while still letting you add channels, tools, memory, and deployment options without turning the system into a monolith.
## ✨ Features
<table align="center">
<tr align="center">
<th><p align="center">📈 24/7 Real-Time Market Analysis</p></th>
<th><p align="center">🚀 Full-Stack Software Engineer</p></th>
<th><p align="center">📅 Smart Daily Routine Manager</p></th>
<th><p align="center">📚 Personal Knowledge Assistant</p></th>
</tr>
<tr>
<td align="center"><p align="center"><img src="case/search.gif" width="180" height="400"></p></td>
<td align="center"><p align="center"><img src="case/code.gif" width="180" height="400"></p></td>
<td align="center"><p align="center"><img src="case/schedule.gif" width="180" height="400"></p></td>
<td align="center"><p align="center"><img src="case/memory.gif" width="180" height="400"></p></td>
</tr>
<tr>
<td align="center">Discovery • Insights • Trends</td>
<td align="center">Develop • Deploy • Scale</td>
<td align="center">Schedule • Automate • Organize</td>
<td align="center">Learn • Memory • Reasoning</td>
</tr>
</table>
## 📚 Docs ## 📚 Docs
Browse the [repo docs](./docs/README.md) for the latest features and GitHub development version, or visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview) for the stable release documentation. Browse the [repo docs](./docs/README.md) for the latest features and GitHub development version, or visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview) for the stable release documentation.
- Use task-oriented guides: [Guides](./docs/guides/README.md)
- Start with no technical background: [Start Without Technical Background](./docs/start-without-technical-background.md) - Start with no technical background: [Start Without Technical Background](./docs/start-without-technical-background.md)
- Start from zero with developer basics: [Install and Quick Start](./docs/quick-start.md) - Start from zero with developer basics: [Install and Quick Start](./docs/quick-start.md)
- Understand the runtime model: [Concepts](./docs/concepts.md) - Understand the runtime model: [Concepts](./docs/concepts.md)
@ -472,30 +276,53 @@ Browse the [repo docs](./docs/README.md) for the latest features and GitHub deve
- Choose a provider/model: [Providers and Models](./docs/providers.md) - Choose a provider/model: [Providers and Models](./docs/providers.md)
- Copy provider setup recipes: [Provider Cookbook](./docs/provider-cookbook.md) - Copy provider setup recipes: [Provider Cookbook](./docs/provider-cookbook.md)
- Debug setup and runtime failures: [Troubleshooting](./docs/troubleshooting.md) - Debug setup and runtime failures: [Troubleshooting](./docs/troubleshooting.md)
- Talk to your nanobot with familiar chat apps: [Chat Apps](./docs/chat-apps.md) - Talk to your nanobot with familiar chat apps: [Chat App AI Agent](./docs/guides/chat-app-ai-agent.md) · [Chat Apps](./docs/chat-apps.md)
- Schedule or trigger agent work: [Automations](./docs/automations.md)
- Configure providers, web search, MCP, and runtime behavior: [Configuration](./docs/configuration.md) - Configure providers, web search, MCP, and runtime behavior: [Configuration](./docs/configuration.md)
- Integrate nanobot with local tools and automations: [OpenAI-Compatible API](./docs/openai-api.md) · [Python SDK](./docs/python-sdk.md) - Integrate nanobot with local tools and automations: [OpenAI-Compatible API](./docs/openai-api.md) · [Python SDK](./docs/python-sdk.md)
- Run nanobot with Docker or as a Linux service: [Deployment](./docs/deployment.md) - Run nanobot with Docker or as a Linux service: [Deployment](./docs/deployment.md)
## 🤝 Contribute & Roadmap ## Releases
PRs welcome! The codebase is intentionally small and readable. 🤗 **Latest release: [v0.3.0 - The Agency Release](https://github.com/HKUDS/nanobot/releases/tag/v0.3.0)**
### Contribution Flow The Agency Release turns nanobot from a durable workbench into an agent runtime that can coordinate helpers, switch models per session, and carry authorized work through to completion.
See [CONTRIBUTING.md](./CONTRIBUTING.md) for setup, review, and contribution guidelines. - Consult inline subagents without leaving the current task
- Switch model presets per session directly from the composer
- Start from a guided WebUI setup with clearer execution controls
- Apply configuration changes live across a more reliable provider, channel, and tool runtime
**Roadmap** — Pick an item and [open a PR](https://github.com/HKUDS/nanobot/pulls)! [Read the v0.3.0 release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.3.0)
- **Multi-modal** — See and hear (images, voice, video) ## Recent Updates
- **Long-term memory** — Never forget important context
- **Better reasoning** — Multi-step planning and reflection - **2026-07-24** Guided first-run setup, inline subagents, and model switching from the composer.
- **More integrations** — Calendar and more - **2026-07-23** Grok OAuth with hosted X Search, live image settings, and clearer fallback models.
- **Self-improvement** — Learn from feedback and mistakes - **2026-07-22** Parallel Search, live configuration reloads, richer app discovery, and a smoother mobile WebUI.
- **2026-07-21** Codex fast mode, visible skill references, safer configuration saves, and sturdier task cleanup.
- **2026-07-20** Cleaner code blocks and copy actions, self-contained channels, and steadier QQ reconnects.
For older updates, see the [release archive](./docs/release-archive.md) or [GitHub releases](https://github.com/HKUDS/nanobot/releases).
## Open Source Partners
<p align="center">
<a href="https://platform.kimi.com?aff=nanobot"><picture><source media="(prefers-color-scheme: dark)" srcset="https://kimi-file.moonshot.cn/prod-chat-kimi/kfs/4/1/2026-06-05/1d8h69mt3v89kkekg24gg"><img alt="Kimi Open Source Friends" height="44" src="https://kimi-file.moonshot.cn/prod-chat-kimi/kfs/4/1/2026-06-05/1d8h69fudcmosb3pipls0"></picture></a>
<a href="https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link"><img alt="MiniMax" height="40" src="https://mintcdn.com/minimax-zh/1UjvBcdoC6r0UeyA/logo/light.svg?fit=max&auto=format&n=1UjvBcdoC6r0UeyA&q=85&s=672d724b639b2d88d0702fae329ea4f8"></a>
</p>
## 🤝 Contribute
Use nanobot for a real task, report what broke, and then pick a focused improvement.
- Read [CONTRIBUTING.md](./CONTRIBUTING.md) for the development workflow.
- Browse [open issues](https://github.com/HKUDS/nanobot/issues) for problems to investigate.
- Open a [pull request](https://github.com/HKUDS/nanobot/pulls) for a focused fix or integration.
## Contact ## Contact
This project was started by [Xubin Ren](https://github.com/re-bin) as a personal open-source project and continues to be maintained in an individual capacity using personal resources, with contributions from the open-source community. Feel free to contact [xubinrencs@gmail.com](mailto:xubinrencs@gmail.com) for questions, ideas, or collaboration. Nanobot was started by [Xubin Ren](https://github.com/re-bin) as a personal open-source project and is now maintained collaboratively with contributors from the open-source community. Feel free to contact [xubinrencs@gmail.com](mailto:xubinrencs@gmail.com) for questions, ideas, or collaboration.
### Contributors ### Contributors
@ -503,19 +330,6 @@ This project was started by [Xubin Ren](https://github.com/re-bin) as a personal
<img src="https://contrib.rocks/image?repo=HKUDS/nanobot&max=100&columns=12&updated=20260210" alt="Contributors" /> <img src="https://contrib.rocks/image?repo=HKUDS/nanobot&max=100&columns=12&updated=20260210" alt="Contributors" />
</a> </a>
## ⭐ Star History
<div align="center">
<a href="https://star-history.com/#HKUDS/nanobot&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=HKUDS/nanobot&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=HKUDS/nanobot&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=HKUDS/nanobot&type=Date" style="border-radius: 15px; box-shadow: 0 0 30px rgba(0, 217, 255, 0.3);" />
</picture>
</a>
</div>
<p align="center"> <p align="center">
<em> Thanks for visiting ✨ nanobot!</em><br><br> <em> Thanks for visiting ✨ nanobot!</em><br><br>
<img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.nanobot&style=for-the-badge&color=00d4ff" alt="Views"> <img src="https://visitor-badge.laobi.icu/badge?page_id=HKUDS.nanobot&style=for-the-badge&color=00d4ff" alt="Views">

View File

@ -21,6 +21,11 @@ We aim to respond to security reports within 48 hours.
**CRITICAL**: Never commit API keys to version control. **CRITICAL**: Never commit API keys to version control.
```bash ```bash
# ✅ Best: Use environment variable references in config (never writes the key to disk)
# In ~/.nanobot/config.json:
# "apiKey": "${ANTHROPIC_API_KEY}"
# Then supply the key at runtime via env var or Docker secret.
# ✅ Good: Store in config file with restricted permissions # ✅ Good: Store in config file with restricted permissions
chmod 600 ~/.nanobot/config.json chmod 600 ~/.nanobot/config.json
@ -28,9 +33,9 @@ chmod 600 ~/.nanobot/config.json
``` ```
**Recommendations:** **Recommendations:**
- Store API keys in `~/.nanobot/config.json` with file permissions set to `0600` - **Prefer environment variable references** (`${VAR}`) in config — the config file stores the `${VAR}` placeholder, and the plaintext value only exists in memory at runtime. See [Configuration: Environment Variables for Secrets](https://nanobot.wiki/docs/latest/use-nanobot/configuration/#environment-variables-for-secrets) for details.
- Consider using environment variables for sensitive keys - When plaintext keys are stored in `~/.nanobot/config.json`, set file permissions to `0600` (`chmod 600`)
- Use OS keyring/credential manager for production deployments - Consider using an OS keyring/credential manager for production deployments
- Rotate API keys regularly - Rotate API keys regularly
- Use separate API keys for development and production - Use separate API keys for development and production
@ -48,7 +53,7 @@ chmod 600 ~/.nanobot/config.json
}, },
"whatsapp": { "whatsapp": {
"enabled": true, "enabled": true,
"allowFrom": ["+1234567890"] "allowFrom": ["1234567890"]
} }
} }
} }
@ -57,7 +62,7 @@ chmod 600 ~/.nanobot/config.json
**Security Notes:** **Security Notes:**
- In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all users. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default — set `["*"]` to explicitly allow everyone. - In `v0.1.4.post3` and earlier, an empty `allowFrom` allowed all users. Since `v0.1.4.post4`, empty `allowFrom` denies all access by default — set `["*"]` to explicitly allow everyone.
- Get your Telegram user ID from `@userinfobot` - Get your Telegram user ID from `@userinfobot`
- Use full phone numbers with country code for WhatsApp - Use WhatsApp sender IDs as full phone numbers with country code and no leading `+`
- Review access logs regularly for unauthorized access attempts - Review access logs regularly for unauthorized access attempts
### 3. Shell Command Execution ### 3. Shell Command Execution
@ -107,12 +112,12 @@ File operations have path traversal protection, but:
**API Calls:** **API Calls:**
- All external API calls use HTTPS by default - All external API calls use HTTPS by default
- Timeouts are configured to prevent hanging requests - Timeouts are configured to prevent hanging requests
- The OpenAI-compatible API server must set `api.api_key` when binding to `0.0.0.0` or `::`; otherwise startup fails to prevent unauthenticated network access
- Consider using a firewall to restrict outbound connections if needed - Consider using a firewall to restrict outbound connections if needed
**WhatsApp Bridge:** **WhatsApp:**
- The bridge binds to `127.0.0.1:3001` (localhost only, not accessible from external network) - Keep the neonize session database under `~/.nanobot/whatsapp-auth` secure (mode 0700).
- Set `bridgeToken` in config to enable shared-secret authentication between Python and Node.js - Use `nanobot channels login whatsapp --force` to remove and recreate the local session database when rotating linked devices.
- Keep authentication data in `~/.nanobot/whatsapp-auth` secure (mode 0700)
### 6. Dependency Security ### 6. Dependency Security
@ -127,17 +132,9 @@ pip-audit
pip install --upgrade nanobot-ai pip install --upgrade nanobot-ai
``` ```
For Node.js dependencies (WhatsApp bridge):
```bash
cd bridge
npm audit
npm audit fix
```
**Important Notes:** **Important Notes:**
- Keep `litellm` updated to the latest version for security fixes - Keep `litellm` updated to the latest version for security fixes
- We've updated `ws` to `>=8.17.1` to fix DoS vulnerability - Run `pip-audit` regularly after enabling the channels used in production; their manifest-declared dependencies are installed into the same environment
- Run `pip-audit` or `npm audit` regularly
- Subscribe to security advisories for nanobot and its dependencies - Subscribe to security advisories for nanobot and its dependencies
### 7. Production Deployment ### 7. Production Deployment
@ -238,14 +235,14 @@ If you suspect a security breach:
✅ **Secure Communication** ✅ **Secure Communication**
- HTTPS for all external API calls - HTTPS for all external API calls
- TLS for Telegram API - TLS for Telegram API
- WhatsApp bridge: localhost-only binding + optional token auth - WhatsApp session secrets stay in the local session database
## Known Limitations ## Known Limitations
⚠️ **Current Security Limitations:** ⚠️ **Current Security Limitations:**
1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed) 1. **No Rate Limiting** - Users can send unlimited messages (add your own if needed)
2. **Plain Text Config** - API keys stored in plain text (use keyring for production) 2. **Plain Text Config** - API keys stored in plain text in `config.json` (prefer `${VAR}` env references when possible, or use keyring for production)
3. **No Session Management** - No automatic session expiry 3. **No Session Management** - No automatic session expiry
4. **Limited Command Filtering** - Only blocks obvious dangerous patterns (enable the bwrap sandbox for kernel-level isolation on Linux) 4. **Limited Command Filtering** - Only blocks obvious dangerous patterns (enable the bwrap sandbox for kernel-level isolation on Linux)
5. **No Audit Trail** - Limited security event logging (enhance as needed) 5. **No Audit Trail** - Limited security event logging (enhance as needed)
@ -268,7 +265,7 @@ Before deploying nanobot:
## Updates ## Updates
**Last Updated**: 2026-04-05 **Last Updated**: 2026-07-21
For the latest security updates and announcements, check: For the latest security updates and announcements, check:
- GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories - GitHub Security Advisories: https://github.com/HKUDS/nanobot/security/advisories

View File

@ -1,26 +0,0 @@
{
"name": "nanobot-whatsapp-bridge",
"version": "0.1.0",
"description": "WhatsApp bridge for nanobot using Baileys",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsc && node dist/index.js"
},
"dependencies": {
"@whiskeysockets/baileys": "7.0.0-rc.9",
"ws": "^8.17.1",
"qrcode-terminal": "^0.12.0",
"pino": "^9.0.0"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/ws": "^8.5.10",
"typescript": "^5.4.0"
},
"engines": {
"node": ">=20.0.0"
}
}

View File

@ -1,56 +0,0 @@
#!/usr/bin/env node
/**
* nanobot WhatsApp Bridge
*
* This bridge connects WhatsApp Web to nanobot's Python backend
* via WebSocket. It handles authentication, message forwarding,
* and reconnection logic.
*
* Usage:
* npm run build && npm start
*
* Or with custom settings:
* BRIDGE_PORT=3001 AUTH_DIR=~/.nanobot/whatsapp npm start
*/
// Polyfill crypto for Baileys in ESM
import { webcrypto } from 'crypto';
if (!globalThis.crypto) {
(globalThis as any).crypto = webcrypto;
}
import { BridgeServer } from './server.js';
import { homedir } from 'os';
import { join } from 'path';
const PORT = parseInt(process.env.BRIDGE_PORT || '3001', 10);
const AUTH_DIR = process.env.AUTH_DIR || join(homedir(), '.nanobot', 'whatsapp-auth');
const TOKEN = process.env.BRIDGE_TOKEN?.trim();
if (!TOKEN) {
console.error('BRIDGE_TOKEN is required. Start the bridge via nanobot so it can provision a local secret automatically.');
process.exit(1);
}
console.log('🐈 nanobot WhatsApp Bridge');
console.log('========================\n');
const server = new BridgeServer(PORT, AUTH_DIR, TOKEN);
// Handle graceful shutdown
process.on('SIGINT', async () => {
console.log('\n\nShutting down...');
await server.stop();
process.exit(0);
});
process.on('SIGTERM', async () => {
await server.stop();
process.exit(0);
});
// Start the server
server.start().catch((error) => {
console.error('Failed to start bridge:', error);
process.exit(1);
});

View File

@ -1,155 +0,0 @@
/**
* WebSocket server for Python-Node.js bridge communication.
* Security: binds to 127.0.0.1 only; requires BRIDGE_TOKEN auth; rejects browser Origin headers.
*/
import { WebSocketServer, WebSocket } from 'ws';
import { WhatsAppClient, InboundMessage } from './whatsapp.js';
interface SendCommand {
type: 'send';
to: string;
text: string;
}
interface SendMediaCommand {
type: 'send_media';
to: string;
filePath: string;
mimetype: string;
caption?: string;
fileName?: string;
}
type BridgeCommand = SendCommand | SendMediaCommand;
interface BridgeMessage {
type: 'message' | 'status' | 'qr' | 'error';
[key: string]: unknown;
}
export class BridgeServer {
private wss: WebSocketServer | null = null;
private wa: WhatsAppClient | null = null;
private clients: Set<WebSocket> = new Set();
constructor(private port: number, private authDir: string, private token: string) {}
async start(): Promise<void> {
if (!this.token.trim()) {
throw new Error('BRIDGE_TOKEN is required');
}
// Bind to localhost only — never expose to external network
this.wss = new WebSocketServer({
host: '127.0.0.1',
port: this.port,
verifyClient: (info, done) => {
const origin = info.origin || info.req.headers.origin;
if (origin) {
console.warn(`Rejected WebSocket connection with Origin header: ${origin}`);
done(false, 403, 'Browser-originated WebSocket connections are not allowed');
return;
}
done(true);
},
});
console.log(`🌉 Bridge server listening on ws://127.0.0.1:${this.port}`);
console.log('🔒 Token authentication enabled');
// Initialize WhatsApp client
this.wa = new WhatsAppClient({
authDir: this.authDir,
onMessage: (msg) => this.broadcast({ type: 'message', ...msg }),
onQR: (qr) => this.broadcast({ type: 'qr', qr }),
onStatus: (status) => this.broadcast({ type: 'status', status }),
});
// Handle WebSocket connections
this.wss.on('connection', (ws) => {
// Require auth handshake as first message
const timeout = setTimeout(() => ws.close(4001, 'Auth timeout'), 5000);
ws.once('message', (data) => {
clearTimeout(timeout);
try {
const msg = JSON.parse(data.toString());
if (msg.type === 'auth' && msg.token === this.token) {
console.log('🔗 Python client authenticated');
this.setupClient(ws);
} else {
ws.close(4003, 'Invalid token');
}
} catch {
ws.close(4003, 'Invalid auth message');
}
});
});
// Connect to WhatsApp
await this.wa.connect();
}
private setupClient(ws: WebSocket): void {
this.clients.add(ws);
ws.on('message', async (data) => {
try {
const cmd = JSON.parse(data.toString()) as BridgeCommand;
await this.handleCommand(cmd);
ws.send(JSON.stringify({ type: 'sent', to: cmd.to }));
} catch (error) {
console.error('Error handling command:', error);
ws.send(JSON.stringify({ type: 'error', error: String(error) }));
}
});
ws.on('close', () => {
console.log('🔌 Python client disconnected');
this.clients.delete(ws);
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
this.clients.delete(ws);
});
}
private async handleCommand(cmd: BridgeCommand): Promise<void> {
if (!this.wa) return;
if (cmd.type === 'send') {
await this.wa.sendMessage(cmd.to, cmd.text);
} else if (cmd.type === 'send_media') {
await this.wa.sendMedia(cmd.to, cmd.filePath, cmd.mimetype, cmd.caption, cmd.fileName);
}
}
private broadcast(msg: BridgeMessage): void {
const data = JSON.stringify(msg);
for (const client of this.clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(data);
}
}
}
async stop(): Promise<void> {
// Close all client connections
for (const client of this.clients) {
client.close();
}
this.clients.clear();
// Close WebSocket server
if (this.wss) {
this.wss.close();
this.wss = null;
}
// Disconnect WhatsApp
if (this.wa) {
await this.wa.disconnect();
this.wa = null;
}
}
}

View File

@ -1,3 +0,0 @@
declare module 'qrcode-terminal' {
export function generate(text: string, options?: { small?: boolean }): void;
}

View File

@ -1,360 +0,0 @@
/**
* WhatsApp client wrapper using Baileys.
* Based on OpenClaw's working implementation.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
import makeWASocket, {
DisconnectReason,
useMultiFileAuthState,
fetchLatestBaileysVersion,
makeCacheableSignalKeyStore,
downloadMediaMessage,
extractMessageContent as baileysExtractMessageContent,
} from '@whiskeysockets/baileys';
import { Boom } from '@hapi/boom';
import qrcode from 'qrcode-terminal';
import pino from 'pino';
import { readFile, writeFile, mkdir } from 'fs/promises';
import { join, basename, resolve, sep } from 'path';
import { randomBytes } from 'crypto';
const VERSION = '0.1.0';
export interface InboundMessage {
id: string;
sender: string;
pn: string;
participant?: string;
content: string;
timestamp: number;
isGroup: boolean;
isForwarded?: boolean;
wasMentioned?: boolean;
isReplyToBot?: boolean;
media?: string[];
}
export interface WhatsAppClientOptions {
authDir: string;
onMessage: (msg: InboundMessage) => void;
onQR: (qr: string) => void;
onStatus: (status: string) => void;
}
export class WhatsAppClient {
private sock: any = null;
private options: WhatsAppClientOptions;
private reconnecting = false;
constructor(options: WhatsAppClientOptions) {
this.options = options;
}
private normalizeJid(jid: string | undefined | null): string {
return (jid || '').trim().toLowerCase().replace(/:\d+(?=@)/g, '');
}
private selfJids(): Set<string> {
return new Set(
[this.sock?.user?.id, this.sock?.user?.lid, this.sock?.user?.jid]
.map((jid) => this.normalizeJid(jid))
.filter(Boolean),
);
}
private messageContextInfos(msg: any): any[] {
const unwrapped = baileysExtractMessageContent(msg?.message);
const containers = [msg?.message, unwrapped];
const infos = containers.flatMap((message) => [
message?.extendedTextMessage?.contextInfo,
message?.imageMessage?.contextInfo,
message?.videoMessage?.contextInfo,
message?.documentMessage?.contextInfo,
message?.audioMessage?.contextInfo,
]);
return infos.filter(Boolean);
}
private botAddressing(msg: any): { wasMentioned: boolean; isReplyToBot: boolean } {
if (!msg?.key?.remoteJid?.endsWith('@g.us')) {
return { wasMentioned: false, isReplyToBot: false };
}
const selfIds = this.selfJids();
const contextInfos = this.messageContextInfos(msg);
const mentioned = contextInfos.flatMap((info) => (
Array.isArray(info?.mentionedJid) ? info.mentionedJid : []
));
const wasMentioned = mentioned.some((jid: string) => selfIds.has(this.normalizeJid(jid)));
const isReplyToBot = contextInfos.some((info) => {
const quotedParticipant = this.normalizeJid(info?.participant);
return Boolean(info?.stanzaId && quotedParticipant && selfIds.has(quotedParticipant));
});
return { wasMentioned, isReplyToBot };
}
private isForwarded(msg: any): boolean {
return this.messageContextInfos(msg).some((info) => Boolean(info?.isForwarded));
}
async connect(): Promise<void> {
const logger = pino({ level: 'silent' });
const { state, saveCreds } = await useMultiFileAuthState(this.options.authDir);
const { version } = await fetchLatestBaileysVersion();
console.log(`Using Baileys version: ${version.join('.')}`);
// Record startup time — messages older than this will be ignored
// to avoid replaying history on reconnect
const startupTimestamp = Math.floor(Date.now() / 1000);
// Create socket following OpenClaw's pattern
this.sock = makeWASocket({
auth: {
creds: state.creds,
keys: makeCacheableSignalKeyStore(state.keys, logger),
},
version,
logger,
printQRInTerminal: false,
browser: ['nanobot', 'cli', VERSION],
syncFullHistory: false,
markOnlineOnConnect: false,
});
// Handle WebSocket errors
if (this.sock.ws && typeof this.sock.ws.on === 'function') {
this.sock.ws.on('error', (err: Error) => {
console.error('WebSocket error:', err.message);
});
}
// Handle connection updates
this.sock.ev.on('connection.update', async (update: any) => {
const { connection, lastDisconnect, qr } = update;
if (qr) {
// Display QR code in terminal
console.log('\n📱 Scan this QR code with WhatsApp (Linked Devices):\n');
qrcode.generate(qr, { small: true });
this.options.onQR(qr);
}
if (connection === 'close') {
const statusCode = (lastDisconnect?.error as Boom)?.output?.statusCode;
const shouldReconnect = statusCode !== DisconnectReason.loggedOut;
console.log(`Connection closed. Status: ${statusCode}, Will reconnect: ${shouldReconnect}`);
this.options.onStatus('disconnected');
if (shouldReconnect && !this.reconnecting) {
this.reconnecting = true;
console.log('Reconnecting in 5 seconds...');
setTimeout(() => {
this.reconnecting = false;
this.connect();
}, 5000);
}
} else if (connection === 'open') {
console.log('✅ Connected to WhatsApp');
this.options.onStatus('connected');
}
});
// Save credentials on update
this.sock.ev.on('creds.update', saveCreds);
// Handle incoming messages
this.sock.ev.on('messages.upsert', async ({ messages, type }: { messages: any[]; type: string }) => {
if (type !== 'notify') return;
for (const msg of messages) {
if (msg.key.fromMe) continue;
if (msg.key.remoteJid === 'status@broadcast') continue;
// Drop messages older than startup time (avoid replaying history on reconnect)
const msgTimestamp = msg.messageTimestamp as number;
if (msgTimestamp && msgTimestamp < startupTimestamp) continue;
// Send read receipt (blue check) immediately
try {
await this.sock!.readMessages([msg.key]);
} catch (e) {
// Non-fatal: log but don't block message processing
console.error('Failed to send read receipt:', (e as Error).message);
}
const unwrapped = baileysExtractMessageContent(msg.message);
if (!unwrapped) continue;
const content = this.getTextContent(unwrapped);
let fallbackContent: string | null = null;
const mediaPaths: string[] = [];
if (unwrapped.imageMessage) {
fallbackContent = '[Image]';
const path = await this.downloadMedia(msg, unwrapped.imageMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.documentMessage) {
fallbackContent = '[Document]';
const path = await this.downloadMedia(msg, unwrapped.documentMessage.mimetype ?? undefined,
unwrapped.documentMessage.fileName ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.videoMessage) {
fallbackContent = '[Video]';
const path = await this.downloadMedia(msg, unwrapped.videoMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.audioMessage) {
fallbackContent = '[Voice Message]';
const path = await this.downloadMedia(msg, unwrapped.audioMessage.mimetype ?? undefined);
if (path) mediaPaths.push(path);
} else if (unwrapped.contactMessage) {
// Single shared contact
const displayName = unwrapped.contactMessage.displayName || '';
const vcard = unwrapped.contactMessage.vcard || '';
fallbackContent = `[Contact: ${displayName}]\n${vcard}`;
} else if (unwrapped.contactsArrayMessage) {
// Multiple shared contacts
const vcards = unwrapped.contactsArrayMessage.contacts || [];
const parts = vcards.map((c: any) => {
const name = c.displayName || '';
const vc = c.vcard || '';
return `[Contact: ${name}]\n${vc}`;
});
fallbackContent = parts.join('\n\n');
}
const isForwarded = this.isForwarded(msg);
const finalContent = content || (mediaPaths.length === 0 ? fallbackContent : '') || '';
if (!finalContent && mediaPaths.length === 0) continue;
const isGroup = msg.key.remoteJid?.endsWith('@g.us') || false;
const { wasMentioned, isReplyToBot } = this.botAddressing(msg);
this.options.onMessage({
id: msg.key.id || '',
sender: msg.key.remoteJid || '',
pn: msg.key.remoteJidAlt || '',
...(isGroup && msg.key.participant ? { participant: msg.key.participant } : {}),
content: finalContent,
timestamp: msg.messageTimestamp as number,
isGroup,
...(isForwarded ? { isForwarded } : {}),
...(isGroup ? { wasMentioned: wasMentioned || isReplyToBot, isReplyToBot } : {}),
...(mediaPaths.length > 0 ? { media: mediaPaths } : {}),
});
}
});
}
private async downloadMedia(msg: any, mimetype?: string, fileName?: string): Promise<string | null> {
try {
const mediaDir = join(this.options.authDir, '..', 'media');
await mkdir(mediaDir, { recursive: true });
const buffer = await downloadMediaMessage(msg, 'buffer', {}) as Buffer;
let outFilename: string;
if (fileName) {
const safeName = basename(fileName).replace(/[^a-zA-Z0-9._-]/g, '_');
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}_${safeName}`;
} else {
const mime = mimetype || 'application/octet-stream';
const ext = '.' + (mime.split('/').pop()?.split(';')[0] || 'bin');
outFilename = `wa_${Date.now()}_${randomBytes(4).toString('hex')}${ext}`;
}
const filepath = resolve(mediaDir, outFilename);
if (!filepath.startsWith(resolve(mediaDir) + sep)) {
throw new Error(`Path traversal blocked: ${outFilename}`);
}
await writeFile(filepath, buffer);
return filepath;
} catch (err) {
console.error('Failed to download media:', err);
return null;
}
}
private getTextContent(message: any): string | null {
// Text message
if (message.conversation) {
return message.conversation;
}
// Extended text (reply, link preview)
if (message.extendedTextMessage?.text) {
return message.extendedTextMessage.text;
}
// Image with optional caption
if (message.imageMessage) {
return message.imageMessage.caption || '';
}
// Video with optional caption
if (message.videoMessage) {
return message.videoMessage.caption || '';
}
// Document with optional caption
if (message.documentMessage) {
return message.documentMessage.caption || '';
}
// Voice/Audio message
if (message.audioMessage) {
return `[Voice Message]`;
}
return null;
}
async sendMessage(to: string, text: string): Promise<void> {
if (!this.sock) {
throw new Error('Not connected');
}
await this.sock.sendMessage(to, { text });
}
async sendMedia(
to: string,
filePath: string,
mimetype: string,
caption?: string,
fileName?: string,
): Promise<void> {
if (!this.sock) {
throw new Error('Not connected');
}
const buffer = await readFile(filePath);
const category = mimetype.split('/')[0];
if (category === 'image') {
await this.sock.sendMessage(to, { image: buffer, caption: caption || undefined, mimetype });
} else if (category === 'video') {
await this.sock.sendMessage(to, { video: buffer, caption: caption || undefined, mimetype });
} else if (category === 'audio') {
await this.sock.sendMessage(to, { audio: buffer, mimetype });
} else {
const name = fileName || basename(filePath);
await this.sock.sendMessage(to, { document: buffer, mimetype, fileName: name });
}
}
async disconnect(): Promise<void> {
if (this.sock) {
this.sock.end(undefined);
this.sock = null;
}
}
}

View File

@ -1,16 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 MiB

62
conftest.py Normal file
View File

@ -0,0 +1,62 @@
"""Cross-suite test infrastructure."""
from __future__ import annotations
import os
import ssl
import sys
from collections.abc import Iterator
import certifi
import pytest
from loguru import logger
@pytest.fixture(autouse=True)
def _isolate_nanobot_log_activation() -> Iterator[None]:
"""Keep CLI log settings from leaking into later tests in the same process."""
logger.enable("nanobot")
try:
yield
finally:
logger.enable("nanobot")
@pytest.fixture(scope="session", autouse=True)
def _use_windows_system_ca_for_default_http_clients() -> Iterator[None]:
"""Avoid reparsing certifi's CA bundle for every offline HTTP client.
Loading certifi takes roughly 0.7 seconds per client on Windows. The test
suite constructs hundreds of clients while mocking their I/O. System roots
preserve certificate verification for accidental local requests; explicit
``cafile``, ``capath``, and ``cadata`` arguments still use the real loader.
"""
if sys.platform != "win32":
yield
return
original = ssl.create_default_context
certifi_path = os.path.normcase(os.path.abspath(certifi.where()))
def create_default_context(
purpose: ssl.Purpose = ssl.Purpose.SERVER_AUTH,
*,
cafile: str | None = None,
capath: str | None = None,
cadata: str | bytes | None = None,
) -> ssl.SSLContext:
requested_path = os.path.normcase(os.path.abspath(cafile)) if cafile else None
if requested_path == certifi_path and capath is None and cadata is None:
return original(purpose)
return original(
purpose,
cafile=cafile,
capath=capath,
cadata=cadata,
)
ssl.create_default_context = create_default_context
try:
yield
finally:
ssl.create_default_context = original

16
docker-compose.bwrap.yml Normal file
View File

@ -0,0 +1,16 @@
x-bwrap-security: &bwrap-security
cap_add:
- SYS_ADMIN
security_opt:
- apparmor=unconfined
- seccomp=unconfined
services:
nanobot-gateway:
<<: *bwrap-security
nanobot-api:
<<: *bwrap-security
nanobot-cli:
<<: *bwrap-security

View File

@ -2,15 +2,12 @@ x-common-config: &common-config
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args:
NANOBOT_CHANNELS: ${NANOBOT_CHANNELS:-whatsapp}
volumes: volumes:
- ~/.nanobot:/home/nanobot/.nanobot - ~/.nanobot:/home/nanobot/.nanobot
cap_drop: cap_drop:
- ALL - ALL
cap_add:
- SYS_ADMIN
security_opt:
- apparmor=unconfined
- seccomp=unconfined
services: services:
nanobot-gateway: nanobot-gateway:
@ -19,7 +16,7 @@ services:
command: ["gateway"] command: ["gateway"]
restart: unless-stopped restart: unless-stopped
ports: ports:
- 18790:18790 - 127.0.0.1:18790:18790
- 8765:8765 - 8765:8765
deploy: deploy:
resources: resources:

View File

@ -1,108 +1,84 @@
# nanobot Docs # nanobot Documentation
For published release documentation, visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview). The pages in this directory track the current repository and may describe features that have not reached the published site yet. Use these docs to get a working agent first, then open a task guide only when you need the next capability. Source-level design and extension details are kept in the contributor section.
If you have never used a terminal or edited a config file before, start with [`start-without-technical-background.md`](./start-without-technical-background.md). Otherwise, start with [`quick-start.md`](./quick-start.md) and get one local `nanobot agent -m "Hello!"` reply working before connecting chat apps, WebUI, Docker, or custom tools. Repository docs follow the current source tree and can be newer than the latest package release. For published release docs, visit [nanobot.wiki](https://nanobot.wiki/docs/latest/getting-started/nanobot-overview).
Most JSON examples in these docs are snippets to merge into `~/.nanobot/config.json`, not full replacement files.
Provider examples are concrete walkthroughs, not rankings or endorsements. Use the provider whose key, endpoint, and model ID you actually control.
If you find a docs mistake, outdated command, or confusing step, please open an issue: <https://github.com/HKUDS/nanobot/issues>.
## Pick a Track
| You are | Start with | Then use |
|---|---|---|
| New to terminals and config files | [`start-without-technical-background.md`](./start-without-technical-background.md) | [`troubleshooting.md`](./troubleshooting.md) if the first reply fails |
| Comfortable pasting commands and JSON | [`quick-start.md`](./quick-start.md) | [`provider-cookbook.md`](./provider-cookbook.md) for pasteable provider setups |
| Operating a long-running bot | [`concepts.md`](./concepts.md) | [`chat-apps.md`](./chat-apps.md), [`webui.md`](./webui.md), and [`deployment.md`](./deployment.md) |
| Integrating or extending nanobot | [`architecture.md`](./architecture.md) | [`configuration.md`](./configuration.md), [`openai-api.md`](./openai-api.md), [`python-sdk.md`](./python-sdk.md), [`development.md`](./development.md), and [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
## Start Here ## Start Here
| Goal | Read | Outcome | | Your situation | Read this | You are done when... |
|---|---|---| |---|---|---|
| Start with no technical background | [`start-without-technical-background.md`](./start-without-technical-background.md) | One-command setup, terminal basics, config, API keys, and the first reply | | Terminals, Python, or API keys are new to you | [Beginner walkthrough](./start-without-technical-background.md) | The browser can send `Hello!` and receive a reply |
| Install and get the first reply | [`quick-start.md`](./quick-start.md) | A working CLI agent and a known-good config path | | You are comfortable running commands | [Install and Quick Start](./quick-start.md) | `nanobot status` is healthy and the WebUI or CLI can get one reply |
| Understand how the pieces fit | [`concepts.md`](./concepts.md) | Mental model for config, workspace, gateway, channels, tools, memory, and sessions | | Something already failed | [Troubleshooting](./troubleshooting.md) | You have isolated the problem to install, config, model, gateway, channel, or tool access |
| Choose or change a model provider | [`providers.md`](./providers.md) | Correct provider/model pairing without reading the full config reference |
| Copy a provider setup recipe | [`provider-cookbook.md`](./provider-cookbook.md) | Pasteable OpenRouter, OpenAI, Anthropic, local model, fallback, and Langfuse setups |
| Fix a first-run or runtime problem | [`troubleshooting.md`](./troubleshooting.md) | A diagnosis order and targeted checks for common failures |
## After the First Reply Works The recommended first-run path is:
Do not configure everything at once. Pick one next surface: 1. Install nanobot.
2. Let the installer open `nanobot webui` on a fresh local desktop.
3. Configure a provider and model in **Settings → Models**.
4. Send `Hello!` before configuring anything else.
If a local `nanobot agent` session can already answer normally, you can also ask nanobot to help configure itself: have it read the relevant docs, inspect your current config, make one specific next change, and tell you when to run `/restart`. Most people do not need to edit JSON for the first run. The WebUI handles the initial provider, model, and local browser settings. SSH, headless, existing-config, and older-release installs retain `nanobot onboard --wizard` as a terminal fallback. After the WebUI opens, use **Settings** for models and built-in capabilities, **Settings → Channels** for chat apps, and **Apps** for CLI App or MCP integrations.
| Next goal | Read | First check | ## Add One Capability
|---|---|---|
| Use nanobot in a browser | [`webui.md`](./webui.md) | Enable WebSocket, run `nanobot gateway`, open `http://127.0.0.1:8765` |
| Talk through a chat app | [`chat-apps.md`](./chat-apps.md) | Merge one channel snippet, run `nanobot channels status`, keep `nanobot gateway` running |
| Change provider or add fallbacks | [`provider-cookbook.md`](./provider-cookbook.md) | Keep `modelPresets` named and set `agents.defaults.modelPreset` |
| Call nanobot from Python | [`python-sdk.md`](./python-sdk.md) | Reuse the same config/workspace from code, then run or stream one agent turn |
| Understand before operating long-term | [`concepts.md`](./concepts.md) | Know what config, workspace, gateway, sessions, memory, and tools mean |
| Diagnose a new failure | [`troubleshooting.md`](./troubleshooting.md) | Start with `nanobot status`, then `nanobot agent -m "Hello!"` |
## Use nanobot Pick the row that matches what you want to accomplish next:
| Goal | Read | Outcome | | Goal | Guide |
|---|---|---| |---|---|
| Open the bundled browser UI | [`webui.md`](./webui.md) | WebUI on port `8765`, chat workspace, Apps, Skills, Automations, and settings | | Learn the browser workbench | [WebUI](./webui.md) |
| Connect Telegram, Discord, WeChat, Slack, and other apps | [`chat-apps.md`](./chat-apps.md) | A gateway-backed chat channel with access control | | Connect Telegram, Discord, Slack, Feishu, WeChat, Email, or another chat app | [Chat Apps](./chat-apps.md) |
| Use slash commands and periodic tasks | [`chat-commands.md`](./chat-commands.md) | Pairing, model presets, heartbeat tasks, and chat-side controls | | Choose a hosted, OAuth, company, or local model | [Provider Cookbook](./provider-cookbook.md) |
| Generate images | [`image-generation.md`](./image-generation.md) | Image provider config, WebUI image mode, and artifact behavior | | Add model fallbacks | [Configure Model Fallback](./guides/configure-model-fallback.md) |
| Run several isolated bots | [`multiple-instances.md`](./multiple-instances.md) | Separate configs, workspaces, ports, and sessions | | Enable web search | [Configure Web Search](./guides/configure-web-search.md) |
| Deploy outside a terminal | [`deployment.md`](./deployment.md) | Docker, systemd user services, and macOS LaunchAgent setup | | Add an MCP tool server | [Configure MCP Tools](./guides/configure-mcp-tools.md) |
| Join agent communities | [`agent-social-network.md`](./agent-social-network.md) | External agent-community setup | | Generate images | [Image Generation](./image-generation.md) |
| Schedule work or create a local trigger | [Automations](./automations.md) |
| Understand and manage long-term memory | [Memory](./memory.md) |
| Run nanobot continuously | [Deployment](./deployment.md) |
| Run separate bots or workspaces | [Multiple Instances](./multiple-instances.md) |
| Call nanobot from Python | [Python SDK](./python-sdk.md) |
| Expose an OpenAI-compatible endpoint | [OpenAI-Compatible API](./openai-api.md) |
For shorter, outcome-focused walkthroughs, browse the [task guide index](./guides/README.md).
## Operate nanobot
| Need | Read |
|---|---|
| Commands and flags | [CLI Reference](./cli-reference.md) |
| In-chat slash commands | [In-Chat Commands](./chat-commands.md) |
| Config, workspace, gateway, sessions, tools, and memory in plain language | [Concepts](./concepts.md) |
| Provider/model matching and selection | [Providers and Models](./providers.md) |
| Setup and runtime diagnosis | [Troubleshooting](./troubleshooting.md) |
| Older development highlights | [Release Archive](./release-archive.md) |
## Reference ## Reference
| Area | Read | Best for | Use reference pages to look up an exact option after you know what you are trying to configure:
|---|---|---|
| Full configuration schema | [`configuration.md`](./configuration.md) | Exact fields, defaults, provider tables, web tools, MCP, security, and runtime options |
| CLI commands | [`cli-reference.md`](./cli-reference.md) | Command names, common flags, and entrypoints |
| Architecture | [`architecture.md`](./architecture.md) | Source-level runtime map for core flow, providers, channels, tools, WebUI, memory, security, and extension points |
| Development | [`development.md`](./development.md) | Contributor notes for adding providers and transcription adapters |
| Memory | [`memory.md`](./memory.md) | Session history, Dream consolidation, memory files, and versioning |
| Observability | [`configuration.md#langfuse-observability`](./configuration.md#langfuse-observability) | Langfuse tracing setup and required environment variables |
| WebSocket protocol | [`websocket.md`](./websocket.md) | Custom clients, token issuance, multiplexed chats, media, and protocol events |
| OpenAI-compatible API | [`openai-api.md`](./openai-api.md) | `/v1/chat/completions`, `/v1/models`, file uploads, and SDK-compatible usage |
| Python SDK | [`python-sdk.md`](./python-sdk.md) | SDK 101, sessions, streaming, model overrides, runtime helpers, and hooks |
| Runtime self-inspection | [`my-tool.md`](./my-tool.md) | Inspecting and tuning the current agent run |
## Fast Lookup | Area | Reference |
| Need | Jump to |
|---|---| |---|---|
| Provider/model resolution order | [`providers.md#provider-resolution`](./providers.md#provider-resolution) | | Every configuration field and default | [Configuration](./configuration.md) |
| Model presets and fallback chains | [`providers.md#model-presets`](./providers.md#model-presets) and [`providers.md#fallback-models`](./providers.md#fallback-models) | | Provider and model behavior | [Providers and Models](./providers.md) |
| Langfuse environment variables | [`configuration.md#langfuse-observability`](./configuration.md#langfuse-observability) | | Chat channel prerequisites and manual JSON | [Chat Apps](./chat-apps.md) |
| WebSocket/WebUI protocol details | [`websocket.md`](./websocket.md) | | WebSocket authentication and wire protocol | [WebSocket](./websocket.md) |
| OpenAI-compatible API usage | [`openai-api.md`](./openai-api.md) | | Python SDK classes, events, sessions, and hooks | [Python SDK](./python-sdk.md) |
| Python SDK usage | [`python-sdk.md`](./python-sdk.md) | | OpenAI-compatible HTTP routes and payloads | [OpenAI-Compatible API](./openai-api.md) |
| Multiple configs, workspaces, and ports | [`multiple-instances.md`](./multiple-instances.md) | | Runtime self-inspection and tuning | [My Tool](./my-tool.md) |
| Security, sandboxing, and SSRF controls | [`configuration.md#security`](./configuration.md#security) |
| Channel plugin development | [`channel-plugin-guide.md`](./channel-plugin-guide.md) |
## Extend nanobot Configuration examples are usually snippets to merge into `~/.nanobot/config.json`, not complete replacement files. The docs use camelCase because nanobot writes config that way. Keep real API keys, bot tokens, and passwords out of issues and public logs.
| Goal | Read | Outcome | ## Extend or Contribute
|---|---|---|
| Add a provider or transcription adapter | [`development.md`](./development.md) | A registry/schema-aligned implementation path |
| Add a chat channel plugin | [`channel-plugin-guide.md`](./channel-plugin-guide.md) | A packaged channel discovered through entry points |
| Add custom MCP servers | [`configuration.md#mcp-model-context-protocol`](./configuration.md#mcp-model-context-protocol) | External tools exposed to the agent through MCP |
| Tune tool safety | [`configuration.md#security`](./configuration.md#security) | Shell sandboxing, workspace restriction, and SSRF policy |
## Reading Strategy These pages explain implementation and extension points. You do not need them to install or operate nanobot.
Use the docs in this order when you are unsure where to go: | Goal | Read |
|---|---|
| Understand source ownership and runtime flow | [Architecture](./architecture.md) |
| Set up a development environment | [Development](./development.md) and [CONTRIBUTING.md](../CONTRIBUTING.md) |
| Add a channel package | [Channel Package Guide](./channel-package-guide.md) |
| Build the WebUI source | [WebUI Development](../webui/README.md) |
1. If terminal commands or config files are new to you, [`start-without-technical-background.md`](./start-without-technical-background.md) explains the setup words and uses one concrete provider example so there is only one decision at a time. If a command or screen no longer matches these docs, please [open an issue](https://github.com/HKUDS/nanobot/issues) with your nanobot version, operating system, and the page that needs correction.
2. [`quick-start.md`](./quick-start.md) proves installation, config loading, and provider access.
3. [`concepts.md`](./concepts.md) explains the runtime model so later pages are easier to scan.
4. [`provider-cookbook.md`](./provider-cookbook.md) gives pasteable provider, fallback, local model, and Langfuse recipes.
5. A task guide, such as [`chat-apps.md`](./chat-apps.md), [`image-generation.md`](./image-generation.md), or [`deployment.md`](./deployment.md), gets one workflow working.
6. [`configuration.md`](./configuration.md) is the source of truth when you need a specific field, default value, or advanced option.
7. [`troubleshooting.md`](./troubleshooting.md) helps isolate whether a failure is install, config, provider, gateway, channel, or tool related.

View File

@ -1,10 +1,99 @@
# Agent Social Network # Agent Social Network
🐈 nanobot is capable of linking to the agent social network (agent community). **Just send one message and your nanobot joins automatically!** An agent social network lets a nanobot instance join an external agent community
or chat network as a bot identity. After joining, nanobot can receive messages
through that network, answer with its normal agent runtime, and use the same
workspace, tools, memory, and channel access controls that apply elsewhere.
| Platform | How to Join (send this message to your bot) | This page describes the current entry points and the safety model. Treat each
|----------|-------------| network as an external integration: only join networks you trust, keep owner
| [**Moltbook**](https://www.moltbook.com/) | `Read https://moltbook.com/skill.md and follow the instructions to join Moltbook` | approval narrow, and review the skill instructions before asking nanobot to
| [**ClawdChat**](https://clawdchat.ai/) | `Read https://clawdchat.ai/skill.md and follow the instructions to join ClawdChat` | follow them.
Simply send the command above to your nanobot (via CLI or any chat channel), and it will handle the rest. ## What is an agent social network?
In nanobot docs, an agent social network is an external community that publishes
setup instructions for nanobot-compatible agents. The setup usually lives in a
remote `skill.md` file. You send nanobot a message asking it to read that file
and follow the network's registration flow.
The external network is not part of nanobot core. nanobot provides the runtime:
model calls, tools, memory, sessions, and channel delivery.
> [!WARNING]
> Remote `skill.md` files are external instructions. Review them before asking
> nanobot to follow them, especially when file, shell, network, or chat-delivery
> tools are enabled. Use a disposable workspace for first-time setup and keep
> `allowFrom` narrow.
## What nanobot can do after joining
After setup, the exact behavior depends on the network, but the normal pattern
is:
- receive direct messages or community messages addressed to the bot
- reply through the configured network channel
- use normal nanobot tools allowed by your configuration
- keep session history for conversations that flow through the network
- use Dream memory if memory is enabled for the workspace
## Supported networks
| Platform | Join message to send to your bot |
|---|---|
| [Moltbook](https://www.moltbook.com/) | `Read https://moltbook.com/skill.md and follow the instructions to join Moltbook` |
| [ClawdChat](https://clawdchat.ai/) | `Read https://clawdchat.ai/skill.md and follow the instructions to join ClawdChat` |
Send the message from the CLI, WebUI, or an already configured chat channel.
nanobot will read the public setup instructions and perform the requested setup
using its available tools.
## Security model
- The remote setup instructions are external content. Read them yourself before
running the join prompt if the bot has file, shell, or network tools enabled.
- Keep `allowFrom` narrow on the channel you use for setup so only trusted users
can issue registration commands.
- Keep `tools.restrictToWorkspace` enabled unless the network setup explicitly
needs another path.
- Avoid `allowFrom: ["*"]` during setup unless the bot is isolated in a test
workspace.
- Store network tokens through environment variables when the integration
supports secrets.
## Example workflow
1. Confirm the local agent works:
```bash
nanobot agent -m "Hello!"
```
2. Open the WebUI or a trusted chat channel.
3. Send the join message for the network you want.
4. Restart the gateway if the setup changes channel configuration:
```bash
nanobot gateway
```
5. Send a test message through the external network and confirm the session is
routed to the expected workspace and model.
## Limitations
- Network features, identity, and moderation rules are controlled by the
external network.
- Availability depends on the remote setup instructions remaining reachable.
- nanobot does not automatically audit remote skills for you.
- Some networks may require public callbacks, tokens, or channel-specific
account setup.
## Related docs
- [Chat Apps](./chat-apps.md)
- [Security configuration](./configuration.md#security)
- [Pairing](./configuration.md#pairing)
- [Runtime self-inspection](./my-tool.md)

View File

@ -81,11 +81,11 @@ Main files:
| Area | Files | | Area | Files |
|---|---| |---|---|
| Base channel contract | `nanobot/channels/base.py` | | Base channel contract | `nanobot/channels/base.py` |
| Built-in channels | `nanobot/channels/*.py` | | Channel packages | `nanobot/channels/<channel>/` |
| Discovery and lifecycle | `nanobot/channels/manager.py` | | Discovery and lifecycle | `nanobot/channels/manager.py` |
| WebSocket/WebUI channel | `nanobot/channels/websocket.py` | | WebSocket/WebUI channel | `nanobot/channels/websocket/` |
Channels are discovered through built-in module scanning and plugin entry points. A custom channel should follow [`channel-plugin-guide.md`](./channel-plugin-guide.md). Channels are discovered by scanning self-contained packages under `nanobot/channels/`. Add a channel by contributing one package that follows [`channel-package-guide.md`](./channel-package-guide.md).
## WebUI and Gateway ## WebUI and Gateway
@ -149,6 +149,24 @@ Defaults:
The schema accepts both camelCase and snake_case keys, but saves config with camelCase aliases. The schema accepts both camelCase and snake_case keys, but saves config with camelCase aliases.
### Agent-Owned State vs Effective Project Context
Runtime code distinguishes the configured agent workspace from the effective
project workspace carried by a session scope. They are often the same path, but
a WebUI chat may select a separate project:
| Concern | Path owner |
|---|---|
| Sessions, `SOUL.md`, `USER.md`, memory, and custom skills | Configured agent workspace |
| Project `AGENTS.md`, relative tool paths, and shell working directory | Effective project workspace |
| Workspace access mode and project metadata | Session workspace scope |
`ContextBuilder` combines project instructions with agent-owned profile and
memory. Filesystem and search tools use the project as their ordinary boundary
and receive only capability-specific read access to built-in/agent skills and
the exact agent history file. Keep those cross-root capabilities read-only and
explicit; do not treat the entire agent workspace as an allowed root.
## Memory and Sessions ## Memory and Sessions
Session history is the near-term conversation replay. Memory is the longer-term workspace state. Session history is the near-term conversation replay. Memory is the longer-term workspace state.
@ -181,7 +199,7 @@ When changing tools, channels, file access, WebUI workspace behavior, or network
| Extension | How | | Extension | How |
|---|---| |---|---|
| Provider | Add `ProviderSpec` in `providers/registry.py`, add schema field in `config/schema.py`, implement provider only if the generic backend is not enough | | Provider | Add `ProviderSpec` in `providers/registry.py`, add schema field in `config/schema.py`, implement provider only if the generic backend is not enough |
| Channel | Implement `BaseChannel`, expose an entry point, follow [`channel-plugin-guide.md`](./channel-plugin-guide.md) | | Channel | Export a `ChannelPlugin` descriptor, keep its runtime and optional setup surfaces in one package, and follow [`channel-package-guide.md`](./channel-package-guide.md) |
| Tool | Implement a tool under `agent/tools/` or expose a plugin entry point | | Tool | Implement a tool under `agent/tools/` or expose a plugin entry point |
| MCP | Add `tools.mcpServers` config | | MCP | Add `tools.mcpServers` config |
| Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` | | Skill | Add workspace skill files under `<workspace>/skills/` or built-in skills under `nanobot/skills/` |

201
docs/automations.md Normal file
View File

@ -0,0 +1,201 @@
# Automations
<!-- Meta description: Create, run, and manage nanobot scheduled automations, local triggers, and heartbeat-backed background checks. -->
Automations are agent turns that run later in a linked topic. Use them
when nanobot should do work without someone actively typing: reminders,
recurring checks, nightly summaries, CI follow-ups, local script reports, or
webhook-driven events.
Create automations from the chat channel or WebUI topic where the
result should appear. That lets nanobot keep the right session history,
workspace, and reply target.
## Choose an Automation Type
| Type | Starts from | Best for | Created with |
|---|---|---|---|
| Scheduled automation | Time, interval, or cron expression | Recurring reminders, scheduled summaries, one-time future tasks | Ask nanobot in the target topic to schedule it with the `cron` tool |
| Local trigger | A local `nanobot trigger ...` command | CI jobs, webhooks, shell scripts, generated reports | `/trigger <name>` in the target topic |
| Heartbeat | Protected system schedule | Quiet recurring checks that should only report useful results | Edit `<workspace>/HEARTBEAT.md` |
The two user-created automation types are scheduled automations and local
triggers. Heartbeat uses the same background service but is system-managed and
protected from normal automation edits.
## Before You Create One
Keep `nanobot gateway` running. The gateway owns background delivery for chat
apps, WebUI topics, scheduled automations, local triggers, heartbeat, and
Dream jobs.
Use the same workspace and config for the gateway and any process that sends
local trigger messages. If you run multiple nanobot instances, pass the matching
`--config` or `--workspace` option to `nanobot trigger`.
Create each automation from the target topic. An automation without a linked
topic cannot be enabled or run from the WebUI because nanobot would not know
where to deliver the turn.
## Scheduled Automations
Scheduled automations are created by the agent's `cron` tool. In practice, ask
nanobot from the target chat or WebUI topic:
```text
Every weekday at 9am, check open pull requests and summarize blockers here.
```
or:
```text
Tomorrow at 4pm, remind me to send the release notes.
```
The cron tool supports interval schedules, cron expressions, and one-time
scheduled tasks. Cron expressions can include an IANA timezone such as
`America/Vancouver`; otherwise nanobot uses the runtime default timezone.
Scheduled automations normally deliver the result back to the session where they
were created. Use them for work that should run on a predictable schedule and
report each run.
For background checks that should stay quiet unless there is something useful to
report, use heartbeat instead of a user-created scheduled automation.
## Local Triggers
Local triggers let a local script or external service send a message into a
specific nanobot session later.
Create the trigger from the chat or WebUI topic where future messages should
arrive:
```text
/trigger PR review
```
nanobot replies with a trigger ID and a command shaped like:
```bash
nanobot trigger trg_8K4P2Q9X "Review PR #4502"
```
Replace the quoted text with the message nanobot should receive. For generated
or longer content, pipe stdin:
```bash
generate-report | nanobot trigger trg_8K4P2Q9X
```
For multiple instances, use the same config or workspace selector as the
gateway:
```bash
nanobot trigger --config ./bot-a/config.json trg_8K4P2Q9X "Nightly report"
nanobot trigger --workspace ./bot-a/workspace trg_8K4P2Q9X "Nightly report"
```
nanobot does not provide a built-in public webhook receiver for local triggers.
If GitHub, CI, or another external system should wake nanobot, run your own
small webhook service and have it call `nanobot trigger` after it builds the
final message.
## Heartbeat
Heartbeat is for recurring workspace checks that should usually stay quiet. It
reads `<workspace>/HEARTBEAT.md`, executes active tasks, and sends only useful or
actionable results to the most recently active chat target.
Use heartbeat for checks such as "watch this repo for important failures" or
"periodically inspect this workspace and only tell me when action is needed." Use
a scheduled automation instead when every run should produce a visible reminder
or report.
Heartbeat is enabled by default when `nanobot gateway` starts. Configure it in
[`configuration.md#gateway-heartbeat`](./configuration.md#gateway-heartbeat).
## Manage Automations
Use the WebUI Automations view to:
- filter by all, active, paused, needs-attention, or system jobs;
- search by task name, message, trigger command, linked topic, schedule, or
status;
- sort by next run, last run, updated time, or name;
- run scheduled automations now;
- pause or resume, rename, or delete user-created automations;
- copy the CLI command for local triggers;
- inspect protected system automations without changing them.
Local triggers do not have a WebUI "Run now" action because each run needs a
message. Copy the `nanobot trigger ...` command from the WebUI and replace
`"message"` with the content that should be delivered.
## Delivery and Reliability
Automation delivery is workspace-local. Scheduled jobs and local trigger
deliveries use the same workspace as the gateway.
Local trigger messages are written to a durable queue. If the gateway is not
running yet, the message waits in that workspace. If the linked topic is
already running a turn, the trigger waits until the session becomes idle instead
of being injected into the active turn.
The local trigger queue is at-least-once, not exactly-once. If the gateway exits
after claiming a delivery but before the linked turn completes, the next gateway
start requeues that delivery. External scripts should make repeated trigger
messages safe. If the delivery reaches the agent and the turn fails, the
delivery is marked failed instead of retrying forever.
Each local trigger delivery writes an audit record under
`<workspace>/triggers/runs`. Run one gateway consumer per workspace; the local
queue is not a distributed multi-consumer queue.
## Common Patterns
For a nightly report, ask from the target topic:
```text
Every night at 9pm, review today's workspace changes and summarize anything I should handle tomorrow.
```
For a CI follow-up, create a trigger once:
```text
/trigger CI follow-up
```
Then have your CI or webhook adapter call:
```bash
nanobot trigger <trigger-id> "Build failed on main. Inspect the logs and suggest the next fix."
```
For a local report script:
```bash
generate-report | nanobot trigger <trigger-id>
```
## Troubleshooting
If an automation does not run, check that `nanobot gateway` is running, the
automation is enabled, and it was created from a linked topic.
If a local trigger waits forever, confirm the command uses the same workspace or
config as the gateway.
If a trigger message appears twice after a restart, treat it as expected
at-least-once delivery and make the external message idempotent.
If you need to edit, pause, resume, rename, delete, or inspect automations, use
the WebUI Automations view.
## Related Docs
- [`webui.md#automations`](./webui.md#automations) for the browser management view
- [`chat-commands.md#local-triggers`](./chat-commands.md#local-triggers) for `/trigger`
- [`cli-reference.md#local-triggers`](./cli-reference.md#local-triggers) for `nanobot trigger`
- [`configuration.md#gateway-heartbeat`](./configuration.md#gateway-heartbeat) for heartbeat settings
- [`guides/long-running-ai-agent.md`](./guides/long-running-ai-agent.md) for long-running agent work

View File

@ -0,0 +1,793 @@
# Channel Package Guide
Use this guide to add a self-contained channel package to the nanobot repository. A channel is part of nanobot when its package lives at `nanobot/channels/<channel>/`; there is no separate external channel-plugin path.
> **Breaking change:** nanobot no longer discovers the `nanobot.channels` Python entry-point group. Move an entry-point implementation into `nanobot/channels/<channel>/` with a package-owned manifest, runtime, tests, and optional WebUI contribution.
## How It Works
When `nanobot gateway` starts, nanobot scans the packages under `nanobot/channels/` and loads each dependency-free `ChannelPlugin` descriptor from `manifest.py`.
If a matching config section has `"enabled": true`, the channel is instantiated and started.
## Ownership and Sources of Truth
| Concern | Owner and source of truth |
|---------|---------------------------|
| Runtime behavior and platform SDK use | `runtime.py` and package-local helpers |
| Python package requirements | `ChannelPlugin.dependencies` in `manifest.py` |
| Writable settings fields, types, defaults, requirements, secret handling, and validation | `ChannelPlugin.setup` in `manifest.py` |
| Persisted config expansion, instance updates, and runtime naming | `ChannelPlugin.management` backed by a dependency-free module |
| Interactive setup connections and their short-lived state | `ChannelPlugin.connector` backed by package-local `connect.py` |
| Reusable local login-state detection | `ChannelPlugin.management.local_state_present` backed by package-local code |
| Discovery metadata and lazy runtime target | `PLUGIN` in `manifest.py` |
| WebUI structure, components, URLs, field keys, actions, and preset values | `webui/index.ts` or `webui/index.tsx` |
| Channel-specific user-facing copy | `webui/locales/<locale>.json` |
| Generic settings-shell copy shared by every channel | `webui/src/i18n/locales/<locale>/common.json` |
Keep one source of truth for each concern. In particular, the backend setup contract decides what may be written, the TypeScript contribution decides how those fields are presented, and locale JSON supplies the channel-specific words shown to users.
## Quick Start
We'll build a minimal webhook channel that receives messages via HTTP POST and sends replies back.
### Project Structure
```text
nanobot/channels/webhook/
├── __init__.py # lightweight package marker; do not import the runtime
├── manifest.py # dependency-free ChannelPlugin descriptor
├── runtime.py # channel implementation and optional SDK imports
├── tests/ # package-local tests
└── webui/ # optional settings UI and translations
```
### 1. Create Your Channel
```python
# nanobot/channels/webhook/__init__.py
"""Webhook channel package."""
```
```python
# nanobot/channels/webhook/manifest.py
from nanobot.channels.contracts import ChannelFieldSpec, ChannelSetupSpec
from nanobot.channels.plugin import ChannelPlugin
PLUGIN = ChannelPlugin(
name="webhook",
display_name="Webhook",
runtime=f"{__package__}.runtime:WebhookChannel",
dependencies=("aiohttp>=3.9.0,<4.0.0",),
setup=ChannelSetupSpec(
fields={
"port": ChannelFieldSpec(kind="int", default=9000),
"allowFrom": ChannelFieldSpec(kind="list"),
},
),
)
```
```python
# nanobot/channels/webhook/runtime.py
import asyncio
from typing import Any
from aiohttp import web
from loguru import logger
from pydantic import Field
from nanobot.channels.base import BaseChannel
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import Base
class WebhookConfig(Base):
"""Webhook channel configuration."""
enabled: bool = False
port: int = 9000
allow_from: list[str] = Field(default_factory=list)
class WebhookChannel(BaseChannel):
name = "webhook"
display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
@classmethod
def default_config(cls) -> dict[str, Any]:
return WebhookConfig().model_dump(by_alias=True)
async def start(self) -> None:
"""Start an HTTP server that listens for incoming messages.
IMPORTANT: start() must block forever (or until stop() is called).
If it returns, the channel is considered dead.
"""
self._running = True
port = self.config.port
app = web.Application()
app.router.add_post("/message", self._on_request)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port)
await site.start()
logger.info("Webhook listening on :{}", port)
# Block until stopped
while self._running:
await asyncio.sleep(1)
await runner.cleanup()
async def stop(self) -> None:
self._running = False
async def send(self, msg: OutboundMessage) -> None:
"""Deliver an outbound message.
msg.content — markdown text (convert to platform format as needed)
msg.media — list of local file paths to attach
msg.chat_id — the recipient (same chat_id you passed to _handle_message)
msg.metadata — channel routing context such as message/thread ids
msg.event — typed runtime event for progress/status messages
"""
logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
# In a real plugin: POST to a callback URL, send via SDK, etc.
async def _on_request(self, request: web.Request) -> web.Response:
"""Handle an incoming HTTP POST."""
body = await request.json()
sender = body.get("sender", "unknown")
chat_id = body.get("chat_id", sender)
text = body.get("text", "")
media = body.get("media", []) # list of URLs
# This is the key call: validates allowFrom, then puts the
# message onto the bus for the agent to process.
await self._handle_message(
sender_id=sender,
chat_id=chat_id,
content=text,
media=media,
)
return web.json_response({"ok": True})
```
The package directory, `PLUGIN.name`, runtime class name, and config section must all use `webhook`. Channel names use a portable ASCII package identifier: they start with a letter and contain only letters, digits, or underscores.
Declare runtime requirements directly in `ChannelPlugin.dependencies`. Do not add channel requirements to the root `pyproject.toml`: the package manifest is the source of truth used by the CLI, WebUI, and gateway startup. Keep the manifest and anything it imports free of the optional SDK itself.
### 2. Configure
```bash
nanobot plugins list # verify the channel package appears as "webhook"
nanobot onboard # add default config for detected channels
```
Edit `~/.nanobot/config.json`:
```json
{
"channels": {
"webhook": {
"enabled": true,
"port": 9000,
"allowFrom": ["*"]
}
}
}
```
nanobot always loads the dependency-free descriptor during discovery. When the WebUI gateway starts, it installs missing requirements for enabled channels before importing their runtimes. It also installs them when a channel is enabled from the CLI or WebUI. Status, configuration, and disable operations do not need the runtime. Single-instance and multi-instance channels use the same activation rules.
### 3. Run & Test
```bash
nanobot gateway
```
In another terminal:
```bash
curl -X POST http://localhost:9000/message \
-H "Content-Type: application/json" \
-d '{"sender": "user1", "chat_id": "user1", "text": "Hello!"}'
```
The agent receives the message and processes it. Replies arrive in your `send()` method.
## Channel Package Requirements
Every channel is a self-contained package at `nanobot/channels/<channel>/`; channel-specific runtime code, setup metadata, tests, WebUI structure, components, and translations stay under that directory.
### Package Layout
```text
nanobot/channels/<channel>/
├── __init__.py # package marker only; no runtime or SDK imports
├── manifest.py # dependency-free ChannelPlugin and ChannelSetupSpec
├── config.py # optional dependency-free config model and defaults
├── connect.py # optional interactive setup connector
├── instances.py # optional dependency-free multi-instance management adapter
├── state.py # optional persisted login-state detection
├── validation.py # optional package-owned setup checks
├── runtime.py # BaseChannel implementation and platform SDK imports
├── tests/ # channel-specific Python tests
└── webui/ # optional, compiled into the shared WebUI
├── index.ts or index.tsx # structure and optional React components
└── locales/
├── en.json # canonical locale shape
└── <locale>.json # one file for every supported WebUI locale
```
Do not add a runtime module directly under `nanobot/channels/`, create a parallel manifest tree, or add a central per-channel UI catalog. If existing channel files move, use `git mv` so history remains traceable.
### Manifest and Runtime Boundary
`manifest.py` exports a typed `ChannelPlugin` whose `runtime` target is an absolute import target, such as `nanobot.channels.telegram.runtime:TelegramChannel`; using `f"{__package__}.runtime:TelegramChannel"` keeps it package-owned without repeating the package path. Discovery imports the manifest before it knows whether the optional platform dependency is installed, so `manifest.py` must not import `runtime.py` or any platform SDK. Import runtime symbols from `runtime.py` explicitly; `__init__.py` remains an inert package marker.
The manifest owns the channel name, display name, setup contract, management adapter, optional connector target, dependency requirements, capabilities, default activation, and optional WebUI entry path. The management adapter alone decides whether a channel is single-instance or multi-instance.
Interactive browser setup uses one small connector contract. Set `connector=f"{__package__}.connect:MyConnectStore"`; the target is loaded only when `/api/settings/channels/<name>/connect/{start,poll,cancel}` is called. The store exposes one async `handle(action, query)` method and keeps platform-specific parsing, sessions, and errors inside the channel package. The shared settings router only authenticates, dispatches, and applies a successful connection.
Use the small constructors in [`nanobot/channels/_manifest.py`](../nanobot/channels/_manifest.py) for declarative field and requirement definitions. Use [`nanobot/channels/dingtalk/manifest.py`](../nanobot/channels/dingtalk/manifest.py) as a compact single-instance example and [`nanobot/channels/feishu/`](../nanobot/channels/feishu/) as a multi-instance example.
### Package-owned WebUI
Set `webui="webui/index.ts"` or `webui="webui/index.tsx"` in the channel manifest. Candidate modules are bundled from channel packages, but the settings UI activates only the exact path returned by the backend feature payload.
The entry module exports one default `ChannelUiContribution`. Channel identity comes from the package directory, so do not repeat a `channel` field in TypeScript. Keep only structure and executable UI data in this module: presentation metadata, icons or logo URLs, docs URLs, config field keys, action payloads, preset values, aliases, and optional `Panel` or `ConnectFlow` components.
Do not put static descriptions, setup steps, labels, placeholders, help text, action labels, or preset labels in TSX. Those strings belong in the channel's locale JSON. TSX remains appropriate for dynamic rendering, interpolation, conditions, and rich component composition.
### Channel-owned i18n
Create `webui/locales/<locale>.json` for every locale code declared in [`webui/src/i18n/config.ts`](../webui/src/i18n/config.ts). Treat `en.json` as the canonical shape; every other locale must contain the same message keys and the same interpolation variables. `displayName` may be omitted when the product name should remain unchanged.
```json
{
"description": "Use nanobot from Example chats.",
"requirements": "Example app credentials and gateway",
"setup": {
"docsLabel": "Open Example setup",
"officialLabel": "Open Example console",
"summary": "Example needs app credentials.",
"tryIt": "Send a test message.",
"steps": [
"Create an Example app.",
"Add the credentials.",
"Save, enable, and test the channel."
],
"fields": {
"clientId": {
"label": "Client ID",
"placeholder": "Example client ID",
"help": "Copy it from the Example console."
}
},
"actions": {
"copyManifest": "Copy manifest"
},
"presets": {
"default": "Default"
}
},
"custom": {
"connected": "{{name}} is connected."
}
}
```
Field messages are keyed by the config path after `channels.<channel>.`, with remaining punctuation converted to underscores. For example, `channels.signal.dm.allowFrom` maps to `setup.fields.dm_allowFrom`. Action and preset messages use the IDs declared in the TypeScript contribution.
Custom channel components should read dynamic copy with `channelTranslator(t, "<channel>")`; keep the English fallback adjacent to the call so an incomplete translation still renders useful text. Aliases reuse the owning channel's locale namespace rather than duplicating translations.
The dependency direction is intentional:
- [`webui/src/i18n/index.ts`](../webui/src/i18n/index.ts) imports the pure JSON [`channel-plugins/locale-registry.ts`](../webui/src/channel-plugins/locale-registry.ts).
- The locale registry discovers only `nanobot/channels/*/webui/locales/*.json` and must not import the UI registry, React, or TSX.
- Settings components may consume both the UI registry and locale registry.
- Channel UI code may use shared types and generic settings components, but core settings code must not add `if (feature.name === "...")` branches for individual channels.
This separation prevents i18n initialization from eagerly loading every channel React component and keeps channel-specific ownership below the channel package.
### Tests and Definition of Done
Put channel-specific Python tests in `nanobot/channels/<channel>/tests/`. Keep only shared registry, manager, base-class, and cross-channel contract tests in `tests/channels/`. Release builds exclude package-local tests while the repository test configuration discovers both trees.
For a focused channel change, run the smallest relevant set:
```bash
uv run pytest nanobot/channels/<channel>/tests -q
cd webui
bun run test -- src/tests/channel-locale-registry.test.ts src/tests/channel-ui-registry.test.ts src/tests/channel-identity.test.ts
bun run lint
bun run build
```
Before considering the change complete, verify all of the following:
- The manifest can be discovered without importing the runtime or optional platform SDK.
- `ChannelSetupSpec` contains every writable field and rejects unknown fields.
- The TypeScript field, action, and preset IDs have matching English locale messages.
- Every supported locale matches the English key shape and interpolation variables.
- Generic settings copy remains in core `common.json`; channel-specific copy remains inside the channel package.
- User-facing WebUI changes work through the built frontend served by a real gateway, including language switching and refresh persistence.
- Markdown prose paragraphs and individual list items remain on one source line; let the renderer handle visual wrapping.
## BaseChannel API
### Required (abstract)
| Method | Description |
|--------|-------------|
| `async start()` | **Must block forever.** Connect to platform, listen for messages, call `_handle_message()` on each. If this returns, the channel is dead. |
| `async stop()` | Set `self._running = False` and clean up. Called when gateway shuts down. |
| `async send(msg: OutboundMessage)` | Deliver an outbound message to the platform. Raise when the transport does not accept it. |
#### Outbound delivery contract
A normal return from `send()` means either the visible payload was accepted by the platform transport/API, or the channel deliberately had nothing to deliver, such as an empty progress event. Do not log and return when the client is disconnected, still starting, or the platform rejects the request. Raise an exception so `ChannelManager` can apply the shared retry policy.
`send()` may run as soon as `is_running` becomes true. If a channel sets `_running` before its transport is ready, it must keep raising until delivery can be attempted safely. Small platform-specific retries are fine, but the final failure must still reach the manager.
### Interactive Login
If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`:
```python
async def login(self, force: bool = False) -> bool:
"""
Perform channel-specific interactive login.
Args:
force: If True, ignore existing credentials and re-authenticate.
Returns True if already authenticated or login succeeds.
"""
# For QR-code-based login:
# 1. If force, clear saved credentials
# 2. Check if already authenticated (load from disk/state)
# 3. If not, show QR code and poll for confirmation
# 4. Save token on success
```
Channels that don't need interactive login (e.g. Telegram with bot token, Discord with bot token) inherit the default `login()` which just returns `True`.
Users trigger interactive login via:
```bash
nanobot channels login <channel_name>
nanobot channels login <channel_name> --force # re-authenticate
```
### Provided by Base
| Method / Property | Description |
|-------------------|-------------|
| `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. |
| `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` allows all, `[]` denies all. |
| `default_config()` (classmethod) | Returns runtime-local defaults for callers that construct the class directly. Discovery and onboarding use the descriptor instead. |
| `refresh_feature_metadata(config_path, instance_id)` (classmethod) | Optionally refreshes saved display metadata after an explicit settings action. It is never called by a read-only feature GET. |
| `transcribe_audio(file_path)` | Transcribes audio via the shared top-level `transcription` config (if configured). |
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
| `is_running` | Returns `self._running`. |
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
| `send_reasoning_delta(chat_id, delta, metadata?, *, stream_id?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
| `send_reasoning_end(chat_id, metadata?, *, stream_id?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
### Optional management contract
Persisted-state management belongs to `ChannelPlugin.management`, not `BaseChannel`. Keep the adapter and anything it imports free of optional platform SDKs so status, settings, and disable operations still work when the runtime cannot be imported. Runtime classes own network lifecycle, message delivery, interactive login, enable-time availability checks, and explicit runtime-only actions such as metadata refresh.
```python
from nanobot.channels.contracts import ChannelFieldSpec, ChannelSetupSpec, SetupRequirement
from nanobot.channels.plugin import ChannelPlugin
from .instances import MANAGEMENT
PLUGIN = ChannelPlugin(
name="webhook",
display_name="Webhook",
runtime=f"{__package__}.channel:WebhookChannel",
setup=ChannelSetupSpec(
fields={
"token": ChannelFieldSpec(kind="secret"),
"region": ChannelFieldSpec(
kind="enum",
choices=frozenset({"us", "eu"}),
default="us",
),
},
required=(SetupRequirement.field("token"),),
),
management=MANAGEMENT,
)
```
`instances.py` then exports the dependency-free adapter assembled from channel-owned callbacks:
```python
from typing import Any
from nanobot.channels.contracts import ChannelInstanceSpec, ChannelManagementSpec
from .config import default_config
def instance_specs(section: Any, *, enabled_only: bool = True) -> list[ChannelInstanceSpec]:
... # Expand the persisted channel-owned envelope.
def update_instance_config(
section: Any,
values: dict[str, Any],
*,
instance_id: str = "default",
) -> dict[str, Any]:
... # Update one instance without discarding sibling data.
MANAGEMENT = ChannelManagementSpec(
multi_instance=True,
default_config=default_config,
instance_specs=instance_specs,
update_instance_config=update_instance_config,
)
```
`ChannelSetupSpec` is authoritative for writable field names, field types, choices, defaults, required setup, secret redaction, and optional backend validation. The settings API rejects fields outside this contract. A validator receives `(values, context)`; use `context.allow_local_service_access` for host network policy instead of loading global config from the channel package.
The dependency-free `MANAGEMENT` value is a `ChannelManagementSpec`. Multi-instance plugins provide `instance_specs(section, enabled_only=True)` and `update_instance_config(section, values, instance_id=...)`; they may also provide `default_config`, `runtime_name`, presentation-only `feature_instances`, and `local_state_present`. Single-instance plugins normally derive onboarding defaults from `ChannelSetupSpec`; use `default_config` only when persisted defaults include fields that are not part of generic setup.
Multi-instance adapters return `ChannelInstanceSpec` objects and preserve their persisted envelope when updating one instance. Their descriptor sets `ChannelManagementSpec(multi_instance=True)`. The shared contract enforces these invariants:
- every `instance_id` is non-empty and unique;
- the management adapter's `runtime_name(channel_name, instance_id)` is the single source of routing names, and every derived name is unique and is either the channel name or starts with `<channel-name>.`;
- runtime names cannot overwrite a runtime already owned by another channel;
- settings instance summaries are generated from `instance_specs()` and `ChannelPlugin.setup`. They contain the authoritative `enabled` and `configured` state plus secret-safe `config_values` and `configured_fields` for the generic instance editor;
- the management adapter's `feature_instances()` may return `None` or presentation overrides containing an `id` plus `name`, `display_name`, or `avatar_url`. It cannot override runtime state or the configuration snapshot.
`ChannelInstanceSpec` contains only `instance_id` and the instance config; nanobot derives its runtime name through the adapter. Single-instance plugins keep ownership of their entire config, including a field named `instances`. Only plugins whose management spec sets `multi_instance=True` opt into instance expansion.
The package/config section name owns every runtime produced from that section. Class inheritance does not transfer runtime ownership to another package.
Return a concrete iterable or generator from the adapter's `instance_specs()`; nanobot materializes and validates it before constructing any runtime. Raise an exception for malformed persisted data rather than silently changing instance identity. Keep network-backed metadata refresh behind the runtime's `refresh_feature_metadata()` so feature GET requests remain dependency-free and read-only.
For package layout, WebUI ownership, and localization rules, see [Channel Package Requirements](#channel-package-requirements).
### Optional (streaming)
| Method | Description |
|--------|-------------|
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
### Message Types
```python
@dataclass
class OutboundMessage:
channel: str # your channel name
chat_id: str # recipient (same value you passed to _handle_message)
content: str # markdown text — convert to platform format as needed
media: list[str] # local file paths to attach (images, audio, docs)
metadata: dict # channel routing context, e.g. "message_id" for threading
event: object | None # typed runtime/UI event; usually inspect with isinstance()
```
Runtime/UI semantics live on `msg.event`. Plugin-authored outbound messages should use typed events instead of legacy metadata flags such as `_progress`, `_stream_delta`, `_stream_end`, `_reasoning_delta`, `_turn_end`, or `_goal_status`. nanobot still accepts those old flags as a compatibility bridge for existing in-process extensions, but new plugin code should not add fresh dependencies on them.
## Streaming Support
Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it.
### How It Works
When **both** conditions are met, the agent streams content through your channel:
1. Config has `"streaming": true`
2. Your subclass overrides `send_delta()`
If either is missing, the agent falls back to the normal one-shot `send()` path.
### Implementing `send_delta`
Override `send_delta` to handle two types of calls:
```python
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
buffer_key = stream_id or chat_id
if stream_end:
# Streaming finished — do final formatting, cleanup, etc.
return
# Regular delta — append text, update the message on screen
# delta contains a small chunk of text (a few tokens)
```
Streaming state is passed through keyword-only arguments, not `_stream_delta` or `_stream_end` metadata flags. Use `stream_id` to key any per-stream buffers; fall back to `chat_id` when it is missing.
### Example: Webhook with Streaming
```python
class WebhookChannel(BaseChannel):
name = "webhook"
display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
self._buffers: dict[str, str] = {}
async def send_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
stream_end: bool = False,
resuming: bool = False,
) -> None:
buffer_key = stream_id or chat_id
if stream_end:
text = self._buffers.pop(buffer_key, "")
# Final delivery — format and send the complete message
await self._deliver(chat_id, text, final=True)
return
self._buffers.setdefault(buffer_key, "")
self._buffers[buffer_key] += delta
# Incremental update — push partial text to the client
await self._deliver(chat_id, self._buffers[buffer_key], final=False)
async def send(self, msg: OutboundMessage) -> None:
# Non-streaming path — unchanged
await self._deliver(msg.chat_id, msg.content, final=True)
```
### Config
Enable streaming per channel:
```json
{
"channels": {
"webhook": {
"enabled": true,
"streaming": true,
"allowFrom": ["*"]
}
}
}
```
When `streaming` is `false` (default) or omitted, only `send()` is called — no streaming overhead.
### BaseChannel Streaming API
| Method / Property | Description |
|-------------------|-------------|
| `async send_delta(chat_id, delta, metadata?, *, stream_id?, stream_end=False, resuming=False)` | Override to handle streaming chunks. No-op by default. |
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
## Progress, Tool Hints, and Reasoning
Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These are intended for UI affordances like status rows, collapsible "used tools" groups, or reasoning/thinking blocks. Platforms that do not have a good place for them can ignore them safely.
### Progress and Tool Hints
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.event` before rendering:
```python
from nanobot.bus.outbound_events import ProgressEvent
async def send(self, msg: OutboundMessage) -> None:
event = msg.event
if isinstance(event, ProgressEvent) and event.tool_hint:
# A short tool breadcrumb, e.g. read_file("config.json")
await self._send_trace(msg.chat_id, msg.content, kind="tool")
return
if isinstance(event, ProgressEvent):
# Generic non-final status, e.g. "Thinking..." or "Running command..."
await self._send_trace(msg.chat_id, msg.content, kind="progress")
return
await self._send_message(msg.chat_id, msg.content, media=msg.media)
```
Tool hints are on by default. Users can disable them globally or per channel:
```json
{
"channels": {
"sendToolHints": true,
"webhook": {
"enabled": true,
"sendToolHints": false
}
}
}
```
### Reasoning Blocks
Reasoning is delivered through dedicated optional hooks, not `send()`. Override `send_reasoning_delta()` and `send_reasoning_end()` if your platform can show model reasoning as a subdued/collapsible block. The default implementation is a no-op, so unsupported channels simply drop reasoning content.
```python
class WebhookChannel(BaseChannel):
name = "webhook"
display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
self._reasoning_buffers: dict[str, str] = {}
async def send_reasoning_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
buffer_key = stream_id or chat_id
self._reasoning_buffers[buffer_key] = self._reasoning_buffers.get(buffer_key, "") + delta
await self._update_reasoning_block(chat_id, self._reasoning_buffers[buffer_key], final=False)
async def send_reasoning_end(
self,
chat_id: str,
metadata: dict[str, Any] | None = None,
*,
stream_id: str | None = None,
) -> None:
buffer_key = stream_id or chat_id
text = self._reasoning_buffers.pop(buffer_key, "")
if text:
await self._update_reasoning_block(chat_id, text, final=True)
```
**Reasoning arguments:**
| Argument | Meaning |
|------|---------|
| `delta` | A reasoning/thinking chunk for `send_reasoning_delta()`. |
| `stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
| `send_reasoning_end()` | The current reasoning block is complete. |
Reasoning visibility is controlled by `showReasoning` globally or per channel:
```json
{
"channels": {
"showReasoning": true,
"webhook": {
"enabled": true,
"showReasoning": true
}
}
}
```
Recommended rendering:
- Render tool hints and progress as trace/status UI, not as normal assistant replies.
- Render reasoning with lower visual emphasis and collapse it after completion when the platform supports that.
- Keep reasoning separate from final answer text. A final answer still arrives through `send()` or `send_delta()`.
## Config
### Why Pydantic model is required
`BaseChannel.is_allowed()` reads the permission list via `getattr(self.config, "allow_from", [])`. This works for Pydantic models where `allow_from` is a real Python attribute, but **fails silently for plain `dict`**`dict` has no `allow_from` attribute, so `getattr` always returns the default `[]`, causing all messages to be denied.
Channel runtimes use Pydantic config models by subclassing `Base` from `nanobot.config.schema`.
### Pattern
1. Define a Pydantic model inheriting from `nanobot.config.schema.Base`:
```python
from pydantic import Field
from nanobot.config.schema import Base
class WebhookConfig(Base):
"""Webhook channel configuration."""
enabled: bool = False
port: int = 9000
allow_from: list[str] = Field(default_factory=list)
```
`Base` is configured with `alias_generator=to_camel` and `populate_by_name=True`, so JSON keys like `"allowFrom"` and `"allow_from"` are both accepted.
2. Convert `dict` → model in `__init__`:
```python
from typing import Any
from nanobot.bus.queue import MessageBus
class WebhookChannel(BaseChannel):
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
```
3. Access config as attributes (not `.get()`):
```python
async def start(self) -> None:
port = self.config.port
token = self.config.token
```
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
`nanobot onboard` reads the descriptor without importing the runtime. Put writable defaults in `ChannelSetupSpec`:
```python
setup=ChannelSetupSpec(
fields={
"port": ChannelFieldSpec(kind="int", default=9000),
"allowFrom": ChannelFieldSpec(kind="list"),
},
)
```
String and secret fields default to `""`, list fields to `[]`, and boolean fields to `false` when no explicit default is declared. For non-setup or multi-instance persisted defaults, provide `ChannelManagementSpec.default_config` from a dependency-free package-local module.
## Naming Convention
| What | Format | Example |
|------|--------|---------|
| Package directory | `nanobot/channels/{name}` | `nanobot/channels/webhook` |
| Manifest name | `{name}` | `webhook` |
| Config section | `channels.{name}` | `channels.webhook` |
| Runtime import | `nanobot.channels.{name}.runtime` | `nanobot.channels.webhook.runtime` |
## Local Development
```bash
git clone https://github.com/HKUDS/nanobot.git
cd nanobot
python -m pip install -e .
nanobot plugins list # should show the package as "webhook"
nanobot plugins enable webhook
nanobot gateway # test end-to-end
```
## Verify
```bash
$ nanobot plugins list
Name Type Enabled
discord channel no
telegram channel yes
webhook channel yes
```

View File

@ -1,550 +0,0 @@
# Channel Plugin Guide
Build a custom nanobot channel in three steps: subclass, package, install.
> **Note:** We recommend developing channel plugins against a source checkout of nanobot (`python -m pip install -e .`) rather than a PyPI release, so you always have access to the latest base-channel features and APIs.
## How It Works
nanobot discovers channel plugins via Python [entry points](https://packaging.python.org/en/latest/specifications/entry-points/). When `nanobot gateway` starts, it scans:
1. Built-in channels in `nanobot/channels/`
2. External packages registered under the `nanobot.channels` entry point group
If a matching config section has `"enabled": true`, the channel is instantiated and started.
## Quick Start
We'll build a minimal webhook channel that receives messages via HTTP POST and sends replies back.
### Project Structure
```text
nanobot-channel-webhook/
├── nanobot_channel_webhook/
│ ├── __init__.py # re-export WebhookChannel
│ └── channel.py # channel implementation
└── pyproject.toml
```
### 1. Create Your Channel
```python
# nanobot_channel_webhook/__init__.py
from nanobot_channel_webhook.channel import WebhookChannel
__all__ = ["WebhookChannel"]
```
```python
# nanobot_channel_webhook/channel.py
import asyncio
from typing import Any
from aiohttp import web
from loguru import logger
from pydantic import Field
from nanobot.channels.base import BaseChannel
from nanobot.bus.events import OutboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.config.schema import Base
class WebhookConfig(Base):
"""Webhook channel configuration."""
enabled: bool = False
port: int = 9000
allow_from: list[str] = Field(default_factory=list)
class WebhookChannel(BaseChannel):
name = "webhook"
display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
@classmethod
def default_config(cls) -> dict[str, Any]:
return WebhookConfig().model_dump(by_alias=True)
async def start(self) -> None:
"""Start an HTTP server that listens for incoming messages.
IMPORTANT: start() must block forever (or until stop() is called).
If it returns, the channel is considered dead.
"""
self._running = True
port = self.config.port
app = web.Application()
app.router.add_post("/message", self._on_request)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", port)
await site.start()
logger.info("Webhook listening on :{}", port)
# Block until stopped
while self._running:
await asyncio.sleep(1)
await runner.cleanup()
async def stop(self) -> None:
self._running = False
async def send(self, msg: OutboundMessage) -> None:
"""Deliver an outbound message.
msg.content — markdown text (convert to platform format as needed)
msg.media — list of local file paths to attach
msg.chat_id — the recipient (same chat_id you passed to _handle_message)
msg.metadata — may contain "_progress": True for streaming chunks
"""
logger.info("[webhook] -> {}: {}", msg.chat_id, msg.content[:80])
# In a real plugin: POST to a callback URL, send via SDK, etc.
async def _on_request(self, request: web.Request) -> web.Response:
"""Handle an incoming HTTP POST."""
body = await request.json()
sender = body.get("sender", "unknown")
chat_id = body.get("chat_id", sender)
text = body.get("text", "")
media = body.get("media", []) # list of URLs
# This is the key call: validates allowFrom, then puts the
# message onto the bus for the agent to process.
await self._handle_message(
sender_id=sender,
chat_id=chat_id,
content=text,
media=media,
)
return web.json_response({"ok": True})
```
### 2. Register the Entry Point
```toml
# pyproject.toml
[project]
name = "nanobot-channel-webhook"
version = "0.1.0"
dependencies = ["nanobot-ai", "aiohttp"]
[project.entry-points."nanobot.channels"]
webhook = "nanobot_channel_webhook:WebhookChannel"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["nanobot_channel_webhook"]
```
The key (`webhook`) becomes the config section name. The value points to your `BaseChannel` subclass.
### 3. Install & Configure
```bash
python -m pip install -e .
nanobot plugins list # verify "Webhook" shows as "plugin"
nanobot onboard # auto-adds default config for detected plugins
```
Edit `~/.nanobot/config.json`:
```json
{
"channels": {
"webhook": {
"enabled": true,
"port": 9000,
"allowFrom": ["*"]
}
}
}
```
### 4. Run & Test
```bash
nanobot gateway
```
In another terminal:
```bash
curl -X POST http://localhost:9000/message \
-H "Content-Type: application/json" \
-d '{"sender": "user1", "chat_id": "user1", "text": "Hello!"}'
```
The agent receives the message and processes it. Replies arrive in your `send()` method.
## BaseChannel API
### Required (abstract)
| Method | Description |
|--------|-------------|
| `async start()` | **Must block forever.** Connect to platform, listen for messages, call `_handle_message()` on each. If this returns, the channel is dead. |
| `async stop()` | Set `self._running = False` and clean up. Called when gateway shuts down. |
| `async send(msg: OutboundMessage)` | Deliver an outbound message to the platform. |
### Interactive Login
If your channel requires interactive authentication (e.g. QR code scan), override `login(force=False)`:
```python
async def login(self, force: bool = False) -> bool:
"""
Perform channel-specific interactive login.
Args:
force: If True, ignore existing credentials and re-authenticate.
Returns True if already authenticated or login succeeds.
"""
# For QR-code-based login:
# 1. If force, clear saved credentials
# 2. Check if already authenticated (load from disk/state)
# 3. If not, show QR code and poll for confirmation
# 4. Save token on success
```
Channels that don't need interactive login (e.g. Telegram with bot token, Discord with bot token) inherit the default `login()` which just returns `True`.
Users trigger interactive login via:
```bash
nanobot channels login <channel_name>
nanobot channels login <channel_name> --force # re-authenticate
```
### Provided by Base
| Method / Property | Description |
|-------------------|-------------|
| `_handle_message(sender_id, chat_id, content, media?, metadata?, session_key?)` | **Call this when you receive a message.** Checks `is_allowed()`, then publishes to the bus. Automatically sets `_wants_stream` if `supports_streaming` is true. |
| `is_allowed(sender_id)` | Checks against `config.allow_from`; `"*"` allows all, `[]` denies all. |
| `default_config()` (classmethod) | Returns default config dict for `nanobot onboard`. Override to declare your fields. |
| `transcribe_audio(file_path)` | Transcribes audio via the shared top-level `transcription` config (if configured). |
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
| `is_running` | Returns `self._running`. |
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
| `send_reasoning_delta(chat_id, delta, metadata?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
| `send_reasoning_end(chat_id, metadata?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
### Optional (streaming)
| Method | Description |
|--------|-------------|
| `async send_delta(chat_id, delta, metadata?)` | Override to receive streaming chunks. See [Streaming Support](#streaming-support) for details. |
### Message Types
```python
@dataclass
class OutboundMessage:
channel: str # your channel name
chat_id: str # recipient (same value you passed to _handle_message)
content: str # markdown text — convert to platform format as needed
media: list[str] # local file paths to attach (images, audio, docs)
metadata: dict # may contain: "_progress" (bool) for streaming chunks,
# "message_id" for reply threading
```
## Streaming Support
Channels can opt into real-time streaming — the agent sends content token-by-token instead of one final message. This is entirely optional; channels work fine without it.
### How It Works
When **both** conditions are met, the agent streams content through your channel:
1. Config has `"streaming": true`
2. Your subclass overrides `send_delta()`
If either is missing, the agent falls back to the normal one-shot `send()` path.
### Implementing `send_delta`
Override `send_delta` to handle two types of calls:
```python
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
meta = metadata or {}
if meta.get("_stream_end"):
# Streaming finished — do final formatting, cleanup, etc.
return
# Regular delta — append text, update the message on screen
# delta contains a small chunk of text (a few tokens)
```
**Metadata flags:**
| Flag | Meaning |
|------|---------|
| `_stream_delta: True` | A content chunk (delta contains the new text) |
| `_stream_end: True` | Streaming finished (delta is empty) |
### Example: Webhook with Streaming
```python
class WebhookChannel(BaseChannel):
name = "webhook"
display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
self._buffers: dict[str, str] = {}
async def send_delta(self, chat_id: str, delta: str, metadata: dict[str, Any] | None = None) -> None:
meta = metadata or {}
if meta.get("_stream_end"):
text = self._buffers.pop(chat_id, "")
# Final delivery — format and send the complete message
await self._deliver(chat_id, text, final=True)
return
self._buffers.setdefault(chat_id, "")
self._buffers[chat_id] += delta
# Incremental update — push partial text to the client
await self._deliver(chat_id, self._buffers[chat_id], final=False)
async def send(self, msg: OutboundMessage) -> None:
# Non-streaming path — unchanged
await self._deliver(msg.chat_id, msg.content, final=True)
```
### Config
Enable streaming per channel:
```json
{
"channels": {
"webhook": {
"enabled": true,
"streaming": true,
"allowFrom": ["*"]
}
}
}
```
When `streaming` is `false` (default) or omitted, only `send()` is called — no streaming overhead.
### BaseChannel Streaming API
| Method / Property | Description |
|-------------------|-------------|
| `async send_delta(chat_id, delta, metadata?)` | Override to handle streaming chunks. No-op by default. |
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
## Progress, Tool Hints, and Reasoning
Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These are intended for UI affordances like status rows, collapsible "used tools" groups, or reasoning/thinking blocks. Platforms that do not have a good place for them can ignore them safely.
### Progress and Tool Hints
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.metadata` before rendering:
```python
async def send(self, msg: OutboundMessage) -> None:
meta = msg.metadata or {}
if meta.get("_tool_hint"):
# A short tool breadcrumb, e.g. read_file("config.json")
await self._send_trace(msg.chat_id, msg.content, kind="tool")
return
if meta.get("_progress"):
# Generic non-final status, e.g. "Thinking..." or "Running command..."
await self._send_trace(msg.chat_id, msg.content, kind="progress")
return
await self._send_message(msg.chat_id, msg.content, media=msg.media)
```
Tool hints are off by default for most channels. Users can enable them globally or per channel:
```json
{
"channels": {
"sendToolHints": true,
"webhook": {
"enabled": true,
"sendToolHints": true
}
}
}
```
### Reasoning Blocks
Reasoning is delivered through dedicated optional hooks, not `send()`. Override `send_reasoning_delta()` and `send_reasoning_end()` if your platform can show model reasoning as a subdued/collapsible block. The default implementation is a no-op, so unsupported channels simply drop reasoning content.
```python
class WebhookChannel(BaseChannel):
name = "webhook"
display_name = "Webhook"
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
self._reasoning_buffers: dict[str, str] = {}
async def send_reasoning_delta(
self,
chat_id: str,
delta: str,
metadata: dict[str, Any] | None = None,
) -> None:
meta = metadata or {}
stream_id = str(meta.get("_stream_id") or chat_id)
self._reasoning_buffers[stream_id] = self._reasoning_buffers.get(stream_id, "") + delta
await self._update_reasoning_block(chat_id, self._reasoning_buffers[stream_id], final=False)
async def send_reasoning_end(
self,
chat_id: str,
metadata: dict[str, Any] | None = None,
) -> None:
meta = metadata or {}
stream_id = str(meta.get("_stream_id") or chat_id)
text = self._reasoning_buffers.pop(stream_id, "")
if text:
await self._update_reasoning_block(chat_id, text, final=True)
```
**Reasoning metadata flags:**
| Flag | Meaning |
|------|---------|
| `_reasoning_delta: True` | A reasoning/thinking chunk; `delta` contains the new text. |
| `_reasoning_end: True` | The current reasoning block is complete; `delta` is empty. |
| `_reasoning: True` | Legacy one-shot reasoning. `BaseChannel.send_reasoning()` converts it to delta + end. |
| `_stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
Reasoning visibility is controlled by `showReasoning` globally or per channel:
```json
{
"channels": {
"showReasoning": true,
"webhook": {
"enabled": true,
"showReasoning": true
}
}
}
```
Recommended rendering:
- Render tool hints and progress as trace/status UI, not as normal assistant replies.
- Render reasoning with lower visual emphasis and collapse it after completion when the platform supports that.
- Keep reasoning separate from final answer text. A final answer still arrives through `send()` or `send_delta()`.
## Config
### Why Pydantic model is required
`BaseChannel.is_allowed()` reads the permission list via `getattr(self.config, "allow_from", [])`. This works for Pydantic models where `allow_from` is a real Python attribute, but **fails silently for plain `dict`**`dict` has no `allow_from` attribute, so `getattr` always returns the default `[]`, causing all messages to be denied.
Built-in channels use Pydantic config models (subclassing `Base` from `nanobot.config.schema`). Plugin channels **must do the same**.
### Pattern
1. Define a Pydantic model inheriting from `nanobot.config.schema.Base`:
```python
from pydantic import Field
from nanobot.config.schema import Base
class WebhookConfig(Base):
"""Webhook channel configuration."""
enabled: bool = False
port: int = 9000
allow_from: list[str] = Field(default_factory=list)
```
`Base` is configured with `alias_generator=to_camel` and `populate_by_name=True`, so JSON keys like `"allowFrom"` and `"allow_from"` are both accepted.
2. Convert `dict` → model in `__init__`:
```python
from typing import Any
from nanobot.bus.queue import MessageBus
class WebhookChannel(BaseChannel):
def __init__(self, config: Any, bus: MessageBus):
if isinstance(config, dict):
config = WebhookConfig(**config)
super().__init__(config, bus)
```
3. Access config as attributes (not `.get()`):
```python
async def start(self) -> None:
port = self.config.port
token = self.config.token
```
`allowFrom` is handled automatically by `_handle_message()` — you don't need to check it yourself.
Override `default_config()` so `nanobot onboard` auto-populates `config.json`:
```python
@classmethod
def default_config(cls) -> dict[str, Any]:
return WebhookConfig().model_dump(by_alias=True)
```
> **Note:** `default_config()` returns a plain `dict` (not a Pydantic model) because it's used to serialize into `config.json`. The recommended way is to instantiate your config model and call `model_dump(by_alias=True)` — this automatically uses camelCase keys (`allowFrom`) and keeps defaults in a single source of truth.
If not overridden, the base class returns `{"enabled": false}`.
## Naming Convention
| What | Format | Example |
|------|--------|---------|
| PyPI package | `nanobot-channel-{name}` | `nanobot-channel-webhook` |
| Entry point key | `{name}` | `webhook` |
| Config section | `channels.{name}` | `channels.webhook` |
| Python package | `nanobot_channel_{name}` | `nanobot_channel_webhook` |
## Local Development
```bash
git clone https://github.com/you/nanobot-channel-webhook
cd nanobot-channel-webhook
python -m pip install -e .
nanobot plugins list # should show "Webhook" as "plugin"
nanobot gateway # test end-to-end
```
## Verify
```bash
$ nanobot plugins list
Name Source Enabled
telegram builtin yes
discord builtin no
webhook plugin yes
```

View File

@ -1,6 +1,22 @@
# Chat Apps # Chat Apps for Self-Hosted AI Agents
Connect nanobot to your favorite chat platform. Want to build your own? See the [Channel Plugin Guide](./channel-plugin-guide.md). Connect nanobot to Telegram, Discord, Slack, WeChat, Email, Mattermost, and
other chat platforms. This page is the full chat-channel reference. If you want
a focused setup path for one platform, start with a guide:
| Platform | Guide |
|---|---|
| Telegram | [Build a Telegram AI Agent with nanobot](./guides/telegram-ai-agent.md) |
| Discord | [Build a Discord AI Agent with nanobot](./guides/discord-ai-agent.md) |
| Slack | [Build a Slack AI Agent with nanobot](./guides/slack-ai-agent.md) |
| Feishu | [Build a Feishu AI Agent with nanobot](./guides/feishu-ai-agent.md) |
| WhatsApp | [Build a WhatsApp AI Agent with nanobot](./guides/whatsapp-ai-agent.md) |
| WeChat | [Build a WeChat AI Agent with nanobot](./guides/wechat-ai-agent.md) |
| QQ | [Build a QQ AI Agent with nanobot](./guides/qq-ai-agent.md) |
| Email | [Build an Email AI Agent with nanobot](./guides/email-ai-agent.md) |
| Mattermost | [Build a Mattermost AI Agent with nanobot](./guides/mattermost-ai-agent.md) |
Want to build your own channel? See the [Channel Package Guide](./channel-package-guide.md).
Before configuring a chat app, make sure the local CLI path works: Before configuring a chat app, make sure the local CLI path works:
@ -10,33 +26,67 @@ nanobot agent -m "Hello!"
If that fails, fix installation, config, provider, or model setup first with [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md). Chat apps require `nanobot gateway` to stay running after the channel is configured. If that fails, fix installation, config, provider, or model setup first with [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md). Chat apps require `nanobot gateway` to stay running after the channel is configured.
Most examples below are snippets to merge into `~/.nanobot/config.json`. ## Recommended Setup in the WebUI
## Common Setup Pattern For normal local setup, let the WebUI write and validate the channel config:
1. Run `nanobot webui`.
2. Open **Settings → Channels**.
3. Search for the platform and open its setup panel.
4. Follow the credential fields or QR flow. The screen tells you which platform-side token, permission, account, or URL it needs.
5. Let nanobot install the optional channel support when prompted.
6. Restart from the WebUI if it reports that a restart is required.
7. Send a private test message. If the channel returns a pairing code, approve the pending request in the WebUI and send the message again.
If your installed stable release does not show **Settings → Channels**, continue with the [manual setup pattern](#manual-setup-pattern) below or install current source.
Optional package installation is available to a same-machine WebUI by default. Remote browser clients cannot change the Python environment unless an administrator explicitly enables that capability. Run `nanobot plugins enable <channel>` locally when the guided install is unavailable.
The sections below explain what each chat platform requires and provide manual config for deployments that manage `config.json` directly.
> [!NOTE]
> If you are upgrading from a version where chat app SDKs were installed by default,
> enable the channel in the same Python environment so nanobot installs its
> manifest-declared dependencies:
>
> ```bash
> nanobot plugins enable <channel>
> ```
>
> Replace `<channel>` with names such as `telegram`, `slack`, `feishu`,
> `dingtalk`, `matrix`, `qq`, `napcat`, `weixin`, `wecom`, or `msteams`.
> To turn a channel off later, run `nanobot plugins disable <channel>`.
> nanobot keeps the saved settings, but stops loading that channel after the
> next restart.
## Manual Setup Pattern
Most examples below are snippets to merge into `~/.nanobot/config.json`. When a snippet includes `allowFrom`, it is showing a static allowlist. For pairing-based access on supported channels, omit `allowFrom`; Slack and Mattermost also need `dm.policy` set to `"allowlist"` for DMs to issue pairing codes.
Every chat app uses the same shape: Every chat app uses the same shape:
1. Create or prepare the bot/account in the chat platform. 1. Create or prepare the bot/account in the chat platform.
2. Copy the token, secret, QR login state, webhook URL, or account ID that platform gives you. 2. Copy the token, secret, QR login state, webhook URL, or account ID that platform gives you.
3. Merge that platform's JSON snippet into `~/.nanobot/config.json`. 3. Merge that platform's JSON snippet into `~/.nanobot/config.json`.
4. Keep access control narrow at first with `allowFrom` or the platform-specific allow list. 4. Prefer pairing for DM-capable channels: omit `allowFrom`, let the first DM receive a pairing code, then approve it with `/pairing approve <code>`.
5. Check that nanobot can see the configured channel: 5. For channels without pairing, such as Email, keep access narrow with `allowFrom` or the platform-specific allow list.
6. Check that nanobot can see the configured channel:
```bash ```bash
nanobot channels status nanobot channels status
``` ```
6. Start the gateway and leave that terminal running: 7. Start the gateway and leave that terminal running:
```bash ```bash
nanobot gateway nanobot gateway
``` ```
7. Send a message from the allowed account. In group chats, follow that channel's `groupPolicy` behavior: many channels default to mention-only, while Matrix and WhatsApp default to open group replies. 8. Send a test DM. If the bot returns a pairing code, approve it and send the message again. In group chats, follow that channel's `groupPolicy` behavior: many channels default to mention-only, while Matrix and WhatsApp default to open group replies.
If `nanobot channels status` does not show the channel as enabled, the config snippet is in the wrong place, the channel name is misspelled, or the config file you edited is not the one nanobot is reading. If the channel is enabled but messages do not arrive, run `nanobot gateway --verbose` and compare the platform-side credentials, event permissions, and allow lists. If `nanobot channels status` does not show the channel as enabled, the config snippet is in the wrong place, the channel name is misspelled, or the config file you edited is not the one nanobot is reading. If the channel is enabled but messages do not arrive, run `nanobot gateway --verbose` and compare the platform-side credentials, event permissions, and allow lists.
> `["*"]` allows anyone who can reach that channel to talk to the bot. Use it only when that is intentional, or temporarily while testing in a private sandbox. > `allowFrom: ["*"]` bypasses pairing and allows anyone who can reach that channel to talk to the bot. Use it only when that is intentional, or temporarily while testing in a private sandbox.
| Channel | What you need | | Channel | What you need |
|---------|---------------| |---------|---------------|
@ -59,6 +109,29 @@ If `nanobot channels status` does not show the channel as enabled, the config sn
<details> <details>
<summary><b>Telegram</b></summary> <summary><b>Telegram</b></summary>
**Recommended WebUI setup**
1. Create a bot with `@BotFather` and copy its token.
2. Run `nanobot webui`, then open **Settings → Channels → Telegram**.
3. Paste the token. If the gateway cannot reach Telegram directly, expand
**Advanced** and add an HTTP or SOCKS proxy.
4. Save and enable Telegram, then send the bot a direct message.
The configuration badge means nanobot found a saved token. The live connection
check is separate, so a temporary Telegram or proxy outage does not make an
existing configuration disappear. Saved tokens and proxy URLs remain masked.
See the [step-by-step Telegram guide](./guides/telegram-ai-agent.md) for pairing
and troubleshooting.
**Manual setup**
Install the optional channel dependency:
```bash
nanobot plugins enable telegram
```
**1. Create a bot** **1. Create a bot**
- Open Telegram, search `@BotFather` - Open Telegram, search `@BotFather`
- Send `/newbot`, follow prompts - Send `/newbot`, follow prompts
@ -78,7 +151,24 @@ If `nanobot channels status` does not show the channel as enabled, the config sn
} }
``` ```
If the gateway cannot reach Telegram directly, add a proxy to the same section:
```json
{
"channels": {
"telegram": {
"proxy": "http://127.0.0.1:7890"
}
}
}
```
HTTP, HTTPS, SOCKS5, and SOCKS5H proxy URLs are accepted. Treat a proxy URL
containing a username or password as a secret.
> You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`. Copy this value **without the `@` symbol** and paste it into the config file. > You can find your **User ID** in Telegram settings. It is shown as `@yourUserId`. Copy this value **without the `@` symbol** and paste it into the config file.
>
> `richMessages` defaults to `false`. Set it to `true` only if your Telegram client supports Bot API 10.1 rich messages and you want richer markdown rendering; keep it disabled for Telegram Web, which may show unsupported-message errors for rich messages.
**3. Run** **3. Run**
@ -121,6 +211,14 @@ Telegram uses long polling by default. To receive updates through a webhook, exp
Uses **Socket.IO WebSocket** by default, with HTTP polling fallback. Uses **Socket.IO WebSocket** by default, with HTTP polling fallback.
**Install the optional realtime dependency**
```bash
nanobot plugins enable mochat
```
Without these dependencies, Mochat still works through HTTP polling.
**1. Ask nanobot to set up Mochat for you** **1. Ask nanobot to set up Mochat for you**
Simply send this message to nanobot (replace `xxx@xxx` with your real email): Simply send this message to nanobot (replace `xxx@xxx` with your real email):
@ -231,14 +329,14 @@ nanobot gateway
<details> <details>
<summary><b>Matrix (Element)</b></summary> <summary><b>Matrix (Element)</b></summary>
Install Matrix dependencies first: Enable Matrix support first:
```bash ```bash
python -m pip install "nanobot-ai[matrix]" nanobot plugins enable matrix
``` ```
> [!NOTE] > [!NOTE]
> Matrix is not supported on Windows. `matrix-nio[e2e]` depends on `python-olm`, which has no pre-built Windows wheel and is skipped by the `matrix` extra on `sys_platform == 'win32'`. The command above will still succeed on Windows but without `matrix-nio` installed, so enabling the Matrix channel will fail at startup. Use macOS, Linux, or WSL2. > Matrix encryption is disabled by default on Windows because `matrix-nio[e2e]` depends on `python-olm`, which has no pre-built Windows wheel. Use macOS, Linux, or WSL2 if you need Matrix E2EE.
**1. Create/choose a Matrix account** **1. Create/choose a Matrix account**
@ -301,9 +399,13 @@ nanobot gateway
<details> <details>
<summary><b>WhatsApp</b></summary> <summary><b>WhatsApp</b></summary>
Requires **Node.js ≥18**. Requires the WhatsApp optional dependencies:
**1. Link device** ```bash
nanobot plugins enable whatsapp
```
**1. Link device with QR**
```bash ```bash
nanobot channels login whatsapp nanobot channels login whatsapp
@ -317,30 +419,45 @@ nanobot channels login whatsapp
"channels": { "channels": {
"whatsapp": { "whatsapp": {
"enabled": true, "enabled": true,
"allowFrom": ["+1234567890"] "allowFrom": ["1234567890"]
} }
} }
} }
``` ```
**3. Run** (two terminals) For groups, `allowFrom` can contain either a participant sender ID/LID or a
group JID/bare group ID. A participant entry allows that sender wherever the bot
can see them; a group entry allows replies in that group.
```bash Optional session database path:
# Terminal 1
nanobot channels login whatsapp
# Terminal 2 ```json
nanobot gateway {
"channels": {
"whatsapp": {
"databasePath": "~/.nanobot/whatsapp-auth/neonize.db"
}
}
}
``` ```
> WhatsApp bridge updates are not applied automatically for existing installations. After upgrading nanobot, rebuild the local bridge with: **Migrating from the old bridge**
> `rm -rf ~/.nanobot/bridge && nanobot channels login whatsapp`
- Remove `bridgeUrl` and `bridgeToken`; WhatsApp no longer runs a local Node.js bridge.
- Re-run `nanobot channels login whatsapp`; old Baileys bridge auth data is not reused by neonize.
- Update `allowFrom` entries to the WhatsApp sender ID without a leading `+`.
**3. Run**
```bash
nanobot gateway
```
**Optional: static LID mappings** **Optional: static LID mappings**
Modern WhatsApp can deliver a sender's LID instead of their phone number. nanobot Modern WhatsApp can deliver a sender's LID instead of their phone number. nanobot
learns the LID→phone mapping at runtime (and reuses the ones the bridge persists on learns LID to phone mappings at runtime when both identifiers are present, but you
disk), but you can also seed mappings up front so the phone number resolves from the can also seed mappings up front so the phone number resolves from the
very first message: very first message:
```json ```json
@ -348,7 +465,7 @@ very first message:
"channels": { "channels": {
"whatsapp": { "whatsapp": {
"enabled": true, "enabled": true,
"allowFrom": ["+1234567890"], "allowFrom": ["1234567890"],
"lidMappings": { "123456789012345": "1234567890" } "lidMappings": { "123456789012345": "1234567890" }
} }
} }
@ -365,6 +482,7 @@ Uses **WebSocket** long connection — no public IP required.
**Quick setup: QR login** **Quick setup: QR login**
```bash ```bash
nanobot plugins enable feishu
nanobot channels login feishu nanobot channels login feishu
# Use --force to create/sign in with a new bot # Use --force to create/sign in with a new bot
``` ```
@ -435,6 +553,12 @@ nanobot gateway
Uses **botpy SDK** with WebSocket — no public IP required. Currently supports **private messages only**. Uses **botpy SDK** with WebSocket — no public IP required. Currently supports **private messages only**.
**Install the optional channel dependency**
```bash
nanobot plugins enable qq
```
**1. Register & create bot** **1. Register & create bot**
- Visit [QQ Open Platform](https://q.qq.com) → Register as a developer (personal or enterprise) - Visit [QQ Open Platform](https://q.qq.com) → Register as a developer (personal or enterprise)
- Create a new bot application - Create a new bot application
@ -487,6 +611,12 @@ Connects to a [Napcat](https://github.com/NapNeko/NapCatQQ) instance over its **
- Copy the forward websocket server's token - Copy the forward websocket server's token
- (Optional) In the webui, follow "系统配置" -> "登陆配置" -> "快速登录QQ" to automatically login after restarts - (Optional) In the webui, follow "系统配置" -> "登陆配置" -> "快速登录QQ" to automatically login after restarts
**Install the optional channel dependency**
```bash
nanobot plugins enable napcat
```
**2. Configure** **2. Configure**
```json ```json
@ -524,6 +654,12 @@ Connects to a [Napcat](https://github.com/NapNeko/NapCatQQ) instance over its **
Uses **Stream Mode** — no public IP required. Uses **Stream Mode** — no public IP required.
**Install the optional channel dependency**
```bash
nanobot plugins enable dingtalk
```
**1. Create a DingTalk bot** **1. Create a DingTalk bot**
- Visit [DingTalk Open Platform](https://open-dev.dingtalk.com/) - Visit [DingTalk Open Platform](https://open-dev.dingtalk.com/)
- Create a new app -> Add **Robot** capability - Create a new app -> Add **Robot** capability
@ -566,6 +702,12 @@ nanobot gateway
Uses **Socket Mode** — no public URL required. Uses **Socket Mode** — no public URL required.
**Install the optional channel dependency**
```bash
nanobot plugins enable slack
```
**1. Create a Slack app** **1. Create a Slack app**
- Go to [Slack API](https://api.slack.com/apps) → **Create New App** → "From scratch" - Go to [Slack API](https://api.slack.com/apps) → **Create New App** → "From scratch"
- Pick a name and select your workspace - Pick a name and select your workspace
@ -676,10 +818,10 @@ nanobot gateway
Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required. Uses **HTTP long-poll** with QR-code login via the ilinkai personal WeChat API. No local WeChat desktop client is required.
**1. Install with WeChat support** **1. Enable WeChat support**
```bash ```bash
python -m pip install "nanobot-ai[weixin]" nanobot plugins enable weixin
``` ```
**2. Configure** **2. Configure**
@ -728,10 +870,10 @@ nanobot gateway
> >
> Uses **WebSocket** long connection — no public IP required. > Uses **WebSocket** long connection — no public IP required.
**1. Install the optional dependency** **1. Enable WeCom support**
```bash ```bash
python -m pip install "nanobot-ai[wecom]" nanobot plugins enable wecom
``` ```
**2. Create a WeCom AI Bot** **2. Create a WeCom AI Bot**
@ -767,10 +909,10 @@ nanobot gateway
> Direct-message text in/out, tenant-aware OAuth, conversation reference persistence. > Direct-message text in/out, tenant-aware OAuth, conversation reference persistence.
> Uses a public HTTPS webhook — no WebSocket; you need a tunnel or reverse proxy. > Uses a public HTTPS webhook — no WebSocket; you need a tunnel or reverse proxy.
**1. Install the optional dependency** **1. Enable Microsoft Teams support**
```bash ```bash
python -m pip install "nanobot-ai[msteams]" nanobot plugins enable msteams
``` ```
**2. Create a Teams / Azure bot app registration** **2. Create a Teams / Azure bot app registration**

View File

@ -9,13 +9,17 @@ These commands work inside chat channels and interactive agent sessions:
| `/restart` | Restart the bot | | `/restart` | Restart the bot |
| `/status` | Show bot status | | `/status` | Show bot status |
| `/model` | Show the current model and available model presets | | `/model` | Show the current model and available model presets |
| `/model <preset>` | Switch the runtime model preset for future turns | | `/model <preset>` | Switch and persist the model preset for the current session |
| `/dream` | Run Dream memory consolidation now | | `/dream` | Run Dream memory consolidation now |
| `/dream-log` | Show the latest Dream memory change | | `/dream-log` | Show the latest Dream memory change |
| `/dream-log <sha>` | Show a specific Dream memory change | | `/dream-log <sha>` | Show a specific Dream memory change |
| `/dream-restore` | List recent Dream memory versions | | `/dream-restore` | List recent Dream memory versions |
| `/dream-restore <sha>` | Restore memory to the state before a specific change | | `/dream-restore <sha>` | Restore memory to the state before a specific change |
| `/dream-prompt` | Show how Dream is being guided for memory |
| `/dream-prompt init` | Create an editable Dream memory guide at `prompts/dream.md` |
| `/skill` | List enabled skills and their descriptions | | `/skill` | List enabled skills and their descriptions |
| `/trigger` | Show local trigger usage |
| `/trigger <name>` | Create a named local trigger for the current chat/session |
| `/pairing` | List pending pairing requests | | `/pairing` | List pending pairing requests |
| `/pairing approve <code>` | Approve a pairing code | | `/pairing approve <code>` | Approve a pairing code |
| `/pairing deny <code>` | Deny a pending pairing request | | `/pairing deny <code>` | Deny a pending pairing request |
@ -43,7 +47,7 @@ Use `/model` to inspect the current runtime model:
/model /model
``` ```
The response shows the current model, the current preset, and the available preset names. Named presets come from the top-level `modelPresets` config and are the recommended way to configure model choices. `default` is always available and represents the model settings from direct `agents.defaults.*` fields. The response shows the current session's model and preset, plus the available preset names. Named presets come from the top-level `modelPresets` config and are the recommended way to configure model choices. `default` is always available and represents the model settings from direct `agents.defaults.*` fields.
To switch presets for future turns: To switch presets for future turns:
@ -53,22 +57,86 @@ To switch presets for future turns:
/model default /model default
``` ```
Preset names come from the top-level `modelPresets` config. Switching is runtime-only: it does not rewrite `config.json`, and an in-progress turn keeps using the model it started with. See [Configuration: Model presets](./configuration.md#model-presets) for setup details. Preset names come from the top-level `modelPresets` config. Switching affects only the current session and persists the selection in that session, so later turns keep using it across process restarts. It does not rewrite `config.json`, does not change other sessions, and does not alter an in-progress turn's captured model. Sessions without a saved selection follow `agents.defaults.modelPreset` (or the implicit `default` preset when it is omitted). See [Configuration: Model presets](./configuration.md#model-presets) for setup details.
## Local triggers
Use `/trigger <name>` when a local script or another service should be able to
send a message into the current chat/session later. A name is required; plain
`/trigger` only shows the usage hint.
Create the trigger from the chat where future messages should arrive:
```text
/trigger PR review
```
nanobot replies with a trigger ID and a command shaped like:
```bash
nanobot trigger trg_8K4P2Q9X "Review PR #4502"
```
Replace `"Review PR #4502"` with the message you want nanobot to receive. The
trigger is bound to the session where it was created, so the message goes back
to that same chat. Keep `nanobot gateway` running so trigger messages can be
delivered. The trigger message starts an automation turn recorded in that
session with the message you passed to the CLI; it is not treated as a normal
user message. If that session is already running a turn, the trigger waits
until the session is idle instead of being injected into the active turn.
Trigger deliveries are stored in the workspace until their linked agent turn
finishes successfully. If the gateway exits after claiming a delivery but before
the turn completes, the next gateway start requeues that delivery. This is an
at-least-once local queue: a delivery may run more than once if the process
exits at the wrong time, so external scripts should make repeated trigger
messages safe. If the delivery reaches the agent and the agent turn fails, the
delivery is marked failed in Automations instead of retrying forever.
For longer or generated content, omit the message argument and pipe stdin:
```bash
printf '%s\n' "Review the latest failed CI job" | nanobot trigger trg_8K4P2Q9X
```
If an external webhook should wake nanobot up, run your own small webhook
service and have it call the trigger command after it builds the final message:
```bash
nanobot trigger <trigger-id> "<message>"
```
If you run multiple nanobot instances, pass the same config or workspace
selector used by the gateway:
```bash
nanobot trigger --config ./bot-a/config.json trg_8K4P2Q9X "Nightly report"
nanobot trigger --workspace ./bot-a/workspace trg_8K4P2Q9X "Nightly report"
```
Manage triggers from the WebUI Automations view. You can search, pause/resume,
rename, delete, and copy the trigger command there. A session may have multiple
triggers, just like it may have multiple scheduled automations.
See [Automations](./automations.md) for how local triggers fit with scheduled
automations, heartbeat, and gateway delivery.
## Periodic Tasks ## Periodic Tasks
Periodic tasks are driven by `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). When `nanobot gateway` starts, it registers a protected heartbeat cron job by default. Every 30 minutes, that job checks the file; if it finds tasks under `## Active Tasks`, the agent executes them and delivers results to your most recently active chat channel. If there are no active tasks, the heartbeat is skipped silently. Periodic background checks are driven by `HEARTBEAT.md` in your workspace (`~/.nanobot/workspace/HEARTBEAT.md`). When `nanobot gateway` starts, it registers a protected heartbeat cron job by default. Every 30 minutes, that job checks the file; if it finds tasks under `## Active Tasks`, the agent executes them and delivers only results that pass the notification gate to your most recently active chat channel. If there are no active tasks, or the result is routine with nothing useful to report, the heartbeat is skipped silently.
Use heartbeat for recurring checks that should usually stay quiet. User-created cron jobs are different: they run as scheduled turns in the chat/session where they were created and normally deliver the result back to that channel.
**Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`): **Setup:** edit `~/.nanobot/workspace/HEARTBEAT.md` (created automatically by `nanobot onboard`):
```markdown ```markdown
## Active Tasks ## Active Tasks
- Check weather forecast and send a summary - Check weather forecast and notify me only if storms are expected
- Scan inbox for urgent emails - Scan inbox for urgent emails and notify me if any are found
``` ```
The agent can also manage this file itself — ask it to "add a periodic task" and it will update `HEARTBEAT.md` for you. Completed tasks should be deleted from the file, not moved to another section. The agent can also manage this file itself - ask it to "add a periodic background check" or "check this periodically but only notify me if something changes" and it will update `HEARTBEAT.md` for you. Completed tasks should be deleted from the file, not moved to another section.
You can change the interval or disable the built-in heartbeat in `~/.nanobot/config.json`: You can change the interval or disable the built-in heartbeat in `~/.nanobot/config.json`:

View File

@ -8,15 +8,19 @@ Use this page when you know what you want to run and need the command shape. For
|---|---|---| |---|---|---|
| Check the install | `nanobot --version` | If this fails, try `python -m nanobot --version` | | Check the install | `nanobot --version` | If this fails, try `python -m nanobot --version` |
| Create or refresh config | `nanobot onboard` | Creates `~/.nanobot/config.json` and `~/.nanobot/workspace/` | | Create or refresh config | `nanobot onboard` | Creates `~/.nanobot/config.json` and `~/.nanobot/workspace/` |
| Refresh config non-interactively | `nanobot onboard --refresh` | Preserves existing values and adds missing default fields without prompting |
| Use guided setup | `nanobot onboard --wizard` | Best when you prefer prompts over hand-editing JSON | | Use guided setup | `nanobot onboard --wizard` | Best when you prefer prompts over hand-editing JSON |
| Check config without calling a model | `nanobot status` | Reads the default config and summarizes the active model/provider | | Open the browser workbench | `nanobot webui` | Prepares local WebUI settings, starts the gateway, and opens the browser |
| Check readiness without calling a model | `nanobot status` | Summarizes config/workspace and validates the active provider/model configuration |
| Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work | | Send one test message | `nanobot agent -m "Hello!"` | First proof that install, config, provider, model, and workspace all work |
| Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` | | Chat in the terminal | `nanobot agent` | Interactive local chat; exit with `exit`, `/exit`, `:q`, or `Ctrl+D` |
| Use WebUI or chat apps | `nanobot gateway` | Keep this terminal running, or use `nanobot gateway --background` | | Run the gateway directly | `nanobot gateway` | Service/ops command for WebUI, chat apps, cron, and heartbeat |
| Deliver a local trigger | `nanobot trigger <id> "message"` | Created first with `/trigger <name>` in the target chat/session |
| Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` | | Serve an OpenAI-compatible API | `nanobot serve` | Starts `/v1/chat/completions`, `/v1/models`, and `/health` |
| Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` | | Check chat channel setup | `nanobot channels status` | Useful before starting `nanobot gateway` |
| Manage optional features | `nanobot plugins list` | Shows channels and optional capabilities you can turn on |
| Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat | | Log in to QR/OAuth-style channels | `nanobot channels login <channel>` | Used by channels such as WhatsApp and WeChat |
| Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OAuth providers such as OpenAI Codex and GitHub Copilot | | Log in to OAuth model providers | `nanobot provider login <provider>` | Used by OpenAI Codex, xAI subscription, and GitHub Copilot providers |
## Global ## Global
@ -55,6 +59,7 @@ with `--background`, use `nanobot gateway stop`.
| Command | Description | | Command | Description |
|---|---| |---|---|
| `nanobot onboard` | Initialize or refresh the default config and workspace | | `nanobot onboard` | Initialize or refresh the default config and workspace |
| `nanobot onboard --refresh` | Refresh an existing config without prompting, preserving existing values |
| `nanobot onboard --wizard` | Use the interactive setup wizard | | `nanobot onboard --wizard` | Use the interactive setup wizard |
| `nanobot onboard --config <path> --workspace <path>` | Initialize or refresh a specific instance | | `nanobot onboard --config <path> --workspace <path>` | Initialize or refresh a specific instance |
@ -65,6 +70,18 @@ Default paths:
| Config | `~/.nanobot/config.json` | | Config | `~/.nanobot/config.json` |
| Workspace | `~/.nanobot/workspace/` | | Workspace | `~/.nanobot/workspace/` |
## Status
| Command | Description |
|---|---|
| `nanobot status` | Summarize the default config/workspace and check Agent provider/model readiness |
| `nanobot status --config <path>` | Check a specific config file |
| `nanobot status --workspace <path>` | Show status with a workspace override |
Status does not send a model request. On success, run the printed
`nanobot agent -m "Hello!"` command to verify network access and credentials. On failure,
follow the printed WebUI **Settings → Models** or `nanobot onboard --wizard` route.
## Agent CLI ## Agent CLI
| Command | Description | | Command | Description |
@ -77,11 +94,31 @@ Default paths:
| `nanobot agent --no-markdown` | Print plain text instead of Rich-rendered Markdown | | `nanobot agent --no-markdown` | Print plain text instead of Rich-rendered Markdown |
| `nanobot agent --logs` | Show runtime logs while chatting | | `nanobot agent --logs` | Show runtime logs while chatting |
In interactive mode, `Enter` sends the current message. Press `Alt+Enter` to add a newline before sending.
Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`. Interactive mode exits with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
## WebUI
| Command | Description |
|---|---|
| `nanobot webui` | Create config/workspace if needed, enable the local WebUI channel after confirmation, start the gateway, and open `http://127.0.0.1:8765` |
| `nanobot webui --background` | Start or reuse a background gateway, then open the WebUI |
| `nanobot webui --dev` | Start the gateway and Vite together at `http://127.0.0.1:5173`, with live frontend updates |
| `nanobot webui --no-open` | Prepare and start the WebUI without opening a browser |
| `nanobot webui --port <port>` | Set the WebUI/WebSocket port |
| `nanobot webui --gateway-port <port>` | Override the gateway health port |
| `nanobot webui --yes` | Apply safe localhost WebUI defaults without confirmation; configure provider credentials in **Settings → Models** |
First-run WebUI setup binds to `127.0.0.1` by default. Use manual configuration and a WebUI password before exposing the WebSocket channel beyond localhost.
`--dev` is a foreground source-checkout workflow and cannot be combined with `--background`.
It installs frontend dependencies when `webui/node_modules` is missing, proxies to the configured
WebSocket channel port, and stops Vite together with the foreground gateway.
## Gateway ## Gateway
`nanobot gateway` starts enabled chat channels, WebUI/WebSocket when configured, cron-backed system jobs, Dream, heartbeat, and the health endpoint. By default it runs in the foreground, which keeps existing scripts and terminal workflows unchanged. Use `--background` when you want a local macOS, Linux, or Windows process that you can manage from the CLI. `nanobot gateway` starts enabled chat channels, WebUI/WebSocket when configured, cron-backed system jobs, Dream, heartbeat, and the health endpoint. Most local browser users should start with `nanobot webui`; use `gateway` directly for service management, chat app operation, and advanced deployment. By default it runs in the foreground, which keeps existing scripts and terminal workflows unchanged. Use `--background` when you want a local macOS, Linux, or Windows process that you can manage from the CLI.
| Command | Description | | Command | Description |
|---|---| |---|---|
@ -122,6 +159,55 @@ http://127.0.0.1:18790/health
The bundled WebUI is served by the WebSocket channel, usually on port `8765`, not by the gateway health endpoint. The bundled WebUI is served by the WebSocket channel, usually on port `8765`, not by the gateway health endpoint.
## Local Triggers
`nanobot trigger` delivers one local message to a trigger that was created from
a chat/session with `/trigger <name>`.
```bash
nanobot trigger trg_8K4P2Q9X "Review PR #4502"
```
Keep `nanobot gateway` running so the message can be delivered to the linked
chat/session. The message is recorded as an automation turn in that session,
not as a normal chat message typed by the user.
The command writes to a workspace-local durable queue. If `nanobot gateway` is
not running yet, the message waits in that workspace. If the target session is
already running a turn, the trigger waits for that session to become idle. If the
gateway exits after claiming a delivery but before the linked turn completes,
the next gateway start requeues that delivery. The queue is at-least-once, not
exactly-once, so the same message can be delivered again after an interrupted
process. If the agent receives the delivery and the turn fails, the delivery is
marked failed instead of retried indefinitely. Each delivery also writes an
audit record under `<workspace>/triggers/runs`. Run one gateway consumer per
workspace; this local queue is not a distributed multi-consumer queue.
Use stdin when another local process generates the message:
```bash
generate-report | nanobot trigger trg_8K4P2Q9X
```
Options:
| Command | Description |
|---|---|
| `nanobot trigger <id> "message"` | Deliver one message through a trigger |
| `nanobot trigger <id>` | Read the message from stdin |
| `nanobot trigger --config <path> <id> "message"` | Use the workspace from a specific config |
| `nanobot trigger --workspace <path> <id> "message"` | Use a specific workspace |
Triggers are managed in the WebUI Automations view instead of through separate
`list`, `revoke`, or `delete` CLI subcommands. From there you can pause/resume,
rename, delete, search, and copy the command for each trigger.
For webhooks or other external systems, run your own small service and have it
call this CLI after it decides what message nanobot should receive.
See [Automations](./automations.md) for the broader automation model, WebUI
management, and delivery behavior.
## OpenAI-Compatible API ## OpenAI-Compatible API
| Command | Description | | Command | Description |
@ -140,6 +226,8 @@ Default API endpoint:
http://127.0.0.1:8900 http://127.0.0.1:8900
``` ```
Public binds (`0.0.0.0` or `::`) require `api.apiKey`; send it as a Bearer token on API routes.
See [`openai-api.md`](./openai-api.md) for request examples. See [`openai-api.md`](./openai-api.md) for request examples.
## Status ## Status
@ -148,7 +236,13 @@ See [`openai-api.md`](./openai-api.md) for request examples.
nanobot status nanobot status
``` ```
Shows the default config path, workspace path, active model, and provider summary. This command does not currently accept `--config`; use explicit `--config` and `--workspace` on `agent`, `gateway`, or `serve` when debugging a specific instance. Shows the config path, workspace path, active model, and provider summary without calling a model.
| Command | Description |
|---|---|
| `nanobot status` | Inspect the default instance |
| `nanobot status --config <path>` | Inspect a specific config |
| `nanobot status --config <path> --workspace <path>` | Inspect a specific config with a workspace override |
## Channels ## Channels
@ -159,6 +253,7 @@ Shows the default config path, workspace path, active model, and provider summar
| `nanobot channels login <channel>` | Run interactive login for supported channels | | `nanobot channels login <channel>` | Run interactive login for supported channels |
| `nanobot channels login <channel> --force` | Re-authenticate even if credentials already exist | | `nanobot channels login <channel> --force` | Re-authenticate even if credentials already exist |
| `nanobot channels login <channel> --config <path>` | Use a specific config file | | `nanobot channels login <channel> --config <path>` | Use a specific config file |
| `nanobot plugins list --config <path>` | Show plugin/channel enabled state for a specific config |
Examples: Examples:
@ -170,13 +265,49 @@ nanobot channels status
See [`chat-apps.md`](./chat-apps.md) for channel-specific setup. See [`chat-apps.md`](./chat-apps.md) for channel-specific setup.
## Optional Features
Use these commands when you want nanobot to add or remove a built-in capability
without hand-editing JSON. Enabling may install the support package first.
Disabling is for channels such as Telegram, Matrix, or Slack; it keeps your
saved settings and turns the channel off.
The `plugins` command name is retained for compatibility, but these entries are
nanobot runtime support packages, not the user-invokable tools shown in WebUI
Apps. They cannot be attached to a chat turn with `@`.
| Feature name | What it enables |
|---|---|
| `api` | Dependencies required by the OpenAI-compatible `nanobot serve` process |
| `azure` | Azure identity support for Azure-hosted models |
| `bedrock` | AWS Bedrock model provider support |
| `langfuse` | Langfuse tracing support for OpenAI-compatible providers |
| `olostep` | Olostep web search provider support |
| A channel name such as `telegram` or `slack` | The connector package and saved channel enablement |
| Command | Description |
|---|---|
| `nanobot plugins list` | Show available channels and optional capabilities |
| `nanobot plugins enable <name>` | Install missing support and enable the feature or channel |
| `nanobot plugins enable <name> --logs` | Show package install logs while enabling |
| `nanobot plugins disable <channel>` | Turn off a channel without deleting its saved settings |
| `nanobot plugins list --config <path>` | Read a specific config file |
| `nanobot plugins enable <name> --config <path>` | Update a specific config file |
| `nanobot plugins disable <channel> --config <path>` | Turn off a channel in a specific config file |
Document and PDF reading are included in the standard installation. The old
`nanobot plugins enable documents` and `nanobot plugins enable pdf` commands
remain accepted as no-op compatibility aliases.
## Provider OAuth ## Provider OAuth
| Command | Description | | Command | Description |
|---|---| |---|---|
| `nanobot provider login openai-codex` | Authenticate OpenAI Codex provider | | `nanobot provider login openai-codex --set-main` | Authenticate Codex and select its current default model |
| `nanobot provider login github-copilot` | Authenticate GitHub Copilot provider | | `nanobot provider login xai-grok --set-main` | Authenticate an eligible X Premium / Grok subscription and select Grok 4.5; hosted X Search is enabled for models that advertise support |
| `nanobot provider login github-copilot --set-main` | Authenticate GitHub Copilot and select its current default model |
| `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state | | `nanobot provider logout openai-codex` | Remove OpenAI Codex OAuth state |
| `nanobot provider logout xai-grok --config <path>` | Remove the selected nanobot instance's xAI OAuth state |
| `nanobot provider logout github-copilot` | Remove GitHub Copilot OAuth state | | `nanobot provider logout github-copilot` | Remove GitHub Copilot OAuth state |
See [`providers.md`](./providers.md#oauth-providers) for when OAuth providers need explicit provider/model selection. See [`providers.md`](./providers.md#oauth-providers) for when OAuth providers need explicit provider/model selection.

View File

@ -12,7 +12,7 @@ nanobot has one small core loop and several ways to enter it:
|---|---| |---|---|
| Agent loop | Builds context, selects the session, calls the provider, runs tools, and publishes replies | | Agent loop | Builds context, selects the session, calls the provider, runs tools, and publishes replies |
| Providers | LLM backends such as OpenRouter, Anthropic, OpenAI, Bedrock, Ollama, vLLM, and other OpenAI-compatible APIs | | Providers | LLM backends such as OpenRouter, Anthropic, OpenAI, Bedrock, Ollama, vLLM, and other OpenAI-compatible APIs |
| Channels | User-facing transports such as CLI, WebUI/WebSocket, Telegram, Discord, Slack, Feishu, WeChat, Email, and others | | Channels | User-facing transports such as CLI, WebUI/WebSocket, Telegram, Discord, Slack, Feishu, WeChat, Email, Mattermost, and others |
| Tools | Capabilities the model may call, including files, shell, web search/fetch, MCP, cron, image generation, and subagents | | Tools | Capabilities the model may call, including files, shell, web search/fetch, MCP, cron, image generation, and subagents |
| Memory | Workspace files and session history that keep useful context across turns | | Memory | Workspace files and session history that keep useful context across turns |
| Gateway | Long-running process that connects enabled channels and serves the health endpoint | | Gateway | Long-running process that connects enabled channels and serves the health endpoint |
@ -38,6 +38,23 @@ nanobot gateway --config ./bot-a/config.json --workspace ./bot-a/workspace
The config file controls what nanobot may use. The workspace is where nanobot keeps state for that instance. The config file controls what nanobot may use. The workspace is where nanobot keeps state for that instance.
### Agent Workspace and Project Workspace
The configured workspace is the **agent workspace**. A WebUI chat can also select
a different **project workspace** for repository-specific work without moving the
agent's identity or durable state.
| Resource | Owner when a project is selected |
|---|---|
| Project instructions | `AGENTS.md` from the selected project; there is no fallback to the agent workspace's `AGENTS.md` |
| Agent profile | `SOUL.md` and `USER.md` from the agent workspace; project-local files with those names are ignored |
| Memory and custom skills | `memory/` and `skills/` from the agent workspace |
| Relative file paths and shell working directory | The selected project workspace |
When no separate project is selected, one directory normally serves both roles.
Selecting a project changes the working context for that chat; it does not create
a second agent or relocate the configured agent workspace.
## Config Format ## Config Format
`config.json` accepts both camelCase and snake_case keys. The docs use camelCase because nanobot writes config back to disk with camelCase aliases, for example `apiKey`, `modelPresets`, `intervalS`, and `maxToolResultChars`. `config.json` accepts both camelCase and snake_case keys. The docs use camelCase because nanobot writes config back to disk with camelCase aliases, for example `apiKey`, `modelPresets`, `intervalS`, and `maxToolResultChars`.
@ -49,7 +66,7 @@ Most examples are partial snippets. Merge them into the existing file created by
A normal turn follows this flow: A normal turn follows this flow:
1. A channel receives a user message and publishes it to the message bus. 1. A channel receives a user message and publishes it to the message bus.
2. The agent loop chooses a session key and builds context from the workspace, skills, memory, recent messages, channel metadata, and runtime settings. 2. The agent loop chooses a session key and builds context from the effective project workspace, agent-owned profile/skills/memory, recent messages, channel metadata, and runtime settings.
3. The provider receives the model request. 3. The provider receives the model request.
4. If the model asks for tools, the runner executes them and feeds results back to the model. 4. If the model asks for tools, the runner executes them and feeds results back to the model.
5. The final reply is saved to the session and sent back through the channel. 5. The final reply is saved to the session and sent back through the channel.
@ -64,9 +81,9 @@ That flow is the same whether the message starts in the CLI, WebUI, Telegram, Di
| CLI interactive | `nanobot agent` | Terminal chat with persistent session history | | CLI interactive | `nanobot agent` | Terminal chat with persistent session history |
| Gateway | `nanobot gateway` | Chat apps, WebUI, heartbeat, Dream, and long-running service mode | | Gateway | `nanobot gateway` | Chat apps, WebUI, heartbeat, Dream, and long-running service mode |
| OpenAI-compatible API | `nanobot serve` | Programmatic access through `/v1/chat/completions` | | OpenAI-compatible API | `nanobot serve` | Programmatic access through `/v1/chat/completions` |
| WebUI | `nanobot gateway` plus WebSocket channel | Browser workbench served by the WebSocket channel on port `8765` | | WebUI | `nanobot webui` | Prepare the local WebUI, start the gateway, and open the browser workbench |
The gateway health endpoint is on `gateway.port` (`18790` by default). The browser WebUI is served by the WebSocket channel (`8765` by default), not by the health endpoint. The WebUI launcher is the normal browser entry point. Underneath, the gateway keeps the WebSocket channel and other long-running services alive. The gateway health endpoint is on `gateway.port` (`18790` by default); the browser WebUI is served on `8765` by default, not by the health endpoint.
## Provider and Model Selection ## Provider and Model Selection
@ -123,7 +140,7 @@ Tools are discovered automatically from built-in modules and plugin entry points
- shell execution with configurable sandboxing; - shell execution with configurable sandboxing;
- web search and web fetch with SSRF checks; - web search and web fetch with SSRF checks;
- MCP servers; - MCP servers;
- cron reminders and heartbeat tasks; - cron reminders, local triggers, and heartbeat tasks;
- image generation; - image generation;
- subagents and runtime self-inspection. - subagents and runtime self-inspection.
@ -131,14 +148,29 @@ Security-sensitive controls live in [`configuration.md#security`](./configuratio
## Background Jobs ## Background Jobs
When `nanobot gateway` starts, it creates workspace-scoped cron storage at `<workspace>/cron/jobs.json` and registers system jobs: When `nanobot gateway` starts, it runs workspace-scoped automations and
registers system jobs:
- `dream`, when `agents.defaults.dream.enabled` is true; - `dream`, when `agents.defaults.dream.enabled` is true;
- `heartbeat`, when `gateway.heartbeat.enabled` is true. - `heartbeat`, when `gateway.heartbeat.enabled` is true.
Heartbeat reads `<workspace>/HEARTBEAT.md`. If the file has tasks under `## Active Tasks`, nanobot executes them and sends useful results to the most recently active chat target. Heartbeat reads `<workspace>/HEARTBEAT.md`. If the file has tasks under `## Active Tasks`, nanobot executes them and sends only useful/actionable results to the most recently active chat target. Routine "nothing changed" results are suppressed.
User-created reminders use the same cron service but are not the same as the protected heartbeat system job. User-created reminders use the same cron service but are not the same as the
protected heartbeat system job. They run as scheduled turns in their origin
chat/session and normally deliver the result back to that channel.
Local triggers are also session-bound, but they do not have their own
schedule. Create one from the target chat with `/trigger <name>`, then call
`nanobot trigger <id> "<message>"` when a local script or external service wants
nanobot to respond in that session. Webhook servers, third-party auth, and
event-to-message formatting stay outside nanobot. Trigger deliveries are stored
in the workspace until the linked agent turn finishes successfully. If the
target session is busy, the trigger waits until that session is idle instead of
being injected into the active turn. The message is recorded as an automation
turn in that session. Delivery is at-least-once, so external systems should
tolerate repeated trigger messages; a delivery that reaches the agent but fails
is marked failed rather than retried forever.
## Where to Go Next ## Where to Go Next

View File

@ -4,6 +4,8 @@ Config file: `~/.nanobot/config.json`
This is the full reference. If this is your first install, start with [`quick-start.md`](./quick-start.md). If you are trying to choose a model or fix provider/model matching, use [`providers.md`](./providers.md) first and come back here for exact fields and advanced options. This is the full reference. If this is your first install, start with [`quick-start.md`](./quick-start.md). If you are trying to choose a model or fix provider/model matching, use [`providers.md`](./providers.md) first and come back here for exact fields and advanced options.
For normal local use, prefer the WebUI before editing JSON: **Settings → Models** manages model choices and provider credentials, **Settings → Channels** guides chat-platform setup, other Settings pages cover built-in capabilities, and **Apps** manages CLI App and MCP integrations. Edit `config.json` directly when you need an advanced field, automate deployment, or intentionally manage configuration as code.
The JSON examples below are usually partial snippets to merge into your existing config, not full replacement files. For the mental model behind config, workspace, gateway, channels, sessions, tools, and memory, see [`concepts.md`](./concepts.md). The JSON examples below are usually partial snippets to merge into your existing config, not full replacement files. For the mental model behind config, workspace, gateway, channels, sessions, tools, and memory, see [`concepts.md`](./concepts.md).
The generated `config.json` uses camelCase keys such as `apiKey` and `intervalS`. snake_case keys are also accepted for compatibility, but the docs prefer camelCase because that is what nanobot writes back to disk. The generated `config.json` uses camelCase keys such as `apiKey` and `intervalS`. snake_case keys are also accepted for compatibility, but the docs prefer camelCase because that is what nanobot writes back to disk.
@ -11,13 +13,29 @@ The generated `config.json` uses camelCase keys such as `apiKey` and `intervalS`
For setup and runtime failures, follow the diagnosis order in [`troubleshooting.md`](./troubleshooting.md) before changing multiple config areas at once. For setup and runtime failures, follow the diagnosis order in [`troubleshooting.md`](./troubleshooting.md) before changing multiple config areas at once.
> [!NOTE] > [!NOTE]
> If your config file is older than the current schema, you can refresh it without overwriting your existing values: run `nanobot onboard`, then answer `N` when asked whether to overwrite the config. nanobot will merge in missing default fields and keep your current settings. > If your config file is older than the current schema, run `nanobot onboard --refresh`. nanobot adds missing default fields while preserving your existing values.
## Configuration Guides
This page is the complete configuration reference. For task-oriented setup, use
the focused guides first and come back here for exact fields and defaults.
| Task | Guide |
|---|---|
| Add MCP tools | [`guides/configure-mcp-tools.md`](./guides/configure-mcp-tools.md) |
| Enable web search and web fetch | [`guides/configure-web-search.md`](./guides/configure-web-search.md) |
| Configure model fallback | [`guides/configure-model-fallback.md`](./guides/configure-model-fallback.md) |
| Add an OpenAI-compatible provider | [`guides/configure-openai-compatible-provider.md`](./guides/configure-openai-compatible-provider.md) |
| Add Langfuse observability | [`guides/configure-langfuse-observability.md`](./guides/configure-langfuse-observability.md) |
| Secure a local AI agent | [`guides/secure-local-ai-agent.md`](./guides/secure-local-ai-agent.md) |
| Deploy the gateway | [`guides/deploy-nanobot-gateway.md`](./guides/deploy-nanobot-gateway.md) |
## Quick Jump ## Quick Jump
| Need | Section | | Need | Section |
|---|---| |---|---|
| Keep secrets out of `config.json` | [Environment Variables for Secrets](#environment-variables-for-secrets) | | Keep secrets out of `config.json` | [Environment Variables for Secrets](#environment-variables-for-secrets) |
| Tune process-level behavior with env vars | [Runtime Environment Variables](#runtime-environment-variables) |
| Trace model calls | [Langfuse Observability](#langfuse-observability) | | Trace model calls | [Langfuse Observability](#langfuse-observability) |
| Configure credentials and endpoints | [Providers](#providers) | | Configure credentials and endpoints | [Providers](#providers) |
| Name and switch model choices | [Model Presets](#model-presets) | | Name and switch model choices | [Model Presets](#model-presets) |
@ -31,23 +49,24 @@ For setup and runtime failures, follow the diagnosis order in [`troubleshooting.
| Control access and pairing | [Pairing](#pairing) | | Control access and pairing | [Pairing](#pairing) |
| Tune gateway jobs, sessions, and tools | [Gateway Heartbeat](#gateway-heartbeat), [Auto Compact](#auto-compact), [Unified Session](#unified-session), [Tool Hint Max Length](#tool-hint-max-length) | | Tune gateway jobs, sessions, and tools | [Gateway Heartbeat](#gateway-heartbeat), [Auto Compact](#auto-compact), [Unified Session](#unified-session), [Tool Hint Max Length](#tool-hint-max-length) |
## Where to Edit First ## Where a Setting Lives
If you are not sure where a setting belongs, start from the task you are trying to complete. Most changes touch one config section and one verification command. If the WebUI does not expose the option you need, start from the task below. Most advanced changes touch one config section and one verification command.
| Task | First keys to check | Verify with | Deep dive | | Task | First keys to check | Verify with | Deep dive |
|---|---|---|---| |---|---|---|---|
| Make the first model reply work | `providers.<name>.apiKey`, optional `providers.<name>.apiBase`, `modelPresets.<preset>`, `agents.defaults.modelPreset` | `nanobot status`, then `nanobot agent -m "Hello!"` | [Providers](#providers), [Model Presets](#model-presets) | | Make the first model reply work | `providers.<name>.apiKey`, optional `providers.<name>.apiBase`, `modelPresets.<preset>`, `agents.defaults.modelPreset` | `nanobot status`, then `nanobot agent -m "Hello!"` | [Providers](#providers), [Model Presets](#model-presets) |
| Add fallback models | `modelPresets.<fallback>`, `agents.defaults.fallbackModels` | `nanobot status`, then a normal agent run | [Model Fallbacks](#model-fallbacks) | | Add fallback models | `modelPresets.<fallback>`, `agents.defaults.fallbackModels` | `nanobot status`, then a normal agent run | [Model Fallbacks](#model-fallbacks) |
| Keep secrets out of the config file | `${ENV_VAR}` placeholders inside any string value | Start nanobot from the same environment that sets the variable | [Environment Variables for Secrets](#environment-variables-for-secrets) | | Keep secrets out of the config file | `${ENV_VAR}` placeholders inside any string value | Start nanobot from the same environment that sets the variable | [Environment Variables for Secrets](#environment-variables-for-secrets) |
| Open the bundled WebUI | `channels.websocket.enabled`, optional `channels.websocket.port`, `channels.websocket.tokenIssueSecret` | `nanobot gateway`, then open `http://127.0.0.1:8765` | [Channel Settings](#channel-settings), [WebSocket docs](./websocket.md) | | Open the bundled WebUI | `channels.websocket.enabled`, optional `channels.websocket.port`, `channels.websocket.tokenIssueSecret` | `nanobot webui` | [Channel Settings](#channel-settings), [WebSocket docs](./websocket.md) |
| Connect one chat app | `channels.<channel>.enabled`, channel credentials, `channels.<channel>.allowFrom` | `nanobot channels status`, then `nanobot gateway --verbose` | [Channel Settings](#channel-settings), [Chat Apps](./chat-apps.md) | | Connect one chat app | `channels.<channel>.enabled`, channel credentials, optional pairing or `channels.<channel>.allowFrom` | `nanobot channels status`, then `nanobot gateway --verbose` | [Channel Settings](#channel-settings), [Chat Apps](./chat-apps.md) |
| Enable voice transcription | `transcription.enabled`, `transcription.provider`, matching `providers.<name>.apiKey` | Send or upload a short voice message through a configured surface | [Transcription Settings](#transcription-settings) | | Enable voice transcription | `transcription.enabled`, `transcription.provider`, matching `providers.<name>.apiKey` | Send or upload a short voice message through a configured surface | [Transcription Settings](#transcription-settings) |
| Enable web search or fetch | `tools.web.search.*`, `tools.web.fetch.*`, optional `tools.ssrfWhitelist` | Ask a question that requires current web information, then inspect logs if needed | [Web Tools](#web-tools), [Security](#security) | | Enable web search or fetch | `tools.web.search.*`, `tools.web.fetch.*`, optional `tools.ssrfWhitelist` | Ask a question that requires current web information, then inspect logs if needed | [Web Tools](#web-tools), [Security](#security) |
| Enable image generation | `tools.imageGeneration.enabled`, `tools.imageGeneration.provider`, `tools.imageGeneration.model`, matching provider credentials | Enable Image Generation in the WebUI and send one image request | [Image Generation](#image-generation) | | Enable image generation | `tools.imageGeneration.enabled`, `tools.imageGeneration.provider`, `tools.imageGeneration.model`, matching provider credentials | Enable Image Generation in the WebUI and send one image request | [Image Generation](#image-generation) |
| Add external tools through MCP | `tools.mcpServers.<name>` | Start `nanobot gateway --verbose` and check startup/tool logs | [MCP](#mcp-model-context-protocol) | | Add external tools through MCP | `tools.mcpServers.<name>` | Start `nanobot gateway --verbose` and check startup/tool logs | [MCP](#mcp-model-context-protocol) |
| Tighten tool and network safety | `tools.restrictToWorkspace`, `tools.exec.sandbox`, `tools.ssrfWhitelist`, `channels.*.allowFrom` | Run the same workflow through the channel or CLI you plan to expose | [Security](#security), [Pairing](#pairing) | | Tighten tool and network safety | `tools.restrictToWorkspace`, `tools.exec.sandbox`, `tools.ssrfWhitelist`, `channels.*.allowFrom` | Run the same workflow through the channel or CLI you plan to expose | [Security](#security), [Pairing](#pairing) |
| Run multiple isolated bots | separate `--config` and `--workspace` paths, plus distinct `gateway.port` or channel ports when processes run together | Start each process with explicit paths and run `nanobot status` for the default instance only | [Multiple Instances](./multiple-instances.md), [CLI Reference](./cli-reference.md) | | Tune request timeouts or process concurrency | `NANOBOT_LLM_TIMEOUT_S`, `NANOBOT_STREAM_IDLE_TIMEOUT_S`, `NANOBOT_MAX_CONCURRENT_REQUESTS` | Start nanobot from the same environment and inspect startup/runtime logs | [Runtime Environment Variables](#runtime-environment-variables) |
| Run multiple isolated bots | separate `--config` and `--workspace` paths, plus distinct `gateway.port` or channel ports when processes run together | Use the same explicit paths with `nanobot status`, `agent`, `webui`, `gateway`, and `serve` | [Multiple Instances](./multiple-instances.md), [CLI Reference](./cli-reference.md) |
| Observe model calls | `LANGFUSE_SECRET_KEY`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_BASE_URL` environment variables | Run one model call, then check the matching Langfuse project | [Langfuse Observability](#langfuse-observability) | | Observe model calls | `LANGFUSE_SECRET_KEY`, `LANGFUSE_PUBLIC_KEY`, `LANGFUSE_BASE_URL` environment variables | Run one model call, then check the matching Langfuse project | [Langfuse Observability](#langfuse-observability) |
## Environment Variables for Secrets ## Environment Variables for Secrets
@ -71,7 +90,9 @@ Instead of storing secrets directly in `config.json`, you can use `${VAR_NAME}`
Any string value in `config.json` can use `${VAR_NAME}`. Resolution runs once at startup, in memory only — resolved values are never written back to disk, so editing config through `nanobot onboard` or the WebUI preserves the placeholder. Any string value in `config.json` can use `${VAR_NAME}`. Resolution runs once at startup, in memory only — resolved values are never written back to disk, so editing config through `nanobot onboard` or the WebUI preserves the placeholder.
If a referenced variable is unset, nanobot fails fast at startup with `ValueError: Environment variable 'NAME' referenced in config is not set`. If a referenced variable is unset, nanobot fails fast and reports the exact config field
and variable name without echoing the field value. Run `nanobot status` with the same
`--config` path to inspect the problem.
### More examples ### More examples
@ -159,6 +180,38 @@ ANTHROPIC_API_KEY="$(pass show api/anthropic)" nanobot agent
ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent ANTHROPIC_API_KEY="$(bw get password api/anthropic)" nanobot agent
``` ```
## Runtime Environment Variables
These variables are process-level switches. Set them in the same terminal, service unit, container, or supervisor that starts nanobot.
### Runtime controls
| Variable | Default | Description |
|----------|---------|-------------|
| `NANOBOT_MAX_CONCURRENT_REQUESTS` | `3` | Maximum concurrently running inbound agent requests. Must be an integer; set `0` or a negative value for unlimited. |
| `NANOBOT_LLM_TIMEOUT_S` | `300` | Wall-clock timeout, in seconds. Ordinary requests use this value; streaming requests use the greater of 300 seconds or twice this value. Set `0` to disable. Sustained-goal turns bypass this wall-clock cap. |
| `NANOBOT_STREAM_IDLE_TIMEOUT_S` | `90` | Streaming idle timeout, in seconds, used by streaming providers. Invalid or non-positive values are ignored; values above `3600` are clamped. |
| `NANOBOT_OPENAI_COMPAT_TIMEOUT_S` | `120` | HTTP request timeout, in seconds, for OpenAI-compatible providers. Invalid or non-positive values are ignored. |
| `NANOBOT_WORKSPACE_SANDBOX_ENFORCED` | unset | Marks that an external workspace sandbox is already enforced. Truthy values (`1`, `true`, `yes`, `on`, `enabled`) use `NANOBOT_WORKSPACE_SANDBOX_PROVIDER` as the label; any other non-false value is treated as the provider name. |
| `NANOBOT_WORKSPACE_SANDBOX_PROVIDER` | `unknown` | Display label for the external workspace sandbox when `NANOBOT_WORKSPACE_SANDBOX_ENFORCED` is truthy, for example `macos_app_sandbox` or `bwrap`. |
| `NANOBOT_SANDBOX_ENFORCED` | unset | Legacy compatibility alias for `NANOBOT_WORKSPACE_SANDBOX_ENFORCED`. |
| `NANOBOT_TMUX_SOCKET_DIR` | `${TMPDIR:-/tmp}/nanobot-tmux-sockets` | Socket directory used by the bundled `tmux` skill scripts. |
### Installer, build, and WebUI development
| Variable | Default | Description |
|----------|---------|-------------|
| `NANOBOT_BIN_DIR` | `$HOME/.local/bin` | Installer launcher directory on macOS/Linux. |
| `NANOBOT_VENV` | `$HOME/.nanobot/venv` | Managed virtual environment path used by the installer fallback. |
| `NANOBOT_SKIP_WIZARD` | unset | Set to `1` to skip automatic WebUI or wizard setup after one-command install. |
| `NANOBOT_SKIP_WEBUI_BUILD` | unset | Set to `1` to skip bundling the WebUI during package builds. |
| `NANOBOT_FORCE_WEBUI_BUILD` | unset | Set to `1` to rebuild the bundled WebUI even when `nanobot/web/dist/index.html` already exists. |
| `NANOBOT_EXTRAS` | unset | Docker build argument containing comma-separated Python extras such as `bedrock`. |
| `NANOBOT_CHANNELS` | `whatsapp` | Docker build argument containing comma-separated channels whose manifest dependencies are preinstalled. |
| `NANOBOT_API_URL` | `http://127.0.0.1:8765` | Gateway target for the Vite WebUI dev server proxy. |
Internal variables such as `NANOBOT_RESTART_*` and `NANOBOT_PATH_*` are set by nanobot itself and are not a supported user configuration surface.
## Langfuse Observability ## Langfuse Observability
nanobot can trace OpenAI-compatible provider calls through Langfuse's OpenAI SDK wrapper. This is configured with environment variables, not `config.json`. nanobot can trace OpenAI-compatible provider calls through Langfuse's OpenAI SDK wrapper. This is configured with environment variables, not `config.json`.
@ -166,7 +219,7 @@ nanobot can trace OpenAI-compatible provider calls through Langfuse's OpenAI SDK
Install the optional package in the same Python environment that runs nanobot: Install the optional package in the same Python environment that runs nanobot:
```bash ```bash
python -m pip install langfuse nanobot plugins enable langfuse
``` ```
Set Langfuse credentials before starting `nanobot agent`, `nanobot gateway`, or `nanobot serve`: Set Langfuse credentials before starting `nanobot agent`, `nanobot gateway`, or `nanobot serve`:
@ -198,19 +251,27 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
> - **MiniMax Coding Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.minimax.io/subscribe/coding-plan?code=9txpdXw04g&source=link) · [Mainland China](https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link) > - **MiniMax Coding Plan**: Exclusive discount links for the nanobot community: [Overseas](https://platform.minimax.io/subscribe/coding-plan?code=9txpdXw04g&source=link) · [Mainland China](https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link)
> - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config. > - **MiniMax (Mainland China)**: If your API key is from MiniMax's mainland China platform (minimaxi.com), set `"apiBase": "https://api.minimaxi.com/v1"` in your minimax provider config.
> - **MiniMax thinking mode**: `providers.minimaxAnthropic` is the config block for `reasoningEffort` / thinking mode. MiniMax exposes that capability through its Anthropic-compatible endpoint, so nanobot keeps it as a separate provider instead of guessing MiniMax-specific thinking parameters on the generic OpenAI-compatible `minimax` endpoint. It uses the same `MINIMAX_API_KEY`. Default Anthropic-compatible base URL: `https://api.minimax.io/anthropic`; for mainland China use `https://api.minimaxi.com/anthropic`. > - **MiniMax thinking mode**: `providers.minimaxAnthropic` is the config block for `reasoningEffort` / thinking mode. MiniMax exposes that capability through its Anthropic-compatible endpoint, so nanobot keeps it as a separate provider instead of guessing MiniMax-specific thinking parameters on the generic OpenAI-compatible `minimax` endpoint. It uses the same `MINIMAX_API_KEY`. Default Anthropic-compatible base URL: `https://api.minimax.io/anthropic`; for mainland China use `https://api.minimaxi.com/anthropic`.
> - **Kimi Coding Plan**: Use `providers.kimiCoding` with `provider: "kimi_coding"` for Kimi's dedicated Anthropic Messages API endpoint. The endpoint requires a Claude-compatible `User-Agent`; nanobot sends `claude-code/0.1.0` by default, and you can override it with `extraHeaders.User-Agent` if your account requires a different value.
> - **VolcEngine / BytePlus Coding Plan**: Subscription endpoints are configured through dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan`, separate from the pay-per-use `volcengine` / `byteplus` providers. > - **VolcEngine / BytePlus Coding Plan**: Subscription endpoints are configured through dedicated providers `volcengineCodingPlan` or `byteplusCodingPlan`, separate from the pay-per-use `volcengine` / `byteplus` providers.
> - **OpenCode Zen / Go**: `providers.opencode` (canonical Zen), the legacy-compatible `providers.opencodeZen`, and `providers.opencodeGo` use the same `OPENCODE_API_KEY`, but route to different OpenCode gateways. These providers use OpenCode's OpenAI-compatible `chat/completions` endpoints; choose model IDs from that endpoint family.
> - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config. > - **Zhipu Coding Plan**: If you're on Zhipu's coding plan, set `"apiBase": "https://open.bigmodel.cn/api/coding/paas/v4"` in your zhipu provider config.
> - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config. > - **Alibaba Cloud BaiLian**: If you're using Alibaba Cloud BaiLian's OpenAI-compatible endpoint, set `"apiBase": "https://dashscope.aliyuncs.com/compatible-mode/v1"` in your dashscope provider config.
> - **ModelScope**: If you're using ModelScope's OpenAI-compatible endpoint, set `"apiBase": "https://api-inference.modelscope.cn/v1"` in your modelscope provider config.
> - **StepFun Step Plan**: If you're on StepFun's Step Plan subscription, set `"apiBase": "https://api.stepfun.ai/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`. > - **StepFun Step Plan**: If you're on StepFun's Step Plan subscription, set `"apiBase": "https://api.stepfun.ai/step_plan/v1"` in your stepfun provider config. Supported models include `step-3.5-flash`, `step-3.5-flash-2603`, and `step-router-v1`.
> - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config. > - **Step Fun (Mainland China)**: If your API key is from Step Fun's mainland China platform (stepfun.com), set `"apiBase": "https://api.stepfun.com/v1"` in your stepfun provider config.
> - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default. > - **Xiaomi MiMo thinking mode**: MiMo models (e.g. `mimo-v2.5-pro`) default to enabled thinking. Use `agents.defaults.reasoningEffort: "none"` to disable it, or `"low"` / `"medium"` / `"high"` to keep it on. Omitting the field preserves the provider's per-model default.
> - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config. > - **Xiaomi MiMo Token Plan**: If you're on MiMo's token plan, set `"apiBase": "https://token-plan-sgp.xiaomimimo.com/v1"` in your xiaomi_mimo provider config.
> - **Custom OpenAI-compatible providers**: Besides the built-in `custom` provider, any extra key under `providers` can define its own OpenAI-compatible endpoint. For example, `providers.companyProxy.apiBase` plus `modelPresets.primary.provider: "companyProxy"` creates a separate custom provider. Set `apiBase`; set `apiKey` only when the endpoint requires it. This named-custom path uses the OpenAI-compatible request format only. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` with `provider: "anthropic"`. > - **Custom OpenAI-compatible providers**: Besides the built-in `custom` provider, any extra key under `providers` can define its own OpenAI-compatible endpoint. For example, `providers.companyProxy.apiBase` plus `modelPresets.primary.provider: "companyProxy"` creates a separate custom provider. Set `apiBase`; set `apiKey` only when the endpoint requires it. This named-custom path uses the OpenAI-compatible request format only. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` with `provider: "anthropic"`.
> - **Provider-scoped proxy**: `providers.<name>.proxy` routes only that provider through an HTTP proxy. It is supported for OpenAI-compatible providers, `openai_codex`, and `xai_grok`. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`.
| Provider | Purpose | Get API Key | | Provider | Purpose | Get API Key |
|----------|---------|-------------| |----------|---------|-------------|
| `custom` | Any OpenAI-compatible endpoint | — | | `custom` | Any OpenAI-compatible endpoint | — |
| `openrouter` | LLM gateway for hosted model families + Voice transcription (STT models) | [openrouter.ai](https://openrouter.ai) | | `openrouter` | LLM gateway for hosted model families + Voice transcription (STT models) | [openrouter.ai](https://openrouter.ai) |
| `edenai` | LLM gateway for Eden AI's OpenAI-compatible model catalog | [app.edenai.run](https://app.edenai.run/) |
| `opencode` | LLM gateway (OpenCode Zen coding-agent models) | [opencode.ai/docs/zen](https://opencode.ai/docs/zen/) |
| `opencode_zen` | LLM gateway (legacy alias for OpenCode Zen) | [opencode.ai/docs/zen](https://opencode.ai/docs/zen/) |
| `opencode_go` | LLM gateway (OpenCode Go low-cost coding models) | [opencode.ai/docs/go](https://opencode.ai/docs/go/) |
| `huggingface` | LLM (Hugging Face Inference Providers) | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) | | `huggingface` | LLM (Hugging Face Inference Providers) | [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) |
| `skywork` | LLM (Skywork / APIFree API gateway) | [apifree.ai](https://www.apifree.ai) | | `skywork` | LLM (Skywork / APIFree API gateway) | [apifree.ai](https://www.apifree.ai) |
| `volcengine` | LLM (VolcEngine, pay-per-use) | [Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [volcengine.com](https://www.volcengine.com) | | `volcengine` | LLM (VolcEngine, pay-per-use) | [Coding Plan](https://www.volcengine.com/activity/codingplan?utm_campaign=nanobot&utm_content=nanobot&utm_medium=devrel&utm_source=OWO&utm_term=nanobot) · [volcengine.com](https://www.volcengine.com) |
@ -231,7 +292,9 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) | | `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) | | `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
| `modelscope` | LLM (ModelScope/魔搭社区) + Image generation | [modelscope.cn](https://modelscope.cn) |
| `moonshot` | LLM (Moonshot/Kimi) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) | | `moonshot` | LLM (Moonshot/Kimi) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
| `kimi_coding` | LLM (Kimi Coding Plan, Anthropic Messages API) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) | | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
| `xiaomi_mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) | | `xiaomi_mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
| `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) | | `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) |
@ -244,7 +307,8 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
| `ovms` | LLM (local, OpenVINO Model Server) | [docs.openvino.ai](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) | | `ovms` | LLM (local, OpenVINO Model Server) | [docs.openvino.ai](https://docs.openvino.ai/2026/model-server/ovms_docs_llm_quickstart.html) |
| `vllm` | LLM (local, any OpenAI-compatible server) | — | | `vllm` | LLM (local, any OpenAI-compatible server) | — |
| `nvidia` | LLM (NVIDIA NIM) | [build.nvidia.com](https://build.nvidia.com/) | | `nvidia` | LLM (NVIDIA NIM) | [build.nvidia.com](https://build.nvidia.com/) |
| `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex` | | `openai_codex` | LLM (Codex, OAuth) | `nanobot provider login openai-codex --set-main` |
| `xai_grok` | LLM (Grok, OAuth) | `nanobot provider login xai-grok --set-main` |
| `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` | | `github_copilot` | LLM (GitHub Copilot, OAuth) | `nanobot provider login github-copilot` |
| `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) | | `qianfan` | LLM (Baidu Qianfan) | [cloud.baidu.com](https://cloud.baidu.com/doc/qianfan/s/Hmh4suq26) |
@ -283,8 +347,51 @@ Valid `apiType` values are exactly `auto`, `chat_completions`, and `responses`.
} }
``` ```
The WebUI's OpenAI web-search switch writes the corresponding `apiType` and `extraBody.tools`
fields. A hosted search tool replaces nanobot's same-name local `web_search` function for that
request, while other tools such as `web_fetch` remain available.
</details> </details>
<details>
<summary><b>DeepSeek native web search</b></summary>
DeepSeek V4 Flash uses DeepSeek's native Responses API. Its provider-hosted web search is
enabled by default because it does not require a separate paid add-on. Turn it off from the
WebUI provider settings, or with:
```json
{
"providers": {
"deepseek": {
"apiKey": "${DEEPSEEK_API_KEY}",
"extraBody": {
"tools": []
}
}
}
}
```
The switch applies to `deepseek-v4-flash`; DeepSeek models that remain on Chat Completions
cannot use this Responses tool. Native search calls appear in the WebUI activity stream, and
their opaque output items are preserved for multi-turn Responses state replay.
</details>
<a id="responses-state-and-compaction"></a>
### Responses conversation state and compaction
Providers that use the Responses API can keep reasoning context across a
conversation, which helps with multi-step tasks. Supported providers can also
compact long conversations automatically.
nanobot preserves Responses conversation state automatically for OpenAI Responses, OpenAI Codex, Azure OpenAI, DeepSeek V4 Flash, and compatible GitHub Copilot models.
Native compaction is also automatic when the provider supports it. The
threshold is derived from the active model's context window and reserved output
headroom; no provider configuration is required.
<details> <details>
<summary><b>Azure OpenAI</b></summary> <summary><b>Azure OpenAI</b></summary>
@ -342,7 +449,7 @@ Omit `apiKey` (or leave it empty / unset). The provider falls back to [`DefaultA
Install the optional dependency: Install the optional dependency:
```bash ```bash
python -m pip install 'nanobot-ai[azure]' nanobot plugins enable azure
``` ```
`DefaultAzureCredential` walks this chain in order and uses the first identity that succeeds: `DefaultAzureCredential` walks this chain in order and uses the first identity that succeeds:
@ -357,7 +464,7 @@ python -m pip install 'nanobot-ai[azure]'
The identity that ends up signing the request **must be assigned the `Cognitive Services OpenAI User` RBAC role** (or higher) on the Azure OpenAI resource. Without that role you will see `401`/`403` errors at the first request. The identity that ends up signing the request **must be assigned the `Cognitive Services OpenAI User` RBAC role** (or higher) on the Azure OpenAI resource. Without that role you will see `401`/`403` errors at the first request.
> `apiBase` remains mandatory in both modes — it's your Azure resource endpoint and cannot be inferred. If neither `apiKey` is set nor `azure-identity` is installed, the provider raises a clear error pointing you at `python -m pip install 'nanobot-ai[azure]'`. > `apiBase` remains mandatory in both modes — it's your Azure resource endpoint and cannot be inferred. If neither `apiKey` is set nor `azure-identity` is installed, the provider raises a clear error pointing you at `nanobot plugins enable azure`.
</details> </details>
@ -401,6 +508,17 @@ Bedrock uses the native `bedrock-runtime` Converse API, so it can call Bedrock m
This provider is for Bedrock's native Converse API, not Bedrock's OpenAI-compatible `/openai/v1` endpoint. For OpenAI-compatible Bedrock models, you can still use `custom` if you specifically want that API surface. This provider is for Bedrock's native Converse API, not Bedrock's OpenAI-compatible `/openai/v1` endpoint. For OpenAI-compatible Bedrock models, you can still use `custom` if you specifically want that API surface.
Install Bedrock support first:
```bash
nanobot plugins enable bedrock
```
> [!NOTE]
> If you configured Bedrock before `boto3` became an optional dependency, run
> `nanobot plugins enable bedrock` after upgrading. Otherwise the provider will
> fail when it first tries to create a Bedrock client.
**1. Configure credentials** **1. Configure credentials**
Use the normal AWS credential chain (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, an AWS profile, or an IAM role). The IAM identity needs: Use the normal AWS credential chain (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`, an AWS profile, or an IAM role). The IAM identity needs:
@ -595,42 +713,85 @@ nanobot agent -m "Reply with one short sentence."
<details> <details>
<summary><b>OpenAI Codex (OAuth)</b></summary> <summary><b>OpenAI Codex (OAuth)</b></summary>
Codex uses OAuth instead of API keys. Requires a ChatGPT Plus or Pro account. No `providers.openaiCodex` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config. Codex uses OAuth instead of API keys and requires a ChatGPT Plus or Pro account. Authenticate it and make the current flagship model the active agent model with one command:
**1. Login:**
```bash ```bash
nanobot provider login openai-codex nanobot provider login openai-codex --set-main
``` ```
**2. Set model** (merge into `~/.nanobot/config.json`): Then run:
```bash
nanobot agent -m "Hello!"
```
Codex Fast mode can be enabled from the WebUI provider settings, or with:
```json ```json
{ {
"modelPresets": { "providers": {
"codex": { "openaiCodex": {
"provider": "openai_codex", "extraBody": {
"model": "openai-codex/gpt-5.1-codex" "service_tier": "priority"
} }
},
"agents": {
"defaults": {
"modelPreset": "codex"
} }
} }
} }
``` ```
**3. Chat:** The switch sends the Responses API `service_tier: "priority"` value. It only works for models
and accounts that support Fast mode; turn the switch off to return to standard processing.
Fast mode consumes Codex credits at a higher rate. See the
[OpenAI Codex rate card](https://help.openai.com/en/articles/20001106) for current details.
For proxy, remote/headless login, model-name, or config-key errors, see [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems).
</details>
<details>
<summary><b>xAI Grok (OAuth)</b></summary>
Use an eligible X Premium / Grok subscription without putting an API key in
`config.json`:
```bash ```bash
nanobot agent -m "Hello!" nanobot provider login xai-grok --set-main
nanobot agent -m "Hello from Grok."
# Target a specific workspace/config locally
nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello!"
# One-off workspace override on top of that config
nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -m "Hello!"
``` ```
> Docker users: use `docker run -it` for interactive OAuth login. The default model is `xai-grok/grok-4.5` with a 500,000-token context window.
The provider reads xAI's model catalog and includes the server-hosted `x_search`
tool only when the selected model advertises `supportsBackendSearch`. Models
without that capability continue normally without hosted X Search. When enabled,
searches run inside xAI's Responses API and citations arrive as inline links.
Hosted X Search is on by default to preserve this behavior. It can be turned off in the
WebUI provider settings or with `providers.xaiGrok.extraBody.tools: []`.
This is xAI subscription OAuth, not X Developer OAuth. nanobot follows the
public OAuth client and proxy contract used by
[Grok Build](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/02-authentication.md).
The browser flow uses a random loopback callback and PKCE. The resulting token
is stored in the active instance's `auth/xai.json` (normally
`~/.nanobot/auth/xai.json`), separately from Grok Build so rotating refresh
tokens cannot invalidate one another.
To use a provider-specific proxy, merge this into `config.json` before login:
```json
{
"providers": {
"xaiGrok": {
"proxy": "http://127.0.0.1:7890"
}
}
}
```
The proxy applies to OAuth discovery, token exchange/refresh, model-catalog
lookups, and subscription model requests. Because this integration depends on
xAI's public Grok Build client contract, an upstream contract change may require
a nanobot update.
</details> </details>
@ -638,7 +799,17 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -
<details> <details>
<summary><b>GitHub Copilot (OAuth)</b></summary> <summary><b>GitHub Copilot (OAuth)</b></summary>
GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.githubCopilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config. GitHub Copilot uses OAuth instead of API keys. Requires a [GitHub account with a plan](https://github.com/features/copilot/plans) configured. No `providers.github_copilot` block is needed in `config.json`; `nanobot provider login` stores the OAuth session outside config.
For GitHub Enterprise / Copilot for Business, set the endpoint overrides you need before login:
```bash
export NANOBOT_GITHUB_COPILOT_CLIENT_ID="your-enterprise-client-id"
export NANOBOT_GITHUB_DEVICE_CODE_URL="https://ghe.example/login/device/code"
export NANOBOT_GITHUB_ACCESS_TOKEN_URL="https://ghe.example/login/oauth/access_token"
export NANOBOT_GITHUB_USER_URL="https://api.ghe.example/user"
export NANOBOT_COPILOT_TOKEN_URL="https://api.ghe.example/copilot_internal/v2/token"
export NANOBOT_COPILOT_BASE_URL="https://copilot-api.ghe.example"
```
**1. Login:** **1. Login:**
```bash ```bash
@ -677,6 +848,75 @@ nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test -
</details> </details>
<details>
<summary><b>OpenCode Zen / Go</b></summary>
OpenCode Zen and OpenCode Go are available through nanobot's built-in
OpenAI-compatible provider flow. They share the `OPENCODE_API_KEY` environment
variable, but use separate provider keys and default base URLs:
| Provider | Default API base | Model prefix accepted by nanobot |
|----------|------------------|-----------------------------------|
| `opencode` | `https://opencode.ai/zen/v1` | `opencode/<model-id>` |
| `opencode_zen` | `https://opencode.ai/zen/v1` | `opencode/<model-id>` |
| `opencode_go` | `https://opencode.ai/zen/go/v1` | `opencode-go/<model-id>` |
OpenCode Zen:
```json
{
"providers": {
"opencode": {
"apiKey": "${OPENCODE_API_KEY}"
}
},
"modelPresets": {
"opencodeZen": {
"provider": "opencode",
"model": "opencode/deepseek-v4-pro"
}
},
"agents": {
"defaults": {
"modelPreset": "opencodeZen"
}
}
}
```
`providers.opencodeZen` / `provider: "opencode_zen"` still work as compatibility aliases for existing configs.
OpenCode Go:
```json
{
"providers": {
"opencodeGo": {
"apiKey": "${OPENCODE_API_KEY}"
}
},
"modelPresets": {
"opencodeGo": {
"provider": "opencode_go",
"model": "opencode-go/deepseek-v4-flash"
}
},
"agents": {
"defaults": {
"modelPreset": "opencodeGo"
}
}
}
```
OpenCode's own docs list models across `responses`, `messages`,
provider-specific model endpoints, and `chat/completions`. nanobot's OpenCode
providers use the OpenAI-compatible `chat/completions` path, so pick model IDs
from that endpoint family. The `opencode/...` and `opencode-go/...` prefixes are
accepted for config readability and stripped before sending the request.
</details>
<details> <details>
<summary><b>LongCat (OpenAI-compatible)</b></summary> <summary><b>LongCat (OpenAI-compatible)</b></summary>
@ -882,6 +1122,29 @@ Some OpenAI-compatible gateways expose request-body extensions such as vLLM guid
} }
``` ```
If a custom OpenAI-compatible endpoint exposes a provider-specific thinking toggle, set `thinkingStyle` so nanobot can translate `reasoningEffort` into the right request body. Supported styles are `thinking_type` (`{"thinking":{"type":"enabled"}}`), `enable_thinking` (`{"enable_thinking": true}`), and `reasoning_split` (`{"reasoning_split": true}`):
```json
{
"providers": {
"companyProxy": {
"apiKey": "${COMPANY_PROXY_API_KEY}",
"apiBase": "https://api.your-provider.com/v1",
"thinkingStyle": "enable_thinking"
}
},
"modelPresets": {
"company": {
"provider": "companyProxy",
"model": "served-model-name",
"reasoningEffort": "high"
}
}
}
```
Leave `thinkingStyle` unset unless the endpoint explicitly documents one of those wire formats. `extraBody` is still applied last, so advanced users can override the generated value.
</details> </details>
<a id="local-providers"></a> <a id="local-providers"></a>
@ -1129,7 +1392,7 @@ Contributor notes for adding new providers live in [`development.md`](./developm
## Model Presets ## Model Presets
Model presets let you name a complete model configuration and switch it at runtime with `/model <preset>`. They are the recommended way to configure models because the same names can be reused for startup selection, chat-command switching, and fallback chains. Model presets let you name a complete model configuration and select one per session with `/model <preset>`. They are the recommended way to configure models because the same names can be reused for new-session defaults, chat-command switching, and fallback chains.
Existing configs do not need to change. Direct `agents.defaults.model`, `provider`, `maxTokens`, `contextWindowTokens`, `temperature`, and `reasoningEffort` fields still define the implicit `default` preset. For new configs, prefer top-level `modelPresets` plus `agents.defaults.modelPreset`. Existing configs do not need to change. Direct `agents.defaults.model`, `provider`, `maxTokens`, `contextWindowTokens`, `temperature`, and `reasoningEffort` fields still define the implicit `default` preset. For new configs, prefer top-level `modelPresets` plus `agents.defaults.modelPreset`.
@ -1193,7 +1456,7 @@ Existing configs do not need to change. Direct `agents.defaults.model`, `provide
`default` is reserved and always means the implicit preset built from direct `agents.defaults.*` fields; do not define `modelPresets.default`. Use `/model default` to switch back to those direct fields in an existing config. `default` is reserved and always means the implicit preset built from direct `agents.defaults.*` fields; do not define `modelPresets.default`. Use `/model default` to switch back to those direct fields in an existing config.
Set `agents.defaults.modelPreset` to choose the startup preset. When `modelPreset` is `null` or omitted, startup uses the implicit `default` preset from direct `agents.defaults.*` fields. Runtime changes made with `/model <preset>` are not written back to `config.json`; they affect future turns until the process restarts or another model/config change replaces them. Set `agents.defaults.modelPreset` to choose the preset followed by sessions that have no saved model selection. When `modelPreset` is `null` or omitted, such sessions follow the implicit `default` preset from direct `agents.defaults.*` fields. `/model <preset>` saves an override in the current session, so its future turns keep that preset across process restarts while other sessions remain unchanged. The command does not write the selection back to `config.json`.
### Model Fallbacks ### Model Fallbacks
@ -1271,7 +1534,7 @@ Inline fallback object:
Use inline objects only when a fallback is not worth naming as a reusable preset. `fallbackModels` belongs under `agents.defaults`, not inside individual `modelPresets` entries. Use inline objects only when a fallback is not worth naming as a reusable preset. `fallbackModels` belongs under `agents.defaults`, not inside individual `modelPresets` entries.
Failover normally runs when the primary provider returns a retryable model/provider error before any answer text has been streamed. Stream-stall timeouts are the recovery exception: if the provider already emitted partial answer text and then stalls, nanobot closes the current stream segment and retries/fails over in a new segment. Typical fallback cases include timeouts, connection errors, 5xx server errors, 429 rate limits, overloads, and quota/balance exhaustion. It does not run for malformed requests, authentication/permission errors, content filtering/refusals, or context-length/message-format errors. Failover normally runs when the primary provider returns a fallbackable model/provider error before any answer text has been streamed. Stream-stall timeouts are the recovery exception: if the provider already emitted partial answer text and then stalls, nanobot closes the current stream segment and retries/fails over in a new segment. Typical fallback cases include timeouts, connection errors, 5xx server errors, 429 rate limits, overloads, authentication/permission failures such as invalid or expired credentials, and quota/balance exhaustion. It does not run for malformed requests, content filtering/refusals, or context-length/message-format errors.
If fallback candidates use smaller `contextWindowTokens` values, nanobot builds context using the smallest window in the active chain so every candidate can receive the same prompt. If fallback candidates use smaller `contextWindowTokens` values, nanobot builds context using the smallest window in the active chain so every candidate can receive the same prompt.
@ -1340,8 +1603,7 @@ Global settings that apply to all channels. Configure under the `channels` secti
{ {
"channels": { "channels": {
"sendProgress": true, "sendProgress": true,
"sendToolHints": false, "sendToolHints": true,
"extractDocumentText": true,
"sendMaxRetries": 3, "sendMaxRetries": 3,
"telegram": { "telegram": {
"enabled": false "enabled": false
@ -1353,11 +1615,17 @@ Global settings that apply to all channels. Configure under the `channels` secti
| Setting | Default | Description | | Setting | Default | Description |
|---------|---------|-------------| |---------|---------|-------------|
| `sendProgress` | `true` | Stream agent's text progress to the channel | | `sendProgress` | `true` | Stream agent's text progress to the channel |
| `sendToolHints` | `false` | Stream tool-call hints (e.g. `read_file("…")`) | | `sendToolHints` | `true` | Stream tool-call hints (e.g. `read_file("…")`) |
| `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `<think>` tags). Reasoning flows as a dedicated stream with `_reasoning_delta` / `_reasoning_end` markers — channels override `send_reasoning_delta` / `send_reasoning_end` to render in-place updates. Even with `true`, channels without those overrides stay no-op silently. Currently surfaced on CLI and WebSocket/WebUI (italic shimmer header, auto-collapses after the stream ends); Telegram / Slack / Discord / Feishu / WeChat / Matrix keep the base no-op until their bubble UI is adapted. Independent of `sendProgress`. | | `showReasoning` | `true` | Allow channels to surface model reasoning/thinking content (DeepSeek-R1 `reasoning_content`, Anthropic `thinking_blocks`, inline `<think>` tags). Reasoning flows as a dedicated stream with `_reasoning_delta` / `_reasoning_end` markers — channels override `send_reasoning_delta` / `send_reasoning_end` to render in-place updates. Even with `true`, channels without those overrides stay no-op silently. Currently surfaced on CLI and WebSocket/WebUI (italic shimmer header, auto-collapses after the stream ends); Telegram / Slack / Discord / Feishu / WeChat / Matrix / Mattermost keep the base no-op until their bubble UI is adapted. Independent of `sendProgress`. |
| `extractDocumentText` | `true` | Extract supported document/text attachments into the model prompt. Set to `false` to keep document content out of the prompt and include attachment path references instead. |
| `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) | | `sendMaxRetries` | `3` | Max delivery attempts per outbound message, including the initial send (0-10 configured, minimum 1 actual attempt) |
Non-image attachments are included in the user message as local path references, without
injecting their contents into the model prompt. When file tools are enabled, the agent
can inspect supported text, PDF, DOCX, XLSX, and PPTX files on demand with `read_file`,
or pass the original path to another tool when exact file bytes are required. The deprecated
`channels.extractDocumentText` setting is accepted for compatibility but ignored.
Normal tool workspace and media access rules still apply to attachment paths.
`channels.transcriptionProvider` and `channels.transcriptionLanguage` are deprecated compatibility fields. They remain as a read-only fallback for older configs, but new configuration should use top-level `transcription.provider` and `transcription.language`. `channels.transcriptionProvider` and `channels.transcriptionLanguage` are deprecated compatibility fields. They remain as a read-only fallback for older configs, but new configuration should use top-level `transcription.provider` and `transcription.language`.
`sendProgress` and `sendToolHints` can also be overridden per channel. The global values stay as defaults for channels that do not set their own value: `sendProgress` and `sendToolHints` can also be overridden per channel. The global values stay as defaults for channels that do not set their own value:
@ -1366,10 +1634,11 @@ Global settings that apply to all channels. Configure under the `channels` secti
{ {
"channels": { "channels": {
"sendProgress": true, "sendProgress": true,
"sendToolHints": false, "sendToolHints": true,
"telegram": { "telegram": {
"enabled": true, "enabled": true,
"sendProgress": false "sendProgress": false,
"sendToolHints": false
}, },
"websocket": { "websocket": {
"enabled": true, "enabled": true,
@ -1379,6 +1648,8 @@ Global settings that apply to all channels. Configure under the `channels` secti
} }
``` ```
Telegram `richMessages` defaults to `false`. Enable it only to opt in to Bot API 10.1 `sendRichMessage` rendering; leave it disabled for Telegram Web clients that show unsupported-message errors for rich messages.
### Retry Behavior ### Retry Behavior
Retry is intentionally simple. Retry is intentionally simple.
@ -1427,18 +1698,21 @@ nanobot uses a shared SSRF guard for built-in web fetches and HTTP/SSE MCP conne
Keep whitelist entries as narrow as possible, such as a single host CIDR (`192.168.1.50/32`). The whitelist is global for the shared SSRF guard; it is not limited to one tool or one MCP server. Keep whitelist entries as narrow as possible, such as a single host CIDR (`192.168.1.50/32`). The whitelist is global for the shared SSRF guard; it is not limited to one tool or one MCP server.
HTTP/SSE MCP connections use the same process-wide proxy environment behavior as `web_fetch`: proxied targets use the configured proxy, and URLs excluded by `NO_PROXY` remain DNS-pinned direct connections.
> [!TIP] > [!TIP]
> Use `proxy` in `tools.web` to route all web requests (search + fetch) through a proxy: > Use `proxy` in `tools.web` to route web requests through a proxy:
> ```json > ```json
> { "tools": { "web": { "proxy": "http://127.0.0.1:7890" } } } > { "tools": { "web": { "proxy": "http://127.0.0.1:7890" } } }
> ``` > ```
> `web_fetch` applies DNS pinning for direct connections. When an explicit `tools.web.proxy` or a process-wide proxy environment variable applies to the target URL, nanobot still validates the requested URL locally, but DNS resolution for the outbound fetch happens at the proxy; configure only trusted proxies. URLs excluded by `NO_PROXY` keep the DNS-pinned direct path unless `tools.web.proxy` is configured.
### `tools.web` ### `tools.web`
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `enable` | boolean | `true` | Enable or disable all built-in web tools (`web_search` + `web_fetch`) | | `enable` | boolean | `true` | Enable or disable all built-in web tools (`web_search` + `web_fetch`) |
| `proxy` | string or null | `null` | Proxy for all web requests, for example `http://127.0.0.1:7890` | | `proxy` | string or null | `null` | Proxy for web requests, for example `http://127.0.0.1:7890`. `web_fetch` DNS pinning applies only to direct connections; proxied fetches rely on the configured proxy as the trusted network exit. |
| `userAgent` | string or null | `null` | User-Agent header for all web requests. If null, a browser one will be used | | `userAgent` | string or null | `null` | User-Agent header for all web requests. If null, a browser one will be used |
### Web Search ### Web Search
@ -1581,6 +1855,22 @@ You can also set `WEB_SEARCH_API_KEY` for compatibility with the Volcengine web-
Keenable search works out of the box with no account, via its token-less public endpoint (free tier, limited to 1,000 requests/hour). Set `apiKey` (or `KEENABLE_API_KEY`) from [keenable.ai](https://keenable.ai) to remove the hourly limit. Keenable search works out of the box with no account, via its token-less public endpoint (free tier, limited to 1,000 requests/hour). Set `apiKey` (or `KEENABLE_API_KEY`) from [keenable.ai](https://keenable.ai) to remove the hourly limit.
**Serper** (Google Search API):
```json
{
"tools": {
"web": {
"search": {
"provider": "serper",
"apiKey": "${SERPER_API_KEY}"
}
}
}
}
```
Create a key at [serper.dev](https://serper.dev). You can also set `SERPER_API_KEY` in the environment instead of storing it in config.
**SearXNG** (self-hosted, no API key needed): **SearXNG** (self-hosted, no API key needed):
```json ```json
{ {
@ -1612,7 +1902,7 @@ Keenable search works out of the box with no account, via its token-less public
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `provider` | string | `"duckduckgo"` | Search backend: `brave`, `tavily`, `jina`, `kagi`, `olostep`, `bocha`, `volcengine`, `keenable`, `searxng`, `duckduckgo` | | `provider` | string | `"duckduckgo"` | Search backend: `brave`, `tavily`, `jina`, `kagi`, `olostep`, `bocha`, `volcengine`, `keenable`, `serper`, `searxng`, `duckduckgo` |
| `apiKey` | string | `""` | API key for API-backed search providers | | `apiKey` | string | `""` | API key for API-backed search providers |
| `baseUrl` | string | `""` | Base URL for SearXNG | | `baseUrl` | string | `""` | Base URL for SearXNG |
| `maxResults` | integer | `5` | Results per search (110) | | `maxResults` | integer | `5` | Results per search (110) |
@ -1724,9 +2014,9 @@ Use `enabledTools` to register only a subset of tools from an MCP server:
`enabledTools` accepts either the raw MCP tool name (for example `read_file`) or the wrapped nanobot tool name (for example `mcp_filesystem_write_file`). `enabledTools` accepts either the raw MCP tool name (for example `read_file`) or the wrapped nanobot tool name (for example `mcp_filesystem_write_file`).
- Omit `enabledTools`, or set it to `["*"]`, to register all tools. - Omit `enabledTools`, or set it to `["*"]`, to register all capabilities (tools, resources, and prompts).
- Set `enabledTools` to `[]` to register no tools from that server. - Set `enabledTools` to `[]` to register no tools from that server. Resources and prompts are also skipped, since they have no per-name filter.
- Set `enabledTools` to a non-empty list of names to register only that subset. - Set `enabledTools` to a non-empty list of names to register only those tools — resources and prompts are not registered.
MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed. MCP tools are automatically discovered and registered on startup. The LLM can use them alongside built-in tools — no extra configuration needed.
@ -1740,6 +2030,16 @@ MCP tools are automatically discovered and registered on startup. The LLM can us
For API keys, tokens, and other secrets, see [Environment Variables for Secrets](#environment-variables-for-secrets) — avoid storing them directly in `config.json`. For API keys, tokens, and other secrets, see [Environment Variables for Secrets](#environment-variables-for-secrets) — avoid storing them directly in `config.json`.
> [!NOTE]
> When a restricted WebUI chat selects a project outside the configured agent
> workspace, that project becomes the normal file and shell boundary. Nanobot
> adds capability-specific, read-only access for built-in skills, the agent
> workspace's `skills/` directory, and the exact agent
> `memory/history.jsonl` file. Neighboring memory/profile files and all
> cross-workspace writes remain denied. Agent-owned `SOUL.md` and `USER.md` are
> assembled into model context directly; this does not grant file tools broader
> access to the agent workspace.
| Option | Default | Description | | Option | Default | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `tools.restrictToWorkspace` | `false` | When `true`, enables nanobot's application-level workspace guards for workspace-aware tools. File tools resolve paths under the active workspace; selected internal roots can be added as read-only or explicitly write-enabled roots, and media uploads are read-only by default. Shell execution rejects workspace-external `working_dir` values and applies best-effort command path checks, but this is not an OS sandbox. | | `tools.restrictToWorkspace` | `false` | When `true`, enables nanobot's application-level workspace guards for workspace-aware tools. File tools resolve paths under the active workspace; selected internal roots can be added as read-only or explicitly write-enabled roots, and media uploads are read-only by default. Shell execution rejects workspace-external `working_dir` values and applies best-effort command path checks, but this is not an OS sandbox. |
@ -1748,10 +2048,13 @@ For API keys, tokens, and other secrets, see [Environment Variables for Secrets]
| `tools.exec.timeout` | `60` | Default hard timeout in seconds for shell commands. Config values may exceed the per-call tool cap; set `0` to disable the hard timeout for trusted long-running commands. | | `tools.exec.timeout` | `60` | Default hard timeout in seconds for shell commands. Config values may exceed the per-call tool cap; set `0` to disable the hard timeout for trusted long-running commands. |
| `tools.exec.pathPrepend` | `""` | Extra directories to prepend to `PATH` when running shell commands. Use this when configured tools should win executable lookup precedence, such as a Python virtual environment's `bin` or `Scripts` directory. | | `tools.exec.pathPrepend` | `""` | Extra directories to prepend to `PATH` when running shell commands. Use this when configured tools should win executable lookup precedence, such as a Python virtual environment's `bin` or `Scripts` directory. |
| `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). | | `tools.exec.pathAppend` | `""` | Extra directories to append to `PATH` when running shell commands (e.g. `/usr/sbin` for `ufw`). |
| `tools.exec.sandboxRoBinds` | `[]` | Extra absolute paths to read-only bind into the `"bwrap"` sandbox with `--ro-bind-try`, such as `/home/user/.local/bin` or `/home/user/.cargo/bin` when those paths are also in `pathPrepend`/`pathAppend`. These roots are also accepted by the shell absolute-path guard only while bwrap is active. Bind only directories whose contents are safe for agent commands to read; paths equal to or containing the active workspace are ignored so they cannot uncover its masked parent directory. |
| `tools.exec.sandboxRwBinds` | `[]` | Extra absolute paths to read-write bind into the `"bwrap"` sandbox with `--bind-try`, for trusted tool caches or scratch directories. Use sparingly: paths listed here are intentionally writable by shell commands inside the sandbox. Paths equal to or containing the active workspace are ignored. |
| `tools.webuiAllowRemotePackageInstall` | `false` | When `false`, the WebUI can install missing optional packages only from a browser opened on the same machine as nanobot. Set to `true` only when a trusted remote admin is allowed to install Python packages into this nanobot environment. |
| `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. | | `tools.ssrfWhitelist` | `[]` | CIDR ranges exempted from the shared SSRF guard used by web fetches and HTTP/SSE MCP connections. Prefer exact host CIDRs such as `192.168.1.50/32`; broad ranges increase SSRF exposure. |
| `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. | | `channels.*.allowFrom` | omitted | Access control per channel. Omit to use pairing-only mode; set `["*"]` to allow everyone; or list specific user IDs. See [Pairing](#pairing) for details. |
**Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. When using `docker-compose.yml`, the container drops all Linux capabilities except `SYS_ADMIN` (required for bwrap's namespace isolation). **Docker security**: The official Docker image runs as a non-root user (`nanobot`, UID 1000) with bubblewrap pre-installed. The default `docker-compose.yml` drops all Linux capabilities and keeps Docker's default AppArmor/seccomp profiles enabled. If you enable `"tools.exec.sandbox": "bwrap"` inside Docker, start Compose with `docker-compose.bwrap.yml` as an additional override so bubblewrap can create nested namespaces.
## Pairing ## Pairing
@ -1760,7 +2063,7 @@ Pairing lets users get access to the bot through a simple code exchange — no c
### How it works ### How it works
1. A user sends a DM to the bot on any channel (Telegram, Discord, Slack, etc.) where they aren't yet approved. 1. A user sends a DM to the bot on a pairing-capable channel where they aren't yet approved. This includes Telegram, Discord, WeChat, and channels such as Slack or Mattermost when their DM policy is set to `allowlist`.
2. The bot replies with a pairing code (like `ABCD-EFGH`) and tells them to forward it to you. 2. The bot replies with a pairing code (like `ABCD-EFGH`) and tells them to forward it to you.
3. You approve the code: 3. You approve the code:
@ -1774,7 +2077,7 @@ Pairing only works in **DMs** — unapproved users in group chats are silently i
### Pairing-only mode ### Pairing-only mode
By default, if you don't set `allowFrom`, anyone who isn't approved yet will get a pairing code when they DM the bot. This means you can skip `allowFrom` entirely and manage all access through pairing: By default, if you don't set `allowFrom`, pairing-capable channels can issue a pairing code when an unapproved user DMs the bot. This means you can skip `allowFrom` entirely and manage access through pairing:
```json ```json
{ {
@ -1786,6 +2089,21 @@ By default, if you don't set `allowFrom`, anyone who isn't approved yet will get
} }
``` ```
Slack and Mattermost DMs are open by default. To use pairing there, set the
channel's `dm.policy` to `"allowlist"` and leave `dm.allowFrom` empty until you
approve users:
```json
{
"channels": {
"slack": {
"enabled": true,
"dm": { "policy": "allowlist" }
}
}
}
```
If you prefer to allow everyone without approval: If you prefer to allow everyone without approval:
```json ```json
@ -1835,7 +2153,9 @@ The gateway can run a protected heartbeat cron job that periodically checks `HEA
} }
``` ```
If `HEARTBEAT.md` has tasks under `## Active Tasks`, the agent executes them and delivers useful results to the most recently active chat target. If the file has no active tasks, the heartbeat is skipped silently. If `HEARTBEAT.md` has tasks under `## Active Tasks`, the agent executes them and sends only useful/actionable results to the most recently active chat target. If the file has no active tasks, or the result is routine with nothing useful to report, the heartbeat is skipped silently.
This is intentionally different from user-created cron jobs. A cron job created with the `cron` tool runs as a scheduled turn in its origin chat/session and normally delivers the result back to that channel. Use `HEARTBEAT.md` for recurring background checks that should not notify the user on every run.
The heartbeat job is backed by the same cron service as user-created reminders. It is stored under the active workspace (`<workspace>/cron/jobs.json`) and shows up in `cron(action="list")` as `heartbeat`, but it is system-managed and cannot be removed with the `cron` tool. Disable it through config and restart the gateway if you do not want periodic heartbeat checks. The heartbeat job is backed by the same cron service as user-created reminders. It is stored under the active workspace (`<workspace>/cron/jobs.json`) and shows up in `cron(action="list")` as `heartbeat`, but it is system-managed and cannot be removed with the `cron` tool. Disable it through config and restart the gateway if you do not want periodic heartbeat checks.
@ -1844,6 +2164,11 @@ The heartbeat job is backed by the same cron service as user-created reminders.
| `gateway.heartbeat.enabled` | `true` | Register the built-in heartbeat cron job on gateway startup. | | `gateway.heartbeat.enabled` | `true` | Register the built-in heartbeat cron job on gateway startup. |
| `gateway.heartbeat.intervalS` | `1800` | Seconds between heartbeat checks. | | `gateway.heartbeat.intervalS` | `1800` | Seconds between heartbeat checks. |
| `gateway.heartbeat.keepRecentMessages` | `8` | Number of recent heartbeat-session messages to retain after each run. | | `gateway.heartbeat.keepRecentMessages` | `8` | Number of recent heartbeat-session messages to retain after each run. |
| `gateway.restartMode` | `auto` | Restart strategy for `/restart`: `auto` uses `spawn` on Windows foreground runs and `exec` elsewhere. Use `exit` with Windows service wrappers such as WinSW or nssm so the service manager owns the restart. |
### Custom heartbeat evaluator prompt
The notification gate runs on a built-in system prompt. Advanced users can override it, but you rarely need to — it's strongly advised to first read the evaluator code and the default `evaluator.md`. To override, drop your prompt at `<workspace>/prompts/evaluator.md`. It must still instruct the model to call the `evaluate_notification` tool; otherwise the gate fails closed and stays silent.
## Subagent Concurrency ## Subagent Concurrency
@ -1860,9 +2185,22 @@ By default, nanobot only allows one spawned subagent at a time. When the limit i
} }
``` ```
Subagents also stop immediately when one of their tools returns an execution error. That default keeps failures visible to the parent agent. If your subagent workflows use tools that can fail transiently and should be retried or worked around by the model, disable hard-stop behavior:
```json
{
"agents": {
"defaults": {
"failOnToolError": false
}
}
}
```
| Option | Default | Description | | Option | Default | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `agents.defaults.maxConcurrentSubagents` | `1` | Maximum number of spawned subagents that may run at the same time. Attempts to spawn beyond this limit return an error. | | `agents.defaults.maxConcurrentSubagents` | `1` | Maximum number of spawned subagents that may run at the same time. Attempts to spawn beyond this limit return an error. |
| `agents.defaults.failOnToolError` | `true` | Stop a spawned subagent when a tool execution fails. Set to `false` to return tool errors to the subagent model so it can recover within the same run. |
## Auto Compact ## Auto Compact
@ -1873,7 +2211,8 @@ When a user is idle for longer than a configured threshold, nanobot **proactivel
{ {
"agents": { "agents": {
"defaults": { "defaults": {
"idleCompactAfterMinutes": 15 "idleCompactAfterMinutes": 15,
"idleCompactCheckIntervalSeconds": 60
} }
} }
} }
@ -1882,11 +2221,12 @@ When a user is idle for longer than a configured threshold, nanobot **proactivel
| Option | Default | Description | | Option | Default | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `agents.defaults.idleCompactAfterMinutes` | `15` | Minutes of idle time before auto-compaction starts. Set to `0` to disable. The default is close to a typical LLM KV cache expiry window, so stale sessions get compacted before the user returns. | | `agents.defaults.idleCompactAfterMinutes` | `15` | Minutes of idle time before auto-compaction starts. Set to `0` to disable. The default is close to a typical LLM KV cache expiry window, so stale sessions get compacted before the user returns. |
| `agents.defaults.idleCompactCheckIntervalSeconds` | `60` | Minimum number of seconds between scans for idle sessions. Set to `0` to scan on every idle tick (~1 s). |
`sessionTtlMinutes` remains accepted as a legacy alias for backward compatibility, but `idleCompactAfterMinutes` is the preferred config key going forward. `sessionTtlMinutes` remains accepted as a legacy alias for backward compatibility, but `idleCompactAfterMinutes` is the preferred config key going forward.
How it works: How it works:
1. **Idle detection**: On each idle tick (~1 s), checks all sessions for expiration. 1. **Idle detection**: On each idle tick (~1 s), checks whether an idle-session scan is due. By default, the full scan runs at most once per minute.
2. **Background compaction**: Idle sessions summarize the older live prefix via LLM and keep the most recent legal suffix (currently 8 messages). 2. **Background compaction**: Idle sessions summarize the older live prefix via LLM and keep the most recent legal suffix (currently 8 messages).
3. **Summary injection**: When the user returns, the summary is injected as runtime context (one-shot, not persisted) alongside the retained recent suffix. 3. **Summary injection**: When the user returns, the summary is injected as runtime context (one-shot, not persisted) alongside the retained recent suffix.
4. **Restart-safe resume**: The summary is also mirrored into session metadata so it can still be recovered after a process restart. 4. **Restart-safe resume**: The summary is also mirrored into session metadata so it can still be recovered after a process restart.

View File

@ -4,7 +4,7 @@ Use this page after `nanobot agent -m "Hello!"` works locally. Deployment keeps
## Before You Deploy ## Before You Deploy
Check these once before Docker, systemd, or LaunchAgent: Check these once before Render, Docker, systemd, or LaunchAgent:
| Check | Why it matters | | Check | Why it matters |
|---|---| |---|---|
@ -13,7 +13,7 @@ Check these once before Docker, systemd, or LaunchAgent:
| Secrets are in environment variables or protected config files | API keys, bot tokens, OAuth state, and chat credentials should not be world-readable | | Secrets are in environment variables or protected config files | API keys, bot tokens, OAuth state, and chat credentials should not be world-readable |
| `~/.nanobot/` or your custom config/workspace path is persistent | Sessions, memory, channel login state, generated artifacts, and cron jobs live there | | `~/.nanobot/` or your custom config/workspace path is persistent | Sessions, memory, channel login state, generated artifacts, and cron jobs live there |
| Channel access control is intentional | Use `allowFrom`, pairing, WebSocket `token`/`tokenIssueSecret`, or private test channels before exposing the bot | | Channel access control is intentional | Use `allowFrom`, pairing, WebSocket `token`/`tokenIssueSecret`, or private test channels before exposing the bot |
| Ports are planned | Gateway health defaults to `18790`; WebUI/WebSocket defaults to `8765`; `nanobot serve` defaults to `8900` | | Ports are planned | Gateway health defaults to local-only `127.0.0.1:18790`; WebUI/WebSocket defaults to `8765`; `nanobot serve` defaults to `8900` |
| Logs are easy to reach | Use `docker compose logs`, `journalctl`, LaunchAgent log files, or `nanobot gateway --verbose` while diagnosing startup | | Logs are easy to reach | Use `docker compose logs`, `journalctl`, LaunchAgent log files, or `nanobot gateway --verbose` while diagnosing startup |
Restart the deployed process after editing `config.json`. Long-running processes read config at startup. Restart the deployed process after editing `config.json`. Long-running processes read config at startup.
@ -22,11 +22,40 @@ Restart the deployed process after editing `config.json`. Long-running processes
| Runtime | Use it for | State location | Useful first command | | Runtime | Use it for | State location | Useful first command |
|---|---|---|---| |---|---|---|---|
| Render | One-click hosted gateway and WebUI | Persistent disk at `/home/nanobot/.nanobot` | [Deploy to Render](#render) |
| Docker Compose | Repeatable container runs on Linux servers or workstations | Bind-mount `~/.nanobot` to `/home/nanobot/.nanobot` | `docker compose run --rm nanobot-cli agent -m "Hello!"` | | Docker Compose | Repeatable container runs on Linux servers or workstations | Bind-mount `~/.nanobot` to `/home/nanobot/.nanobot` | `docker compose run --rm nanobot-cli agent -m "Hello!"` |
| Docker CLI | Manual container testing or small one-off hosts | Bind-mount `~/.nanobot` to `/home/nanobot/.nanobot` | `docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot status` | | Docker CLI | Manual container testing or small one-off hosts | Bind-mount `~/.nanobot` to `/home/nanobot/.nanobot` | `docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot status` |
| systemd user service | Linux user-level gateway that restarts automatically | Host user's `~/.nanobot` unless you pass explicit paths | `systemctl --user status nanobot-gateway` | | systemd user service | Linux user-level gateway that restarts automatically | Host user's `~/.nanobot` unless you pass explicit paths | `systemctl --user status nanobot-gateway` |
| macOS LaunchAgent | macOS gateway that starts after login | Host user's `~/.nanobot` unless the plist passes explicit paths | `launchctl list | grep ai.nanobot.gateway` | | macOS LaunchAgent | macOS gateway that starts after login | Host user's `~/.nanobot` unless the plist passes explicit paths | `launchctl list | grep ai.nanobot.gateway` |
## Render
Run nanobot online without managing a server. The blueprint deploys the gateway and bundled WebUI together, with a persistent disk so sessions, memory, and chat history survive restarts.
> [!IMPORTANT]
> This setup requires a paid Render service because persistent disks are not available on the free tier. During setup, provide `ANTHROPIC_API_KEY` and set `NANOBOT_WEB_TOKEN` to a strong private password (for example, generate one with `openssl rand -hex 32`).
[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/HKUDS/nanobot)
[Review the deployment blueprint](../render.yaml)
### First Deployment
1. Click **Deploy to Render**, sign in, and review the Blueprint. It creates one Starter web service and a 1 GB persistent disk.
2. Enter your `ANTHROPIC_API_KEY`. Set `NANOBOT_WEB_TOKEN` to a new random value and save it in your password manager; this is the password for the public WebUI.
3. Create the Blueprint and wait for the service status to become **Live**. The first build can take several minutes.
4. Open the generated `onrender.com` URL. The **Authentication required** page means the gateway is running: enter the same `NANOBOT_WEB_TOKEN` value to open the WebUI.
The model API key is used by nanobot to call Anthropic. The Web token only protects access to this deployment; do not share it in issues, screenshots, or chat.
### Updates and Data
The Blueprint disables automatic deploys so upstream repository changes do not unexpectedly restart your agent. To update, open the service in the Render Dashboard and choose **Manual Deploy → Deploy latest commit**.
The persistent disk keeps `config.json`, sessions, memory, WebUI history, cron state, media, and logs across restarts and updates. The deployment initializes `config.json` only when it does not already exist, so settings changed later in the WebUI are not replaced on every boot.
If deployment fails, open the service **Logs** page first. A missing model key fails provider requests after startup, while an incorrect Web token leaves you on the authentication page.
## Docker ## Docker
> [!TIP] > [!TIP]
@ -38,14 +67,13 @@ Restart the deployed process after editing `config.json`. Long-running processes
> Official Docker usage currently means building from this repository with the included `Dockerfile`. Docker Hub images under third-party namespaces are not maintained or verified by HKUDS/nanobot; do not mount API keys or bot tokens into them unless you trust the publisher. > Official Docker usage currently means building from this repository with the included `Dockerfile`. Docker Hub images under third-party namespaces are not maintained or verified by HKUDS/nanobot; do not mount API keys or bot tokens into them unless you trust the publisher.
> [!IMPORTANT] > [!IMPORTANT]
> The gateway and WebSocket channel default to `host: "127.0.0.1"` in `config.json` (set in `nanobot/config/schema.py`). Docker `-p` port forwarding cannot reach a container's loopback interface, so for the host or LAN to reach the exposed ports you must set both binds to `0.0.0.0` in `~/.nanobot/config.json` before starting the container. To serve the bundled WebUI from Docker, enable the WebSocket channel and protect bootstrap with a secret: > The gateway and WebSocket channel default to `host: "127.0.0.1"` in `config.json` (set in `nanobot/config/schema.py`). Docker `-p` port forwarding cannot reach a container's loopback interface, so for the host or LAN to reach the exposed ports you must set both binds to `0.0.0.0` in `~/.nanobot/config.json` before starting the container. To serve the bundled WebUI from Docker, bind the WebSocket channel externally and protect bootstrap with `tokenIssueSecret`:
> >
> ```json > ```json
> { > {
> "gateway": { "host": "0.0.0.0" }, > "gateway": { "host": "0.0.0.0" },
> "channels": { > "channels": {
> "websocket": { > "websocket": {
> "enabled": true,
> "host": "0.0.0.0", > "host": "0.0.0.0",
> "port": 8765, > "port": 8765,
> "tokenIssueSecret": "your-secret-here" > "tokenIssueSecret": "your-secret-here"
@ -54,10 +82,72 @@ Restart the deployed process after editing `config.json`. Long-running processes
> } > }
> ``` > ```
> >
> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured. See [`webui.md#lan-access`](./webui.md#lan-access) for details. > When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token`, `tokenIssueSecret`, or a fully configured `trustedProxyAuth` is also configured. See [`webui.md#lan-access`](./webui.md#lan-access) for details.
> The gateway health route itself is intentionally minimal and unauthenticated. When the
> container binds it to `0.0.0.0`, publish port `18790` to host loopback only; place any
> remotely monitored health endpoint behind a firewall or reverse proxy. If another host
> must probe it directly, replace `127.0.0.1` in the port mapping with a trusted host
> interface and restrict inbound traffic to the monitoring system.
### Cloudflare Tunnel + Cloudflare Access
For a local `cloudflared` process in front of nanobot, Cloudflare Access can
authenticate the user before forwarding the request and add
`Cf-Access-Jwt-Assertion`. Opt in to trusted-proxy no-token mode only when the
direct TCP peer is the tunnel process and the assertion is non-empty:
```json
{
"gateway": { "host": "127.0.0.1" },
"channels": {
"websocket": {
"host": "127.0.0.1",
"port": 8765,
"publicWsUrl": "wss://nanobot.example.com/",
"trustedProxyAuth": {
"trustedPeerCidrs": ["127.0.0.1/32", "::1/128"],
"assertionHeader": "Cf-Access-Jwt-Assertion"
}
}
}
}
```
This is two-part authorization: a trusted direct loopback peer **and** a
non-empty Cloudflare Access assertion. A trusted CIDR alone is not a bypass.
For this flow `/webui/bootstrap` returns connection metadata without a
bootstrap token or REST API token; the proxy assertion authorizes the WebSocket
handshake and REST requests directly.
Set `publicWsUrl` to the browser-facing `wss://` endpoint when the tunnel sends
the origin host header (such as `127.0.0.1:8765`); otherwise the WebUI could
attempt to open its WebSocket directly against the loopback address.
The assertion header must be generated
by Cloudflare Access after authentication; routing/client metadata headers such
as `Host`, `Forwarded`, `X-Forwarded-*`, `X-Real-IP`, and `CF-Connecting-IP`
are rejected as `assertionHeader` values. Nanobot trusts the assertion but does
not cryptographically validate the JWT, so configure the tunnel and Access
policy carefully and do not expose the nanobot listener directly to untrusted
clients. Forwarded client headers do not establish proxy trust.
### Docker Compose ### Docker Compose
The default image preinstalls WhatsApp dependencies. To bake other enabled
channels into an image (recommended for deployments without PyPI access), pass
a comma-separated `NANOBOT_CHANNELS` build argument:
```bash
NANOBOT_CHANNELS=telegram,slack docker compose build
```
The image keeps nanobot in a virtual environment owned by its built-in non-root
runtime user (UID 1000). If an enabled channel was not preinstalled, gateway
startup can therefore install its manifest-declared dependencies. Rebuilding
with `NANOBOT_CHANNELS` keeps that installation reproducible instead of relying
on the container's writable layer. If you override the container with a
different `--user`, bake every enabled channel into the image because that UID
is not guaranteed write access to the virtual environment.
```bash ```bash
docker compose run --rm nanobot-cli onboard # first-time setup docker compose run --rm nanobot-cli onboard # first-time setup
vim ~/.nanobot/config.json # add API keys vim ~/.nanobot/config.json # add API keys
@ -70,12 +160,32 @@ docker compose logs -f nanobot-gateway # view logs
docker compose down # stop docker compose down # stop
``` ```
The default Compose file drops all Linux capabilities and keeps Docker's default
AppArmor/seccomp profiles enabled. If you explicitly set
`"tools.exec.sandbox": "bwrap"` in `~/.nanobot/config.json`, add the bwrap
override file when starting containers:
```bash
docker compose -f docker-compose.yml -f docker-compose.bwrap.yml up -d nanobot-gateway
docker compose -f docker-compose.yml -f docker-compose.bwrap.yml run --rm nanobot-cli agent -m "Hello!"
```
The override grants `CAP_SYS_ADMIN` and disables AppArmor/seccomp confinement for
the container so bubblewrap can create its nested namespaces. Use it only when the
bwrap sandbox is enabled.
### Docker ### Docker
```bash ```bash
# Build the image # Build the image
docker build -t nanobot . docker build -t nanobot .
# Or preinstall a regular Python extra such as Bedrock support
docker build --build-arg NANOBOT_EXTRAS=bedrock -t nanobot .
# Or preinstall dependencies for a specific set of channels
docker build --build-arg NANOBOT_CHANNELS=telegram,slack -t nanobot .
# Initialize config (first time only) # Initialize config (first time only)
docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
@ -83,18 +193,23 @@ docker run -v ~/.nanobot:/home/nanobot/.nanobot --rm nanobot onboard
vim ~/.nanobot/config.json vim ~/.nanobot/config.json
# Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat). # Run gateway (connects to enabled channels, e.g. Telegram/Discord/Mochat).
# Mirrors the security caps and port mappings declared in docker-compose.yml: # `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway
# - `--cap-drop ALL --cap-add SYS_ADMIN` + unconfined apparmor/seccomp are required # health endpoint on 18790.
# when `tools.exec.sandbox: "bwrap"` is enabled (bwrap needs CAP_SYS_ADMIN for docker run \
# user namespaces). Without them, `bwrap` exits with `clone3: Operation not permitted`. --cap-drop ALL \
# - `-p 8765:8765` exposes the WebSocket channel / WebUI alongside the gateway health -v ~/.nanobot:/home/nanobot/.nanobot \
# endpoint on 18790. -p 18790:18790 -p 8765:8765 \
nanobot gateway
# If `tools.exec.sandbox: "bwrap"` is enabled, run with the extra permissions
# bubblewrap needs for nested namespaces. Without them, `bwrap` may exit with
# `clone3: Operation not permitted`.
docker run \ docker run \
--cap-drop ALL --cap-add SYS_ADMIN \ --cap-drop ALL --cap-add SYS_ADMIN \
--security-opt apparmor=unconfined \ --security-opt apparmor=unconfined \
--security-opt seccomp=unconfined \ --security-opt seccomp=unconfined \
-v ~/.nanobot:/home/nanobot/.nanobot \ -v ~/.nanobot:/home/nanobot/.nanobot \
-p 18790:18790 -p 8765:8765 \ -p 127.0.0.1:18790:18790 -p 8765:8765 \
nanobot gateway nanobot gateway
# Or run a single command # Or run a single command

50
docs/guides/README.md Normal file
View File

@ -0,0 +1,50 @@
# nanobot Task Guides
Start with [Install and Quick Start](../quick-start.md) and get one reply before using a guide below. Each guide targets one outcome; linked reference pages hold the complete option tables and edge cases.
## Start and Use
| Goal | Guide |
|---|---|
| Build a personal AI agent | [Build a personal AI agent](./build-a-personal-ai-agent.md) |
| Use the browser workbench | [AI agent WebUI](./ai-agent-webui.md) |
| Run a self-hosted AI agent | [Self-hosted AI agent](./self-hosted-ai-agent.md) |
| Run a sustained goal | [Long-running AI agent](./long-running-ai-agent.md) |
| Add long-term memory | [AI agent memory](./ai-agent-memory.md) |
## Connect a Chat App
Use **Settings → Channels** in the WebUI for guided setup. These guides explain the account, bot, token, permission, and test-message steps on each platform.
| Goal | Guide |
|---|---|
| Connect chat apps | [Chat app AI agent](./chat-app-ai-agent.md) |
| Connect Telegram | [Telegram AI agent](./telegram-ai-agent.md) |
| Connect Discord | [Discord AI agent](./discord-ai-agent.md) |
| Connect Slack | [Slack AI agent](./slack-ai-agent.md) |
| Connect Feishu | [Feishu AI agent](./feishu-ai-agent.md) |
| Connect WhatsApp | [WhatsApp AI agent](./whatsapp-ai-agent.md) |
| Connect WeChat | [WeChat AI agent](./wechat-ai-agent.md) |
| Connect QQ | [QQ AI agent](./qq-ai-agent.md) |
| Connect Email | [Email AI agent](./email-ai-agent.md) |
| Connect Mattermost | [Mattermost AI agent](./mattermost-ai-agent.md) |
## Integrate from Code
| Goal | Guide |
|---|---|
| Run from Python | [Python AI agent SDK](./python-ai-agent-sdk.md) |
| Expose `/v1/chat/completions` | [OpenAI-compatible agent API](./openai-compatible-agent-api.md) |
## Configure and Operate
| Goal | Guide |
|---|---|
| Add MCP tools | [Configure MCP tools](./configure-mcp-tools.md) |
| Enable web search | [Configure web search](./configure-web-search.md) |
| Add model fallback | [Configure model fallback](./configure-model-fallback.md) |
| Add an OpenAI-compatible provider | [Configure an OpenAI-compatible provider](./configure-openai-compatible-provider.md) |
| Improve Ollama tool prompt-cache reuse | [Configure Ollama prompt caching](./configure-ollama-prompt-cache.md) |
| Add Langfuse tracing | [Configure Langfuse observability](./configure-langfuse-observability.md) |
| Secure local tools | [Secure a local AI agent](./secure-local-ai-agent.md) |
| Deploy the gateway | [Deploy nanobot gateway](./deploy-nanobot-gateway.md) |

View File

@ -0,0 +1,72 @@
# How AI Agent Memory Works in nanobot
This guide explains how to use nanobot's long-term AI agent memory: session
history, compressed archives, durable memory files, Dream consolidation, and
Git-backed memory changes.
## What you will build
- a workspace with persistent session history
- compressed history archives for older turns
- durable memory files such as `USER.md` and `MEMORY.md`
- a Dream workflow for curating long-term memory
## When to use this
Use memory when an agent should remember stable preferences, project facts,
decisions, and recurring context across sessions. Do not use memory as a dumping
ground for every raw transcript; nanobot separates short-term messages from
curated durable knowledge.
## Install
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
nanobot agent -m "Hello!"
```
## Minimal working example
Ask the agent to remember a stable fact in a normal session, then run Dream:
```text
/dream
```
Inspect recent memory changes:
```text
/dream-log
```
The exact files live in the active workspace, usually under
`~/.nanobot/workspace/`.
## Production notes
- Use one workspace per project or personal context.
- Keep durable facts concise; old session details belong in `history.jsonl`.
- Use `/dream-prompt init` when a workspace needs custom memory guidance.
- Review Git-backed memory changes when memory affects important workflows.
## Security notes
- Memory files may contain sensitive user or project facts.
- Avoid sharing workspaces without reviewing `SOUL.md`, `USER.md`, and
`memory/MEMORY.md`.
- Use separate workspaces for personal and team contexts.
## Troubleshooting
- If memory feels stale, run `/dream` and inspect `/dream-log`.
- If memory changed incorrectly, use `/dream-restore` to inspect and restore
previous versions.
- If a new session lacks context, confirm it uses the same workspace.
## Related nanobot docs
- [AI Agent Memory in nanobot](../memory.md)
- [Concepts](../concepts.md)
- [Configuration](../configuration.md#auto-compact)
- [Chat Commands](../chat-commands.md)

View File

@ -0,0 +1,73 @@
# How to Use an AI Agent WebUI with nanobot
nanobot includes a browser WebUI for persistent chat sessions, visible agent
activity, workspace controls, Apps, MCP presets, Skills, settings, and
Automations.
## What you will build
- a local browser workbench
- one persistent chat session
- a visible timeline of agent messages, tool calls, and file edit diffs
- a gateway-backed WebSocket connection
## When to use this
Use the WebUI when you want a local AI agent interface that is easier to operate
than a terminal, especially for project work, file attachments, model switching,
workspace selection, Apps, Skills, and scheduled automations.
## Install
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
nanobot agent -m "Hello!"
```
The published wheel already includes the WebUI bundle. You only need the
`webui/` source directory when changing the frontend.
## Minimal working example
```bash
nanobot webui
```
The launcher checks setup, enables the local WebSocket channel after
confirmation, starts the gateway, and opens the browser.
When nanobot edits a file, the WebUI activity timeline can show the changed
line counts, a unified diff, and an **Open file** action for a read-only
preview. File previews use the chat's current workspace access mode: restricted
access stays inside the selected workspace, while Full Access can preview files
outside the workspace when the gateway allows it.
## Production notes
- Use `nanobot webui --background` when you do not want to keep a terminal open.
- Use `nanobot gateway status`, `logs`, `restart`, and `stop` to manage a
background gateway.
- If you expose the WebUI beyond localhost, set a token issue secret and review
workspace/tool access.
## Security notes
- The first-run WebUI path binds to `127.0.0.1` by default.
- Do not expose the WebUI on a LAN or public host without an intentional access
model.
- Keep file and shell tools scoped to the workspace before inviting other users.
## Troubleshooting
- The WebUI is served by the WebSocket channel on port `8765` by default.
- The gateway health endpoint is separate from the browser UI.
- If the page opens but messages fail, check provider setup with
`nanobot agent -m "Hello!"`.
## Related nanobot docs
- [Nanobot WebUI](../webui.md)
- [Quick Start](../quick-start.md)
- [WebSocket protocol](../websocket.md)
- [Configuration](../configuration.md)

View File

@ -0,0 +1,83 @@
# How to Build a Personal AI Agent with nanobot
This guide builds a personal AI agent you can run locally, talk to from the
terminal or browser, and later connect to chat apps, memory, tools, and
automations.
## What you will build
- a configured nanobot install
- one working model provider
- one local agent reply
- a browser WebUI session for ongoing work
## When to use this
Use this when you want a personal AI agent that you control rather than a hosted
chat-only interface. nanobot is useful when the agent needs local workspace
access, tool calls, session history, memory, scheduled work, or chat app
delivery.
## Install
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
```
The wizard creates `~/.nanobot/config.json` and helps you choose a provider and
model. If terminals and config files are new to you, use
[Start Without Technical Background](../start-without-technical-background.md)
instead.
## Minimal working example
First prove the runtime can answer:
```bash
nanobot agent -m "Hello!"
```
Then open the browser workbench:
```bash
nanobot webui
```
The WebUI starts the local gateway, opens a browser, and keeps persistent chat
sessions for longer work.
## Production notes
- Keep one workspace per project or personal context.
- Use `modelPresets` when you want stable names for fast, deep, local, or
fallback models.
- Keep `nanobot gateway` running for WebUI, chat apps, automations, and the
WebSocket channel.
- Use the Python SDK or OpenAI-compatible API when another program should call
the agent.
## Security notes
- Do not store API keys directly in shared files; use environment variables.
- Prefer chat app pairing for first setup. Use `allowFrom` only for static
allowlists, and keep those lists narrow.
- Enable workspace restriction before exposing file or shell tools to other
users.
- Use a separate workspace for experiments that can modify files.
## Troubleshooting
- `nanobot status` shows the config path, workspace path, and active model.
- If `nanobot agent -m "Hello!"` fails, fix provider setup before opening the
WebUI or chat apps.
- If the WebUI opens but does not answer, check gateway logs and provider
credentials.
## Related nanobot docs
- [Quick Start](../quick-start.md)
- [Concepts](../concepts.md)
- [WebUI](../webui.md)
- [Configuration](../configuration.md)
- [Troubleshooting](../troubleshooting.md)

View File

@ -0,0 +1,96 @@
# How to Connect an AI Agent to Chat Apps with nanobot
nanobot can run as a self-hosted chatbot or AI agent in Telegram, Discord,
Slack, WeChat, Email, Mattermost, and other chat apps. The gateway receives chat
messages, runs the agent, and sends replies back to the same channel.
## What you will build
- a working local agent
- one enabled chat channel
- a running gateway
- a pairing-based approval flow or a narrow static allowlist
## When to use this
Use chat apps when the agent should live where users already communicate:
private DMs, team channels, group chats, email threads, or bot workspaces.
## Install
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
nanobot webui
```
Send `Hello!` in the WebUI before adding a channel. Then choose one platform guide for the bot/account prerequisites:
- [Telegram AI agent](./telegram-ai-agent.md)
- [Discord AI agent](./discord-ai-agent.md)
- [Slack AI agent](./slack-ai-agent.md)
- [Feishu AI agent](./feishu-ai-agent.md)
- [WhatsApp AI agent](./whatsapp-ai-agent.md)
- [WeChat AI agent](./wechat-ai-agent.md)
- [QQ AI agent](./qq-ai-agent.md)
- [Email AI agent](./email-ai-agent.md)
- [Mattermost AI agent](./mattermost-ai-agent.md)
## Minimal working example
Use the guided channel setup:
1. Get the platform token, login state, webhook, or mailbox credentials.
2. Open **Settings → Channels** in the WebUI.
3. Choose the platform and open its setup panel.
4. Complete the credential or QR flow and install optional support if prompted.
5. Restart when the WebUI requests it.
6. Send a private test message.
7. Approve the pairing request in the WebUI when a DM-capable channel asks for one.
If your installed release does not show **Settings → Channels**, use the full [Chat Apps reference](../chat-apps.md#manual-setup-pattern) to configure the channel manually.
Check status from the terminal when you need a lower-level confirmation:
```bash
nanobot channels status
```
The `nanobot webui` command already runs the gateway. For a chat-only or server deployment, start it directly:
```bash
nanobot gateway
```
Use the full [Chat Apps reference](../chat-apps.md) when you manage `config.json` directly or need platform-specific advanced settings.
## Production notes
- Keep the gateway running as a service for always-on chat apps.
- Use mention-only group policies before opening a bot to busy channels.
- Use one channel at a time while debugging.
- Prefer DMs for first tests; pairing only works in DMs, and group chats add
permissions and routing behavior.
## Security notes
- Prefer pairing or explicit allowlists; do not use `allowFrom: ["*"]` outside
an intentional sandbox.
- Rotate bot tokens if they are pasted into logs or shared files.
- Review file, shell, and web tool access before inviting other users.
## Troubleshooting
- If `nanobot channels status` does not show the channel, the config key or
optional dependency is likely missing.
- If the first DM returns a pairing code, approve the pending request in the WebUI or use `/pairing approve <code>` from an authorized chat.
- If messages do not arrive, run `nanobot gateway --verbose` and compare
platform credentials, event permissions, and allow lists.
- If group replies are unexpected, review that channel's group policy.
## Related nanobot docs
- [Chat Apps](../chat-apps.md)
- [Configuration](../configuration.md#channel-settings)
- [Pairing](../configuration.md#pairing)
- [Deployment](../deployment.md)

View File

@ -0,0 +1,79 @@
# How to Configure Langfuse Observability for nanobot
nanobot can trace supported OpenAI-compatible provider calls through Langfuse's
OpenAI SDK wrapper.
## What you will build
- Langfuse installed in the same Python environment as nanobot
- Langfuse environment variables set before startup
- one traced nanobot model call
## When to use this
Use Langfuse when you need observability for model requests, latency, errors,
cost, or prompt behavior during development or production operation.
## Install
Install nanobot and prove the agent works:
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
nanobot agent -m "Hello!"
```
Install Langfuse:
```bash
python -m pip install langfuse
```
## Minimal working example
Set credentials before starting nanobot:
```bash
export LANGFUSE_SECRET_KEY="sk-lf-..."
export LANGFUSE_PUBLIC_KEY="pk-lf-..."
export LANGFUSE_BASE_URL="https://cloud.langfuse.com"
nanobot agent -m "Hello!"
```
PowerShell:
```powershell
$env:LANGFUSE_SECRET_KEY = "sk-lf-..."
$env:LANGFUSE_PUBLIC_KEY = "pk-lf-..."
$env:LANGFUSE_BASE_URL = "https://cloud.langfuse.com"
nanobot agent -m "Hello!"
```
## Production notes
- Langfuse is configured with environment variables, not `config.json`.
- Start services from an environment that exports the same variables.
- Add tracing after the provider works; it should not be the first setup step.
- Native providers that do not use the OpenAI-compatible client path may not
produce Langfuse OpenAI-wrapper traces.
## Security notes
- Treat Langfuse projects as observability stores for sensitive prompts and
outputs.
- Use separate projects for personal, staging, and production traffic.
- Keep Langfuse keys out of committed service files.
## Troubleshooting
- If no traces appear, confirm the service process sees the environment
variables.
- Confirm the provider path is OpenAI-compatible.
- Run one local `nanobot agent -m "Hello!"` call before debugging service logs.
## Related nanobot docs
- [Configuration: Langfuse Observability](../configuration.md#langfuse-observability)
- [Provider Cookbook: Langfuse Tracing](../provider-cookbook.md#recipe-langfuse-tracing)
- [Deployment](../deployment.md)

View File

@ -0,0 +1,82 @@
# How to Configure MCP Tools in nanobot
This guide adds an MCP server to nanobot so the agent can use external tools
through the Model Context Protocol.
## What you will build
- a working nanobot agent
- one MCP integration configured through Apps or `~/.nanobot/config.json`
- a restricted set of MCP tools exposed to the model
## When to use this
Use MCP when the capability you need already exists as an MCP server, or when
you want external tools to be managed outside nanobot core.
## Install
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
nanobot agent -m "Hello!"
```
Install the MCP server runtime separately. Many examples use `npx`, `uvx`, or a
remote HTTP endpoint.
## Minimal working example
For local interactive setup:
1. Run `nanobot webui` and open **Apps**.
2. Choose a known integration preset, or add a custom stdio, HTTP, or SSE server.
3. Limit the enabled tools when the server exposes more than the task needs.
4. Save and restart when prompted.
5. Mention the integration with `@` in the next message and ask for a small test action.
For manual or deployment-managed config, add this to `~/.nanobot/config.json`:
```json
{
"tools": {
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
"enabledTools": ["read_file"]
}
}
}
}
```
Restart nanobot and ask a question that requires the MCP tool.
## Production notes
- Prefer `enabledTools` over exposing every tool by default.
- Use `toolTimeout` for slow MCP operations.
- Use HTTP MCP only for endpoints you trust.
- Keep MCP server commands stable and versioned in deployment docs or scripts.
## Security notes
- Stdio MCP starts a local process; review the command before enabling it.
- HTTP/SSE MCP uses nanobot's SSRF guard.
- Allow private HTTP MCP hosts only with narrow `tools.ssrfWhitelist` CIDRs.
- Do not place secrets in command arguments when environment variables or
headers can be used.
## Troubleshooting
- Run the MCP command outside nanobot first.
- Start `nanobot gateway --verbose` and inspect tool registration logs.
- If an HTTP MCP URL is blocked, check whether it points to loopback or a
private address that needs explicit allowlisting.
## Related nanobot docs
- [MCP tools for AI agents](./mcp-tools-for-ai-agents.md)
- [Configuration: MCP](../configuration.md#mcp-model-context-protocol)
- [Security](../configuration.md#security)

View File

@ -0,0 +1,93 @@
# How to Configure Model Fallback in nanobot
Model fallback lets nanobot try a primary model first, then fall back to one or
more named presets when the primary provider fails or rate-limits.
## What you will build
- two or more `modelPresets`
- a primary `agents.defaults.modelPreset`
- an ordered `agents.defaults.fallbackModels` chain
## When to use this
Use fallback when you want better reliability across rate limits, provider
outages, local model downtime, or cost-sensitive routing.
## Install
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
nanobot agent -m "Hello!"
```
Verify each provider works before adding it as a fallback.
## Minimal working example
Merge this shape into `~/.nanobot/config.json` and replace provider/model names
with ones you control:
```json
{
"modelPresets": {
"fast": {
"label": "Fast",
"provider": "primary-provider",
"model": "primary-model-id",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
},
"deep": {
"label": "Deep",
"provider": "fallback-provider",
"model": "fallback-model-id",
"maxTokens": 4096,
"contextWindowTokens": 200000,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "fast",
"fallbackModels": ["deep"]
}
}
}
```
String entries in `fallbackModels` are preset names, not raw model IDs.
Replace the placeholder model IDs with currently supported model IDs from your
provider. The [Provider Cookbook](../provider-cookbook.md) has concrete recipes
for common providers.
## Production notes
- Keep fallback context windows realistic; smaller fallback windows constrain
how much context can fit.
- Put cheaper or faster fallbacks before expensive ones when acceptable.
- Use `/model <preset>` for runtime switching without editing config.
- Keep labels human-readable for WebUI model lists.
## Security notes
- Different providers may have different data handling policies.
- Do not put provider keys directly in shared config files.
- Confirm fallback models can safely receive the same prompts and files.
## Troubleshooting
- If a fallback never triggers, confirm the primary error is treated as
retryable/fallbackable.
- If startup fails, check that each fallback string matches a key under
`modelPresets`.
- If output is truncated after fallback, review `maxTokens` and
`contextWindowTokens`.
## Related nanobot docs
- [Providers and Models](../providers.md)
- [Provider Cookbook: Fallback Presets](../provider-cookbook.md#recipe-fallback-presets)
- [Configuration: Model Fallbacks](../configuration.md#model-fallbacks)

View File

@ -0,0 +1,239 @@
# How to Improve Ollama Tool-Calling Prompt Cache Reuse in nanobot
Some Ollama model templates move or remove tool definitions as a conversation
switches between user, assistant, and tool messages. nanobot can send a correct
append-only chat request while the model template still renders a different token
prefix. On slower local hardware, re-evaluating that prefix can add tens of seconds
to an otherwise simple tool-using turn.
This guide shows how to diagnose that specific pattern and create a derived
`llama3.1:8b` tag with a prefix-stable tool template. It does not modify nanobot or
overwrite the original Ollama model.
## What you will build
- a repeatable two-turn cache check
- an optional derived `llama3.1:8b-prefix-stable-v1` Ollama tag
- a nanobot model preset that uses the derived tag
## When to use this
Use this guide when all of the following are true:
- direct Ollama responses are reasonably fast;
- nanobot becomes slow after the model calls a tool;
- Ollama logs show a long main prompt, a much shorter tool follow-up, and low
initial cache reuse on the next main prompt;
- the model is `llama3.1:8b` with a template that renders concrete tools only for
the final user message.
Do not apply this template to another model family without checking that model's
tool-call format first.
## Diagnose the rendered prompt
Stop any existing Ollama process, then start a single-slot debug server. A single
slot makes the cache sequence easier to read.
**macOS or Linux**
```bash
OLLAMA_CONTEXT_LENGTH=16384 \
OLLAMA_NUM_PARALLEL=1 \
OLLAMA_DEBUG=1 \
ollama serve
```
**Windows PowerShell**
```powershell
$env:OLLAMA_CONTEXT_LENGTH = "16384"
$env:OLLAMA_NUM_PARALLEL = "1"
$env:OLLAMA_DEBUG = "1"
ollama serve
```
In another terminal, use a fresh session and explicitly request a tool so both
turns exercise the agent loop:
```bash
nanobot agent --session cli:ollama-cache-check \
--message "Use the exec tool to calculate 2+2, then answer"
nanobot agent --session cli:ollama-cache-check \
--message "Use the exec tool to calculate 4+7, then answer"
```
In the Ollama output, find each `new prompt` line and the first
`cached n_tokens` line that follows it. Later increasing `cached n_tokens` lines
are prompt-evaluation progress, not additional initial cache hits.
A cache-unfriendly tool template may produce a pattern like this:
```text
turn 1 main: 2 / 8460 initially cached
turn 1 tool follow-up: 3713 / 3758 initially cached
turn 2 main: 3767 / 8519 initially cached
```
The cache is working, but the next main request can reuse only the shorter prompt.
Hardware throughput determines how expensive the remaining evaluation is.
To inspect the API request bodies as well, add
`OLLAMA_DEBUG_LOG_REQUESTS=1` before starting Ollama. These logs can contain system
prompts, workspace context, and user messages. Keep them local and disable request
logging after diagnosis.
## Why this happens with the stock template
The tested `llama3.1:8b` template conditionally expands the tool definitions inside
a user message:
```gotemplate
{{- if and $.Tools $last }}
... render tool definitions ...
{{- end }}
```
The first request ends with a user message, so the tools are rendered there. After
nanobot appends an assistant tool call and its result, that user message is no
longer last, so the same API request history renders without the concrete tool
block. On the next user turn, the tools reappear at a new position.
This is a model-template behavior. At the API boundary, nanobot continues to append
the assistant tool call and tool result and sends the same tool definitions.
## Create a prefix-stable derived model
Create `PrefixStable.Modelfile` with the content below. The template keeps concrete
tool definitions in the system block, where they remain in the same position across
user and tool messages.
```dockerfile
FROM llama3.1:8b
TEMPLATE """{{- if or .System .Tools }}<|start_header_id|>system<|end_header_id|>
{{- if .System }}
{{ .System }}
{{- end }}
{{- if .Tools }}
Cutting Knowledge Date: December 2023
When you receive a tool call response, use the output to format an answer to the original user question.
You are a helpful assistant with tool calling capabilities.
Given the following functions, respond with a JSON function call with the proper arguments when a tool is needed.
Respond in the format {"name": function name, "parameters": dictionary of argument name and its value}. Do not use variables.
{{ range .Tools }}
{{- . }}
{{ end }}
{{- end }}<|eot_id|>
{{- end }}
{{- range $i, $_ := .Messages }}
{{- $last := eq (len (slice $.Messages $i)) 1 }}
{{- if eq .Role "user" }}<|start_header_id|>user<|end_header_id|>
{{ .Content }}<|eot_id|>{{ if $last }}<|start_header_id|>assistant<|end_header_id|>
{{ end }}
{{- else if eq .Role "assistant" }}<|start_header_id|>assistant<|end_header_id|>
{{- if .ToolCalls }}
{{ range .ToolCalls }}
{"name": "{{ .Function.Name }}", "parameters": {{ .Function.Arguments }}}{{ end }}
{{- else }}
{{ .Content }}
{{- end }}{{ if not $last }}<|eot_id|>{{ end }}
{{- else if eq .Role "tool" }}<|start_header_id|>ipython<|end_header_id|>
{{ .Content }}<|eot_id|>{{ if $last }}<|start_header_id|>assistant<|end_header_id|>
{{ end }}
{{- end }}
{{- end }}"""
```
Create the new tag:
```bash
ollama create llama3.1:8b-prefix-stable-v1 -f PrefixStable.Modelfile
ollama list
```
Ollama reuses the existing model layers. The new tag adds a small template and
manifest instead of copying the base weights.
## Select the derived model in nanobot
Merge this preset into `~/.nanobot/config.json` and select it:
```json
{
"providers": {
"ollama": {
"apiBase": "http://localhost:11434/v1"
}
},
"modelPresets": {
"ollamaPrefixStable": {
"label": "Ollama Llama 3.1 prefix-stable",
"provider": "ollama",
"model": "llama3.1:8b-prefix-stable-v1",
"maxTokens": 2048,
"contextWindowTokens": 16384,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "ollamaPrefixStable"
}
}
}
```
Verify the selected model and repeat the two-turn check:
```bash
nanobot status
nanobot agent --session cli:ollama-stable-check \
--message "Use the exec tool to calculate 2+2, then answer"
nanobot agent --session cli:ollama-stable-check \
--message "Use the exec tool to calculate 4+7, then answer"
```
In one controlled test with Ollama 0.32.1, `llama3.1:8b`, and one slot, the second
main request improved from `3767 / 8519` initially cached (44.22%) to
`8505 / 8520` (99.82%). The number of re-evaluated tokens fell from 4752 to 15.
Treat these numbers as a diagnostic example, not a performance guarantee.
## Roll back
Switch `agents.defaults.modelPreset` back to the original preset. When no config
uses the derived tag, remove it with:
```bash
ollama rm llama3.1:8b-prefix-stable-v1
```
Removing the derived tag does not remove `llama3.1:8b`.
## Limitations
- The template above is specific to the tested `llama3.1:8b` tool-call format.
- Ollama or the model publisher may update the stock template in a later release.
- Validate multiple tool calls, tool errors, parallel calls, and long conversations
before using a custom template for unattended workloads.
- A higher cache ratio reduces prompt evaluation, but model generation, tool
execution, process startup, and storage can still dominate end-to-end latency.
- Multiple Ollama slots change cache scheduling and may produce different results.
## Related nanobot docs
- [Provider Cookbook: Ollama Local Model](../provider-cookbook.md#recipe-ollama-local-model)
- [Providers and Models: Ollama](../providers.md#ollama)
- [Troubleshooting](../troubleshooting.md)

View File

@ -0,0 +1,93 @@
# How to Configure an OpenAI-Compatible Provider in nanobot
nanobot can call OpenAI-compatible model providers by configuring an `apiBase`,
optional `apiKey`, and a model preset that references that provider name.
## What you will build
- a custom provider entry
- a model preset pointing at that provider
- one successful `nanobot agent` run
## When to use this
Use this for local or hosted services that expose OpenAI-compatible endpoints,
including internal gateways, local model servers, and provider proxies that are
not already named in nanobot.
## Install
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
```
Verify the endpoint responds before debugging nanobot:
```bash
curl -sS https://api.example.com/v1/models
```
## Minimal working example
Merge this into `~/.nanobot/config.json`:
```json
{
"providers": {
"custom": {
"apiKey": "${CUSTOM_API_KEY}",
"apiBase": "https://api.example.com/v1"
}
},
"modelPresets": {
"primary": {
"label": "Custom",
"provider": "custom",
"model": "provider-model-name",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
Then run:
```bash
nanobot agent -m "Hello!"
```
## Production notes
- Include the version path in `apiBase` when the service expects `/v1`.
- Use separate provider names for separate endpoints.
- Use a placeholder key such as `EMPTY` only when the endpoint requires a
non-empty key but does not validate it.
- Leave `apiType` unset for OpenAI-compatible custom endpoints.
## Security notes
- Keep provider keys in environment variables.
- Treat internal model gateways as sensitive network services.
- Do not point nanobot at untrusted proxy endpoints for private workspaces.
## Troubleshooting
- If `curl /models` fails, fix the provider endpoint before changing nanobot.
- If nanobot says the model is unknown, check the model ID expected by the
provider.
- If auth fails, confirm whether the provider wants Bearer auth and whether the
key is present in the environment that starts nanobot.
## Related nanobot docs
- [Provider Cookbook: Custom OpenAI-Compatible Provider](../provider-cookbook.md#recipe-custom-openai-compatible-provider)
- [Providers: Custom OpenAI-Compatible Endpoint](../providers.md#custom-openai-compatible-endpoint)
- [OpenAI-Compatible Agent API](./openai-compatible-agent-api.md)

View File

@ -0,0 +1,98 @@
# How to Configure Web Search for a nanobot AI Agent
nanobot includes built-in web search and web fetch tools. Search uses
DuckDuckGo by default and can be configured for API-backed or self-hosted
providers.
## What you will build
- web tools enabled in nanobot
- one search provider selected in the WebUI or `config.json`
- optional web fetch settings for page reading
## When to use this
Configure web search when the agent needs current information, public web
research, source discovery, or page fetching during a task.
## Install
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
nanobot agent -m "Hello!"
```
Web tools are enabled by default. Configure them only when you want a specific
provider, API key, proxy, fetch behavior, or SSRF allowlist.
## Minimal working example
For local interactive setup:
1. Run `nanobot webui`.
2. Open **Settings → Web**.
3. Enable web search, choose a provider, and enter its API key if required.
4. Save and restart when prompted.
5. Ask a question that requires current information and inspect the cited sources.
For manual or deployment-managed config, use the default search provider:
```json
{
"tools": {
"web": {
"enable": true,
"search": {
"provider": "duckduckgo"
}
}
}
}
```
Or use an API-backed provider:
```json
{
"tools": {
"web": {
"search": {
"provider": "brave",
"apiKey": "${BRAVE_API_KEY}"
}
}
}
}
```
Ask a question that requires current information and inspect the tool activity
in the WebUI or logs.
## Production notes
- Keep API keys in environment variables.
- Set `maxResults` when you need fewer or more search results per query.
- Set `tools.web.proxy` only to a proxy you trust.
- Use `fetch.useJinaReader: false` if you need local page conversion.
## Security notes
- Web fetch and HTTP MCP share an SSRF guard.
- Private, loopback, link-local, and cloud metadata addresses are blocked by
default.
- Add `tools.ssrfWhitelist` only for narrow trusted CIDRs.
- Do not give public chat users unrestricted web and shell access without
review.
## Troubleshooting
- If search returns no results, switch provider or check the provider API key.
- If fetch is blocked, inspect the target URL and SSRF whitelist.
- If a proxy changes network behavior, verify `NO_PROXY` and proxy settings.
## Related nanobot docs
- [Configuration: Web Tools](../configuration.md#web-tools)
- [Security](../configuration.md#security)
- [WebUI](../webui.md)

View File

@ -0,0 +1,76 @@
# How to Deploy a Long-Running nanobot AI Agent Gateway
The nanobot gateway is the long-running self-hosted AI agent process that keeps
WebUI sessions, chat apps, automations, local triggers, heartbeat jobs, Dream,
and WebSocket delivery online.
## What you will build
- a verified nanobot config
- a gateway process
- a service or container deployment path with Docker, systemd, or macOS
LaunchAgent
## When to use this
Use this when nanobot should keep running after a single CLI turn. Chat apps,
browser sessions, background automations, local triggers, and server-side
integrations all depend on a live gateway.
## Install
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
nanobot status
nanobot agent -m "Hello!"
```
## Minimal working example
Run the gateway in the foreground:
```bash
nanobot gateway
```
For WebUI background usage:
```bash
nanobot webui --background
nanobot gateway status
nanobot gateway logs
```
## Production notes
- Docker Compose is the most repeatable Linux container path.
- systemd user services are useful for Linux user-level gateway deployments.
- macOS LaunchAgent keeps the gateway alive after login.
- Persist config, workspace, sessions, memory files, channel login state, and
generated artifacts.
- Restart the gateway after editing `config.json`.
## Security notes
- Plan ports before exposing services. Gateway health defaults to `18790`,
WebUI/WebSocket defaults to `8765`, and `nanobot serve` defaults to `8900`.
- Bind externally only when you have configured tokens or API keys.
- Keep chat access control intentional before deploying.
- Use Docker or Linux sandboxing when shell tools are enabled for unattended
work.
## Troubleshooting
- Use the same `--config` and `--workspace` flags for status checks and service
startup.
- Check logs with `docker compose logs`, `journalctl`, LaunchAgent logs, or
`nanobot gateway --verbose`.
- If Docker port publishing does not work, confirm the service is not bound only
to container loopback.
## Related nanobot docs
- [Deployment](../deployment.md)
- [Multiple Instances](../multiple-instances.md)
- [Configuration](../configuration.md)

View File

@ -0,0 +1,107 @@
# Build a Discord AI Agent with nanobot
This guide connects nanobot to Discord so a Discord user or server channel can
talk to your self-hosted AI agent through the nanobot gateway.
## What this guide builds
- a Discord bot application
- Message Content intent enabled
- the `discord` channel enabled in nanobot
- one direct message or mention test
## Prerequisites
- A working local nanobot reply:
```bash
nanobot agent -m "Hello!"
```
- Access to the Discord Developer Portal.
- A Discord server where you can invite a bot.
## Install nanobot
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
```
## Enable the Discord channel
Install the optional channel dependency:
```bash
nanobot plugins enable discord
```
Create a Discord application, add a bot, copy the token, and enable
`MESSAGE CONTENT INTENT` in the bot settings.
Merge this snippet into `~/.nanobot/config.json`:
```json
{
"channels": {
"discord": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"allowChannels": [],
"groupPolicy": "mention",
"streaming": true
}
}
}
```
Omitting `allowFrom` enables pairing-only mode. A new user should DM the bot
first, get a pairing code, and be approved before using the bot in servers.
Invite the bot with permissions to read history and send messages.
## Run nanobot gateway
```bash
nanobot channels status
nanobot gateway
```
## Test a message
Send the bot a DM first. It should return a pairing code. Approve it from a
trusted local surface:
```bash
nanobot agent -m "/pairing approve ABCD-EFGH"
```
After approval, mention it in an allowed server channel:
```text
@your-bot Hello from Discord
```
## Security notes
- Keep `groupPolicy` as `mention` for first deployment.
- Use `allowChannels` for server channels where the bot should operate.
- Prefer pairing-only mode for user access; add `allowFrom` only when you want a
static allowlist.
- Avoid open group behavior in busy channels until session routing is clear.
- Review tool access before inviting the bot into shared servers.
## Troubleshooting
- If no messages arrive, confirm Message Content intent is enabled.
- If a DM returns a pairing code, approve it before testing normal replies.
- If server messages are ignored, check pairing approval, `allowChannels`, and
whether the bot was mentioned.
- If the bot cannot reply, confirm the invite permissions and channel overrides.
## Next: memory, automations, MCP tools
- [Chat Apps reference](../chat-apps.md)
- [Pairing](../configuration.md#pairing)
- [AI Agent Memory](./ai-agent-memory.md)
- [Configure MCP tools](./configure-mcp-tools.md)

View File

@ -0,0 +1,93 @@
# Build an Email AI Agent with nanobot
This guide turns nanobot into an email AI agent that polls IMAP for accepted
messages and replies through SMTP.
## What this guide builds
- a dedicated mailbox for nanobot
- IMAP and SMTP credentials in `config.json`
- an allowed sender list
- a gateway process that polls and replies
## Prerequisites
- A working local nanobot reply:
```bash
nanobot agent -m "Hello!"
```
- A mailbox for the bot.
- IMAP and SMTP access. For Gmail, use an app password rather than your account
password.
## Install nanobot
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
```
## Enable the Email channel
Merge this snippet into `~/.nanobot/config.json` and replace the addresses and
passwords:
```json
{
"channels": {
"email": {
"enabled": true,
"consentGranted": true,
"imapHost": "imap.gmail.com",
"imapPort": 993,
"imapUsername": "my-nanobot@gmail.com",
"imapPassword": "your-app-password",
"smtpHost": "smtp.gmail.com",
"smtpPort": 587,
"smtpUsername": "my-nanobot@gmail.com",
"smtpPassword": "your-app-password",
"fromAddress": "my-nanobot@gmail.com",
"allowFrom": ["your-real-email@gmail.com"],
"autoReplyEnabled": true
}
}
}
```
## Run nanobot gateway
```bash
nanobot channels status
nanobot gateway
```
## Test a message
Send an email from an address in `allowFrom` to the bot mailbox. Keep the
gateway running long enough for the polling interval to receive it.
## Security notes
- Use a dedicated mailbox, not your primary personal inbox.
- Set `consentGranted` to `false` to fully disable mailbox access.
- Email does not use DM pairing. Keep `allowFrom` narrow; `["*"]` accepts mail
from anyone.
- Use environment variables for mailbox passwords.
- Enable attachment types only when the agent needs them.
## Troubleshooting
- If login fails, confirm IMAP/SMTP access and app-password setup.
- If the bot reads but does not reply, check `autoReplyEnabled`, SMTP settings,
and allowed sender addresses.
- If attachments are missing, review `allowedAttachmentTypes`, size limits, and
gateway logs.
## Next: memory, automations, MCP tools
- [Chat Apps reference](../chat-apps.md)
- [Secure local AI agent](./secure-local-ai-agent.md)
- [AI Agent Memory](./ai-agent-memory.md)
- [OpenAI-compatible agent API](./openai-compatible-agent-api.md)

View File

@ -0,0 +1,120 @@
# Build a Feishu AI Agent with nanobot
This guide connects nanobot to Feishu or Lark through the `feishu` channel. The
channel uses a WebSocket long connection, so the first setup does not require a
public webhook URL.
## What this guide builds
- a Feishu/Lark bot app connected to nanobot
- the `feishu` channel enabled in `config.json`
- one pairing-approved Feishu or Lark user
- mention-only group behavior for first deployment
## Prerequisites
- A working local nanobot reply:
```bash
nanobot agent -m "Hello!"
```
- A Feishu or Lark account that can create or approve bot apps.
- Permission to run `nanobot gateway` continuously.
## Install nanobot
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
```
## Enable the Feishu channel
Install the optional channel dependency:
```bash
nanobot plugins enable feishu
```
The easiest path is QR login:
```bash
nanobot channels login feishu
```
Open the printed URL or scan the QR code. nanobot writes the generated `appId`,
`appSecret`, `domain`, and `enabled` fields into the active config.
If QR login is unavailable, create a Feishu/Lark app manually and merge this
shape into `~/.nanobot/config.json`:
```json
{
"channels": {
"feishu": {
"enabled": true,
"appId": "cli_xxx",
"appSecret": "xxx",
"groupPolicy": "mention",
"streaming": true,
"domain": "feishu"
}
}
}
```
Omitting `allowFrom` enables pairing-only mode. A new user should DM the bot,
get a pairing code, and be approved before using the bot normally.
For manual apps, enable the Bot capability, receive-message events, and Long
Connection mode. If your app cannot get the `cardkit:card:write` permission,
set `"streaming": false`.
## Run nanobot gateway
```bash
nanobot channels status
nanobot gateway
```
## Test a message
DM the bot first. It should return a pairing code. Approve it from a trusted
local surface:
```bash
nanobot agent -m "/pairing approve ABCD-EFGH"
```
After approval, DM the bot again or mention it in a group chat:
```text
@nanobot Hello from Feishu
```
## Security notes
- Prefer pairing-only mode for first setup. Add `allowFrom` only when you want a
static allowlist.
- Keep `groupPolicy` as `"mention"` before inviting the bot into busy groups.
- Store app secrets through environment variables for deployed services.
- Review file, shell, and web tool access before adding more users.
## Troubleshooting
- If QR login is unavailable, use manual app setup from the full chat-apps
reference.
- If streaming cards fail, confirm `cardkit:card:write` or set
`"streaming": false`.
- If no messages arrive, check Feishu/Lark event permissions, Long Connection
mode, and `nanobot gateway --verbose`.
- If a first DM returns a pairing code, approve it before testing normal
replies.
## Next: memory, automations, MCP tools
- [Chat Apps reference](../chat-apps.md)
- [Pairing](../configuration.md#pairing)
- [AI Agent Memory](./ai-agent-memory.md)
- [Configure MCP tools](./configure-mcp-tools.md)

View File

@ -0,0 +1,73 @@
# How to Run a Long-Running AI Agent with nanobot
nanobot can keep agent work alive across turns through sustained goals,
persistent sessions, scheduled automations, local triggers, and a gateway
process that stays running.
## What you will build
- a working local agent
- a persistent chat session
- a long-running goal or automation
- a gateway process for background delivery
## When to use this
Use this when the task is not a one-shot answer: project work, recurring checks,
scheduled summaries, file maintenance, multi-step research, or local triggers
from scripts and build jobs.
## Install
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
nanobot agent -m "Hello!"
```
## Minimal working example
Start a gateway:
```bash
nanobot gateway
```
From the WebUI or a chat session, start a sustained goal:
```text
/goal Review this workspace, identify missing tests, and propose the smallest next fix.
```
For scheduled or trigger-based runs, create the automation from the target chat
so nanobot can link it to the correct session and workspace.
## Production notes
- Keep the gateway running for chat apps, WebUI sessions, automations, and local
triggers.
- Use stable session keys or chat sessions for work that should preserve context.
- Keep goals bounded and explicit about done-ness.
- Review Automations in the WebUI before relying on a schedule.
## Security notes
- Treat long-running goals as delegated work with real tool access.
- Restrict workspaces and shell execution before scheduling unattended tasks.
- Keep chat access narrow so unknown users cannot create goals or automations.
## Troubleshooting
- If a goal appears stuck, inspect the active session and gateway logs.
- If an automation does not run, check that it is linked to a chat/session and
that the gateway is still running.
- If a local trigger fails, check the command copied from the WebUI Automations
view.
## Related nanobot docs
- [Automations](../automations.md)
- [WebUI Automations](../webui.md#automations)
- [Chat Commands](../chat-commands.md)
- [Memory](../memory.md)
- [Deployment](../deployment.md)

View File

@ -0,0 +1,113 @@
# Build a Mattermost AI Agent with nanobot
This guide connects nanobot to Mattermost through the built-in Mattermost
channel, using WebSocket events and the Mattermost REST API.
## What this guide builds
- a Mattermost bot account or token
- the `mattermost` channel enabled in nanobot
- mention-only group behavior for first deployment
- one pairing-approved DM or mention test
## Prerequisites
- A working local nanobot reply:
```bash
nanobot agent -m "Hello!"
```
- A Mattermost server URL.
- A bot token or personal access token for the bot account.
## Install nanobot
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
```
## Enable the Mattermost channel
Merge this snippet into `~/.nanobot/config.json`:
```json
{
"channels": {
"mattermost": {
"enabled": true,
"serverUrl": "https://mattermost.example.com",
"token": "YOUR_MATTERMOST_TOKEN",
"teamId": "YOUR_TEAM_ID",
"groupPolicy": "mention",
"groupPolicyInThread": "open",
"replyInThread": true,
"dm": {
"policy": "allowlist"
}
}
}
}
```
`teamId` scopes the channel to a Mattermost team. Keep `groupPolicy` as
`mention` for the first test. `groupPolicyInThread` can be `"mention"`,
`"open"`, or `"allowlist"` and controls messages that reply inside a
thread. If it is omitted, it inherits `groupPolicy`, preserving the behavior
of existing configurations. Set it to `"open"` explicitly when follow-up
messages in threads should not require another @mention.
When `groupPolicy` is `"allowlist"`, `groupAllowFrom` remains the outer
channel boundary for root posts and thread replies. A thread policy cannot open
a channel that is not on that allowlist.
Mattermost DMs are open by default. Setting `dm.policy` to `"allowlist"` with no
`dm.allowFrom` entries makes new DM senders receive a pairing code. Approve the
code before using the bot normally.
## Run nanobot gateway
```bash
nanobot channels status
nanobot gateway
```
## Test a message
DM the bot account. It should return a pairing code. Approve it from a trusted
local surface:
```bash
nanobot agent -m "/pairing approve ABCD-EFGH"
```
Then DM the bot again, or mention it in a channel where the bot has access:
```text
@nanobot Hello from Mattermost
```
## Security notes
- Store the Mattermost token in an environment variable for deployed services.
- Keep `dm.policy` as `"allowlist"` when you want pairing-based approval.
- Use mention-only group behavior before opening the bot to busy channels.
- Review file and shell tools before inviting broad channel access.
## Troubleshooting
- If startup logs say `serverUrl and token must be configured`, check the
camelCase config keys.
- If DMs are ignored, review the `dm` policy and pairing approval state.
- If channel messages are ignored, confirm the bot is mentioned and belongs to
the team/channel.
- If thread replies are surprising, review `groupPolicyInThread`,
`replyInThread`, and `includeThreadContext`.
## Next: memory, automations, MCP tools
- [Chat Apps reference](../chat-apps.md)
- [Pairing](../configuration.md#pairing)
- [Long-running AI Agent](./long-running-ai-agent.md)
- [Deployment](../deployment.md)

View File

@ -0,0 +1,75 @@
# How to Add MCP Tools to an AI Agent with nanobot
nanobot can connect MCP servers and expose their tools to the agent alongside
built-in file, shell, web, cron, image generation, and subagent tools.
## What you will build
- a working nanobot agent
- one MCP server configured in `config.json`
- a restricted set of tools available to the model
## When to use this
Use MCP when a tool already exists as an MCP server, when another application
publishes an MCP adapter, or when you want a clean boundary between nanobot and
external tool logic.
## Install
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
nanobot agent -m "Hello!"
```
Install the MCP server's own runtime separately. For example, many local MCP
servers use `npx` or `uvx`.
## Minimal working example
Add a stdio MCP server to `~/.nanobot/config.json`:
```json
{
"tools": {
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"],
"enabledTools": ["read_file"]
}
}
}
}
```
Restart nanobot, then ask a question that needs the MCP tool.
## Production notes
- Use `enabledTools` to expose only the tools the agent actually needs.
- Set `toolTimeout` for slow MCP servers.
- Prefer stdio MCP for local tools and HTTP MCP for trusted remote services.
- Keep MCP server install/update steps outside nanobot config when possible.
## Security notes
- HTTP/SSE MCP URLs use the same SSRF guard as web fetch.
- Local/private HTTP endpoints require an explicit `tools.ssrfWhitelist` entry.
- Stdio MCP servers run local processes; review their command and arguments.
- Do not pass secrets in command-line args when environment variables or headers
are available.
## Troubleshooting
- Start `nanobot gateway --verbose` and check MCP startup logs.
- Confirm the MCP command works by itself before debugging nanobot.
- If an HTTP MCP server is blocked, review the SSRF whitelist and use a narrow
host CIDR.
## Related nanobot docs
- [Configure MCP tools](./configure-mcp-tools.md)
- [Configuration: MCP](../configuration.md#mcp-model-context-protocol)
- [Security](../configuration.md#security)

View File

@ -0,0 +1,74 @@
# How to Run an OpenAI-Compatible Agent API with nanobot
nanobot can expose a local OpenAI-compatible endpoint behind
`/v1/chat/completions`. This lets existing OpenAI-style clients talk to a
tool-using nanobot agent instead of a raw model.
## What you will build
- a working nanobot agent
- a local API server on `127.0.0.1:8900`
- a `/v1/chat/completions` request
- optional session isolation with `session_id`
## When to use this
Use this when an existing client, another language, or a separate process
already knows how to call an OpenAI-compatible API. Use the Python SDK when you
want in-process access to sessions, memory, runtime helpers, and hooks.
## Install
```bash
python -m pip install nanobot-ai
nanobot plugins enable api
nanobot onboard --wizard
nanobot agent -m "Hello!"
```
## Minimal working example
Start the API server:
```bash
nanobot serve
```
Call the chat endpoint:
```bash
curl http://127.0.0.1:8900/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"messages": [{"role": "user", "content": "hi"}],
"session_id": "demo"
}'
```
## Production notes
- Pass `session_id` to isolate users, jobs, or workflows.
- Streaming uses Server-Sent Events when `stream` is `true`.
- `/v1/models` reports the fixed model surface expected by compatible clients.
- File uploads are supported through JSON base64 or multipart form data.
## Security notes
- Local `127.0.0.1` usage does not require an API key.
- If `api.host` is `0.0.0.0` or `::`, configure `api.apiKey` before startup.
- Treat the API as agent access, not just model access: tools and workspace
permissions still matter.
## Troubleshooting
- If `/v1/chat/completions` fails, test `nanobot agent -m "Hello!"` first.
- If remote clients cannot connect, check `api.host`, `api.port`, firewall, and
API key configuration.
- If sessions mix together, pass unique `session_id` values.
## Related nanobot docs
- [Nanobot OpenAI-Compatible API](../openai-api.md)
- [Python SDK](../python-sdk.md)
- [Configuration](../configuration.md)
- [Deployment](../deployment.md)

View File

@ -0,0 +1,75 @@
# Nanobot Python SDK: Run an AI Agent from Python
This guide shows when to use the Nanobot Python SDK instead of calling a model
directly. The SDK runs the same agent runtime used by the CLI: model routing,
tools, workspace access, session history, memory, streaming events, and runtime
helpers.
## What you will build
- a Python script that creates a `Nanobot`
- one agent run from code
- an optional streamed run with tool visibility
## When to use this
Use the Python SDK for notebooks, evals, product backends, local scripts,
workflow runners, and integrations that need direct access to agent sessions,
memory, hooks, runtime state, or structured run results.
Use the OpenAI-compatible API instead when another language or process should
call nanobot over HTTP.
## Install
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
nanobot agent -m "Hello!"
```
## Minimal working example
```python
import asyncio
from nanobot import Nanobot
async def main() -> None:
async with Nanobot.from_config() as bot:
result = await bot.run("List the top-level files in this workspace.")
print(result.content)
asyncio.run(main())
```
## Production notes
- Reuse one `Nanobot` instance for related work.
- Pass `session_key` when a user, job, or eval case needs persistent history.
- Use `bot.stream(...)` when the caller needs live text, tool, or failure
events.
- Use hooks for audit logs or custom observability.
## Security notes
- The SDK uses the same config, workspace, tools, and secrets as the CLI.
- Do not run untrusted prompts with broad file or shell access.
- Keep separate config/workspace paths for separate products or tenants.
## Troubleshooting
- If SDK code fails, first run `nanobot agent -m "Hello!"` in the same
environment.
- Print `bot.runtime.workspace` and `bot.runtime.model` to confirm the expected
config loaded.
- Use explicit `config_path` and `workspace` when scripts run from services.
## Related nanobot docs
- [Nanobot Python SDK](../python-sdk.md)
- [OpenAI-Compatible API](../openai-api.md)
- [Configuration](../configuration.md)
- [Concepts](../concepts.md)

102
docs/guides/qq-ai-agent.md Normal file
View File

@ -0,0 +1,102 @@
# Build a QQ AI Agent with nanobot
This guide connects nanobot to QQ through the official `qq` channel. The
official channel uses the botpy SDK and currently focuses on private messages.
For QQ group chat and OneBot v11 workflows, use the Napcat section in the full
chat-apps reference.
## What this guide builds
- a QQ bot application
- the `qq` channel enabled in nanobot
- one pairing-approved QQ private sender
- a running nanobot gateway
## Prerequisites
- A working local nanobot reply:
```bash
nanobot agent -m "Hello!"
```
- Access to the QQ Open Platform.
- A QQ account added to the bot sandbox for testing.
## Install nanobot
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
```
## Enable the QQ channel
Install the optional channel dependency:
```bash
nanobot plugins enable qq
```
In the QQ Open Platform, create a bot application and copy the AppID and
AppSecret. Add your QQ account to the sandbox test members, then merge this
snippet into `~/.nanobot/config.json`:
```json
{
"channels": {
"qq": {
"enabled": true,
"appId": "YOUR_APP_ID",
"secret": "YOUR_APP_SECRET",
"msgFormat": "plain"
}
}
}
```
Omitting `allowFrom` enables pairing-only mode. A new private sender should get
a pairing code before normal agent access.
## Run nanobot gateway
```bash
nanobot channels status
nanobot gateway
```
## Test a message
Send the QQ bot a private message from a sandbox account. It should return a
pairing code. Approve it from a trusted local surface:
```bash
nanobot agent -m "/pairing approve ABCD-EFGH"
```
Send the message again after approval.
## Security notes
- Prefer pairing-only mode for first setup. Add `allowFrom` only when you want a
static allowlist.
- Keep sandbox testing separate from production publishing.
- Store QQ AppSecret through environment variables for deployed services.
- Use Napcat only when you intentionally need a QQ account bridge and group chat
features.
## Troubleshooting
- If private messages do not arrive, confirm the sender is in the QQ bot sandbox
and the gateway is running.
- If output formatting is unreliable, keep `msgFormat` as `"plain"`.
- If a first private message returns a pairing code, approve it before testing
normal replies.
- If you need QQ groups, see the Napcat section in the full chat-apps reference.
## Next: memory, automations, MCP tools
- [Chat Apps reference](../chat-apps.md)
- [Pairing](../configuration.md#pairing)
- [AI Agent Memory](./ai-agent-memory.md)
- [Configure MCP tools](./configure-mcp-tools.md)

View File

@ -0,0 +1,78 @@
# How to Secure a Local AI Agent with nanobot
This guide covers the practical controls to review before letting a nanobot
agent access files, shell commands, web fetch, chat apps, or remote users.
## What you will build
- a workspace-scoped agent setup
- narrow channel access
- safer secrets handling
- optional shell sandboxing on Linux
## When to use this
Use this before exposing nanobot to teammates, chat apps, public networks, broad
web access, or unattended automations.
## Install
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
nanobot agent -m "Hello!"
```
## Minimal working example
Start with workspace restriction:
```json
{
"tools": {
"restrictToWorkspace": true,
"exec": {
"enable": true,
"sandbox": "bwrap"
}
}
}
```
`bwrap` is Linux-only and requires bubblewrap. On macOS or Windows, keep
`restrictToWorkspace` enabled and review shell access carefully.
## Production notes
- Use environment variables for provider keys, bot tokens, and mailbox
passwords.
- Keep one workspace per trust boundary.
- Prefer pairing for DM-capable chat apps, use narrow `allowFrom` lists only
when static allowlists are intentional, and keep group policy mention-only at
first.
- Bind WebUI, WebSocket, and API services to localhost unless remote access is
intentional.
## Security notes
- `restrictToWorkspace` is an application-level guard, not an OS sandbox.
- `tools.exec.enable: false` removes shell execution entirely.
- HTTP web fetch and HTTP MCP use SSRF protections by default.
- Adding broad `tools.ssrfWhitelist` ranges increases exposure.
- `allowFrom: ["*"]` bypasses pairing and means anyone who can reach that
channel can talk to the bot.
## Troubleshooting
- If a needed file cannot be read, confirm the active workspace path.
- If a shell command fails under `bwrap`, check whether the command needs files
outside the sandbox.
- If local HTTP tools are blocked, review the SSRF whitelist and use a narrow
CIDR.
## Related nanobot docs
- [Configuration: Security](../configuration.md#security)
- [Pairing](../configuration.md#pairing)
- [Deployment](../deployment.md)
- [Chat Apps](../chat-apps.md)

View File

@ -0,0 +1,83 @@
# How to Run a Self-Hosted AI Agent with nanobot
This guide sets up nanobot as a self-hosted AI agent runtime on your own
machine or server. The result is a gateway process that can serve the WebUI,
chat apps, automations, and API integrations.
## What you will build
- a nanobot config and workspace under your control
- a model provider connected through `config.json`
- a long-running `nanobot gateway`
- optional browser, chat app, and API access
## When to use this
Use this path when you want local or server-side ownership of the agent process,
workspace files, memory files, and provider keys. It is also the right path when
the agent must keep running after one terminal command finishes.
## Install
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
nanobot agent -m "Hello!"
```
Complete the CLI check before deploying the gateway. A deployment problem is
much easier to debug after the provider and model are known to work.
## Minimal working example
For chat apps, automations, and WebSocket delivery, start the gateway:
```bash
nanobot gateway
```
For the browser surface, use the WebUI launcher instead. It can start and manage
the local gateway for you:
```bash
nanobot webui
```
Or connect a channel in `~/.nanobot/config.json`, then keep the same gateway
process running for messages.
## Production notes
- Use Docker, systemd, or a macOS LaunchAgent when the process should survive
terminal exits.
- Give every deployed instance a distinct config path, workspace path, and port
set.
- Keep secrets in environment variables and start the service from the same
environment.
- Use health checks against the gateway or API process, not chat app delivery as
the only signal.
## Security notes
- Bind local-only services to `127.0.0.1` unless you intentionally expose them.
- Set an API key before binding the OpenAI-compatible API to a public interface.
- Prefer pairing for DM-capable chat apps, and keep any static `allowFrom`
allowlists strict.
- Enable `tools.restrictToWorkspace`; on Linux, use the bubblewrap sandbox for
shell execution.
## Troubleshooting
- Run `nanobot status` with the same `--config` and `--workspace` flags used by
the service.
- Run `nanobot gateway --verbose` while debugging channel startup.
- Check port conflicts if the WebUI, WebSocket channel, or API endpoint fails to
bind.
## Related nanobot docs
- [Deployment](../deployment.md)
- [Multiple Instances](../multiple-instances.md)
- [Configuration](../configuration.md)
- [Chat Apps](../chat-apps.md)
- [OpenAI-Compatible API](../openai-api.md)

View File

@ -0,0 +1,109 @@
# Build a Slack AI Agent with nanobot
This guide connects nanobot to Slack through Socket Mode. No public webhook URL
is required for the first working setup.
## What this guide builds
- a Slack app with Socket Mode
- a bot token and app-level token
- the `slack` channel enabled in nanobot
- a DM pairing flow and mention test from an approved Slack user
## Prerequisites
- A working nanobot reply:
```bash
nanobot agent -m "Hello!"
```
- Permission to create a Slack app in a workspace.
## Install nanobot
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
```
## Enable the Slack channel
Install the optional channel dependency:
```bash
nanobot plugins enable slack
```
In Slack, create an app, enable Socket Mode, create an app-level token with
`connections:write`, add bot scopes, subscribe to bot events, and install the
app to your workspace.
Merge this snippet into `~/.nanobot/config.json`:
```json
{
"channels": {
"slack": {
"enabled": true,
"botToken": "xoxb-...",
"appToken": "xapp-...",
"groupPolicy": "mention",
"dm": {
"policy": "allowlist"
}
}
}
}
```
Slack DMs are open by default. Setting `dm.policy` to `"allowlist"` with no
`dm.allowFrom` entries makes new DM senders receive a pairing code. Approve the
code before using the bot normally.
## Run nanobot gateway
```bash
nanobot channels status
nanobot gateway
```
## Test a message
DM the Slack bot directly. It should return a pairing code. Approve it from a
trusted local surface:
```bash
nanobot agent -m "/pairing approve ABCD-EFGH"
```
Then DM the bot again, or mention it in a channel:
```text
@nanobot Hello from Slack
```
## Security notes
- Keep `groupPolicy` as `mention` unless the bot is intentionally listening to
every channel message.
- Keep `dm.policy` as `"allowlist"` when you want pairing-based approval.
- Use `groupAllowFrom` with allowlist mode for approved channels.
- Reinstall the Slack app after changing scopes.
- Keep bot and app tokens out of committed config files.
## Troubleshooting
- If Socket Mode fails, confirm the app-level token starts with `xapp-`.
- If the bot cannot send files, add `files:write`, reinstall the app, and
restart nanobot.
- If a DM responds normally without pairing, check that `dm.policy` is
`"allowlist"`.
- If channel messages are ignored, check event subscriptions and group policy.
## Next: memory, automations, MCP tools
- [Chat Apps reference](../chat-apps.md)
- [Configure web search](./configure-web-search.md)
- [Long-running AI Agent](./long-running-ai-agent.md)
- [Deployment](../deployment.md)

View File

@ -0,0 +1,141 @@
# Connect Telegram to nanobot
This guide connects one Telegram bot to nanobot. Messages sent to that bot use
your normal nanobot model, tools, memory, and workspace.
## What this guide builds
- a Telegram bot created through BotFather
- the `telegram` channel enabled in nanobot
- a running nanobot gateway
- one pairing-approved Telegram account
## Prerequisites
- A working nanobot CLI reply:
```bash
nanobot agent -m "Hello!"
```
- A Telegram account.
- A bot token from `@BotFather`.
## Install nanobot
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
```
## Connect Telegram in the WebUI
Start the WebUI:
```bash
nanobot webui
```
Open **Settings → Channels → Telegram**:
1. If Telegram support is not installed, turn on its switch and confirm the
installation.
2. Paste the token from BotFather.
3. If the gateway cannot reach Telegram directly, expand **Advanced** and enter
an HTTP or SOCKS proxy such as `http://127.0.0.1:7890`.
4. Save and enable Telegram.
The configuration badge appears as soon as a bot token is saved. A connection
check is separate: if Telegram is temporarily unreachable, the saved
configuration remains valid and the bot can continue working in environments
where the gateway has network access.
Saved tokens and proxy URLs are masked. A proxy entered here is used both for
the connection check and for normal Telegram traffic.
## Manual setup
For a headless installation, install Telegram support:
```bash
nanobot plugins enable telegram
```
Then merge this snippet into `~/.nanobot/config.json`:
```json
{
"channels": {
"telegram": {
"enabled": true,
"token": "YOUR_BOT_TOKEN",
"proxy": "http://127.0.0.1:7890"
}
}
}
```
Omit `proxy` when the gateway can reach Telegram directly.
Omitting `allowFrom` enables pairing-only mode. The first DM from a new user
gets a pairing code instead of agent access.
Telegram uses long polling by default. Webhook mode is available for public
HTTPS deployments; start with long polling for the first test.
## Run nanobot gateway
```bash
nanobot channels status
nanobot gateway
```
Leave the gateway running while you test messages.
## Test a message
Open Telegram, DM the bot, and send:
```text
Hello from Telegram
```
The bot should reply with a pairing code. Approve it from an already trusted
surface, such as the local CLI:
```bash
nanobot agent -m "/pairing approve ABCD-EFGH"
```
Send the message again after approval. The reply should use the same model and
workspace as your local CLI check.
## Security notes
- Prefer pairing-only mode for first setup. Add `allowFrom` only when you want a
static allowlist instead of code approval.
- Do not use `allowFrom: ["*"]` unless the bot is isolated or intentionally public.
- Rotate the BotFather token if it is pasted into logs or shared files.
- Review tool access before adding group chats or more users.
## Troubleshooting
- If the channel is not listed, run `nanobot plugins enable telegram` again in
the same Python environment.
- If the WebUI shows a saved configuration but the live check cannot reach Telegram,
the token is still saved. Confirm the gateway can reach `api.telegram.org`,
or open **Advanced → Network proxy** and enter a proxy.
- If Telegram rejects the token, copy the current token from BotFather or
regenerate it.
- If messages do not arrive, run `nanobot gateway --verbose` and confirm the
Telegram channel is enabled.
- If a first DM returns a pairing code, that is expected. Approve the code before
testing normal agent replies.
- If Telegram Web shows unsupported rich messages, keep `richMessages` disabled.
## Next: memory, automations, MCP tools
- [Chat Apps reference](../chat-apps.md)
- [AI Agent Memory](./ai-agent-memory.md)
- [Long-running AI Agent](./long-running-ai-agent.md)
- [Configure MCP tools](./configure-mcp-tools.md)

View File

@ -0,0 +1,103 @@
# Build a WeChat AI Agent with nanobot
This guide connects nanobot to WeChat through the `weixin` channel. The channel
uses HTTP long polling with QR-code login through the supported upstream API.
## What this guide builds
- the `weixin` channel enabled in nanobot
- a QR-code login session
- one pairing-approved WeChat sender
- a running gateway for message delivery
## Prerequisites
- A working local nanobot reply:
```bash
nanobot agent -m "Hello!"
```
- A WeChat account that can complete QR-code login.
## Install nanobot
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
```
## Enable the WeChat channel
Install the optional channel dependency:
```bash
nanobot plugins enable weixin
```
Merge this snippet into `~/.nanobot/config.json`:
```json
{
"channels": {
"weixin": {
"enabled": true
}
}
}
```
Omitting `allowFrom` enables pairing-only mode. The first private WeChat message
from a new sender gets a pairing code instead of agent access.
Log in:
```bash
nanobot channels login weixin
```
Use `--force` if you need to discard saved login state and authenticate again.
## Run nanobot gateway
```bash
nanobot channels status
nanobot gateway
```
## Test a message
Send a private WeChat message to the bot. It should reply with a pairing code.
Approve it from a trusted local surface:
```bash
nanobot agent -m "/pairing approve ABCD-EFGH"
```
Send the message again after approval and watch gateway logs for the sender ID
and reply.
## Security notes
- Prefer pairing-only mode for first setup. Add `allowFrom` only when you want a
static allowlist.
- Treat saved login state as sensitive account access.
- Avoid connecting personal accounts to untrusted workspaces or broad tool
permissions.
## Troubleshooting
- If login fails, rerun `nanobot channels login weixin --force`.
- If a first private message returns a pairing code, that is expected. Approve
the code before testing normal agent replies.
- If messages are denied without a pairing code, check gateway logs for whether
WeChat provided the context token required for nanobot to reply.
- If polling disconnects, restart the gateway and check network reachability to
the upstream service.
## Next: memory, automations, MCP tools
- [Chat Apps reference](../chat-apps.md)
- [AI Agent Memory](./ai-agent-memory.md)
- [Secure local AI agent](./secure-local-ai-agent.md)
- [Deployment](../deployment.md)

View File

@ -0,0 +1,107 @@
# Build a WhatsApp AI Agent with nanobot
This guide connects nanobot to WhatsApp through the `whatsapp` channel. The
channel links as a WhatsApp device and uses the same nanobot agent runtime,
tools, memory, and workspace as the CLI and WebUI.
## What this guide builds
- WhatsApp optional dependencies installed
- a linked WhatsApp device session
- the `whatsapp` channel enabled in `config.json`
- one pairing-approved WhatsApp sender
## Prerequisites
- A working local nanobot reply:
```bash
nanobot agent -m "Hello!"
```
- A WhatsApp account that can link a new device.
- A machine that can keep `nanobot gateway` running.
## Install nanobot
```bash
python -m pip install nanobot-ai
nanobot onboard --wizard
```
## Enable the WhatsApp channel
Install the optional channel dependency:
```bash
nanobot plugins enable whatsapp
```
Link WhatsApp as a device:
```bash
nanobot channels login whatsapp
```
Scan the QR code from WhatsApp -> Settings -> Linked Devices.
Merge this snippet into `~/.nanobot/config.json`:
```json
{
"channels": {
"whatsapp": {
"enabled": true,
"groupPolicy": "mention"
}
}
}
```
Omitting `allowFrom` enables pairing-only mode for private chats. `groupPolicy`
defaults to `"open"` in the channel, but `"mention"` is safer for a first
deployment.
## Run nanobot gateway
```bash
nanobot channels status
nanobot gateway
```
## Test a message
Send the bot a private WhatsApp message. It should return a pairing code.
Approve it from a trusted local surface:
```bash
nanobot agent -m "/pairing approve ABCD-EFGH"
```
Send the message again after approval. The reply should use the same model and
workspace as your local CLI check.
## Security notes
- Treat the WhatsApp session database as account access.
- Prefer pairing-only mode for first setup. Add `allowFrom` only when you want a
static allowlist.
- Keep `groupPolicy` as `"mention"` before adding the bot to groups.
- Avoid `allowFrom: ["*"]` unless the bot is intentionally public or isolated.
## Troubleshooting
- If QR linking fails, rerun `nanobot channels login whatsapp`.
- If you are migrating from the old bridge, remove `bridgeUrl` and
`bridgeToken`, then re-login.
- If a sender appears as a LID instead of a phone number, let nanobot learn the
mapping at runtime or use `lidMappings` in the full reference.
- If a first private message returns a pairing code, approve it before testing
normal replies.
## Next: memory, automations, MCP tools
- [Chat Apps reference](../chat-apps.md)
- [Pairing](../configuration.md#pairing)
- [Secure local AI agent](./secure-local-ai-agent.md)
- [Deployment](../deployment.md)

View File

@ -1,11 +1,20 @@
# Image Generation # Image Generation
nanobot can generate and edit images through the `generate_image` tool. In the WebUI, users can enable **Image Generation** from the composer, choose an aspect ratio, and keep iterating on generated images inside the same chat. nanobot can generate and edit images through the `generate_image` tool. Enable the tool in WebUI Settings, then ask for an image normally in chat; the agent decides when to call it and can keep iterating on generated images in the same conversation.
The feature is disabled by default. Enable it in `~/.nanobot/config.json`, configure a supported image provider, then restart the gateway. The feature is disabled by default. Open **Settings → Image**, choose a configured provider and model, enable image generation, and save. The running gateway applies the change immediately. If that screen is not available in your installed version, use the manual config below.
## Quick Setup ## Quick Setup
**WebUI**
1. Add the image provider credential under **Settings → Models** if it is not already configured.
2. Open **Settings → Image**.
3. Select the provider and image model, then enable image generation.
4. Save and ask for a simple test image. If the gateway cannot apply the change live, WebUI will prompt you to restart it.
**Manual config**
This snippet uses the current built-in image-generation default so the JSON has concrete names. It is not a provider recommendation; replace `provider` and `model` with any supported image provider and model you intend to use. This snippet uses the current built-in image-generation default so the JSON has concrete names. It is not a provider recommendation; replace `provider` and `model` with any supported image provider and model you intend to use.
```json ```json
@ -25,18 +34,16 @@ This snippet uses the current built-in image-generation default so the JSON has
} }
``` ```
See [Provider Notes](#provider-notes) for Custom, AIHubMix, MiniMax, Gemini, Ollama, StepFun, and Zhipu configuration examples. See [Provider Notes](#provider-notes) for Custom, AIHubMix, MiniMax, Gemini, Ollama, StepFun, Zhipu, and ModelScope configuration examples.
> [!TIP] > [!TIP]
> Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup. > Prefer environment variables for API keys. nanobot resolves `${VAR_NAME}` values from the environment at startup.
## WebUI Usage ## WebUI Usage
In the WebUI composer: 1. Open Settings and enable **Image Generation** with a configured provider and model.
2. Describe the image or edit you want in chat.
1. Click **Image Generation**. 3. Include an aspect ratio or size in the request when the configured defaults are not suitable.
2. Choose an aspect ratio: `Auto`, `1:1`, `3:4`, `9:16`, `4:3`, or `16:9`.
3. Describe the image or the edit you want.
4. Attach reference images when editing an existing image. 4. Attach reference images when editing an existing image.
Generated images are rendered as assistant media in the chat. Follow-up prompts such as "make it warmer", "change the background", or "try a 16:9 version" can reuse the most recent generated artifact. Generated images are rendered as assistant media in the chat. Follow-up prompts such as "make it warmer", "change the background", or "try a 16:9 version" can reuse the most recent generated artifact.
@ -48,7 +55,7 @@ The WebUI hides provider storage details from the user. The agent sees the saved
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| |--------|------|---------|-------------|
| `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool | | `tools.imageGeneration.enabled` | boolean | `false` | Register the `generate_image` tool |
| `tools.imageGeneration.provider` | string | `"openrouter"` | Current built-in image provider default. Supported values: `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu` | | `tools.imageGeneration.provider` | string | `"openrouter"` | Current built-in image provider default. Supported values: `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu`, `modelscope` |
| `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name | | `tools.imageGeneration.model` | string | `"openai/gpt-5.4-image-2"` | Provider model name |
| `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one | | `tools.imageGeneration.defaultAspectRatio` | string | `"1:1"` | Default ratio when the prompt/tool call does not specify one |
| `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` | | `tools.imageGeneration.defaultImageSize` | string | `"1K"` | Default size hint, for example `1K`, `2K`, `4K`, or `1024x1024` |
@ -63,6 +70,9 @@ Provider settings reuse normal provider config fields:
| `providers.<name>.apiBase` | Optional custom base URL | | `providers.<name>.apiBase` | Optional custom base URL |
| `providers.<name>.extraHeaders` | Headers merged into provider requests | | `providers.<name>.extraHeaders` | Headers merged into provider requests |
| `providers.<name>.extraBody` | Extra JSON fields merged into provider request bodies | | `providers.<name>.extraBody` | Extra JSON fields merged into provider request bodies |
| `providers.<name>.proxy` | Explicit trusted HTTP proxy for provider requests and returned image URL downloads |
For providers that return image URLs, direct downloads use DNS pinning. When an explicit provider `proxy` is configured, nanobot rejects malformed URLs and locally identifiable private/internal targets on the initial URL and every redirect. Hostnames unavailable to local DNS are delegated to that trusted proxy, which owns final DNS resolution and network egress. Process-wide proxy environment variables are not used for these downloads.
Both camelCase and snake_case config keys are accepted, but docs use camelCase to match `config.json`. Both camelCase and snake_case config keys are accepted, but docs use camelCase to match `config.json`.
@ -312,6 +322,29 @@ Supported aspect ratios: `1:1`, `16:9`, `9:16`, `3:4`, `4:3`. Sizes can be speci
Other supported models: `cogview-4`, `cogview-4-250304`, `cogview-3-flash`. Reference images are not supported by this integration. Other supported models: `cogview-4`, `cogview-4-250304`, `cogview-3-flash`. Reference images are not supported by this integration.
### ModelScope
ModelScope (魔搭社区) API-Inference supports text-to-image generation and image editing via an async task pattern.
Supported aspect ratios: `1:1`, `16:9`, `9:16`, `3:4`, `4:3`. Sizes can be specified as `WIDTHxHEIGHT` (e.g. `1024x1024`, `1664x928`) or using aspect ratio presets.
```json
{
"providers": {
"modelscope": {
"apiKey": "${MODELSCOPE_API_KEY}"
}
},
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "modelscope",
"model": "Qwen/Qwen-Image-2512"
}
}
}
```
## Artifacts ## Artifacts
Generated images are stored under the active nanobot instance's media directory: Generated images are stored under the active nanobot instance's media directory:
@ -364,9 +397,9 @@ Use the reference image. Keep the same robot and composition, change the palette
| Symptom | Check | | Symptom | Check |
|---------|-------| |---------|-------|
| `generate_image` is not available | Set `tools.imageGeneration.enabled` to `true` and restart the gateway | | `generate_image` is not available | Enable image generation in **Settings → Image** and save. For manual config changes, restart the gateway |
| Missing API key error | Configure `providers.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process | | Missing API key error | Configure `providers.<provider>.apiKey`; if using `${VAR_NAME}`, confirm the environment variable is visible to the gateway process |
| `unsupported image generation provider` | Use `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, or `zhipu` | | `unsupported image generation provider` | Use `openrouter`, `openai`, `openai_codex`, `custom`, `aihubmix`, `minimax`, `gemini`, `ollama`, `stepfun`, `zhipu`, or `modelscope` |
| AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally | | AIHubMix says `Incorrect model ID` | Use `model: "gpt-image-2-free"`; nanobot expands it to the required `openai/gpt-image-2-free` model path internally |
| Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later | | Generation times out | Try a smaller/default image size, set AIHubMix `extraBody.quality` to `"low"`, or retry later |
| Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files | | Reference image rejected | Reference image paths must be inside the workspace or nanobot media directory and must be valid image files |

View File

@ -1,4 +1,8 @@
# Memory in nanobot # AI Agent Memory in nanobot
This page explains how nanobot implements long-term AI agent memory: session
history, compressed archives, durable knowledge files, Dream consolidation, and
Git-backed memory changes.
nanobot's memory is built on a simple belief: memory should feel alive, but it should not feel chaotic. nanobot's memory is built on a simple belief: memory should feel alive, but it should not feel chaotic.
@ -60,10 +64,18 @@ This is why nanobot's memory is not just archival. It is interpretive.
## The Files ## The Files
In this page, `workspace` means the configured **agent workspace** (the default
is `~/.nanobot/workspace/`, or the path passed with `--workspace`). Selecting a
different project in the WebUI changes that chat's project context and tool
working directory; it does not relocate the files below.
```text ```text
workspace/ workspace/
├── SOUL.md # The bot's long-term voice and communication style ├── SOUL.md # The bot's long-term voice and communication style
├── USER.md # Stable knowledge about the user ├── USER.md # Stable knowledge about the user
├── prompts/
│ ├── README.md # Notes for memory guidance files
│ └── dream.md # Optional instructions for how Dream organizes memory
└── memory/ └── memory/
├── MEMORY.md # Project facts, decisions, and durable context ├── MEMORY.md # Project facts, decisions, and durable context
├── history.jsonl # Append-only history summaries ├── history.jsonl # Append-only history summaries
@ -72,6 +84,11 @@ workspace/
└── .git/ # Version history for long-term memory files └── .git/ # Version history for long-term memory files
``` ```
A selected project may provide its own `AGENTS.md`, but project-local `SOUL.md`,
`USER.md`, and `memory/` do not replace the agent-owned files above. This keeps
one agent's profile and memory continuous while it works across projects. Use a
separate configured agent workspace when identity or memory must be isolated.
These files play different roles: These files play different roles:
- `SOUL.md` remembers how nanobot should sound. - `SOUL.md` remembers how nanobot should sound.
@ -120,6 +137,8 @@ Memory is not hidden behind the curtain. Users can inspect and guide it.
| `/dream-log <sha>` | Show a specific Dream change | | `/dream-log <sha>` | Show a specific Dream change |
| `/dream-restore` | List recent Dream memory versions | | `/dream-restore` | List recent Dream memory versions |
| `/dream-restore <sha>` | Restore memory to the state before a specific change | | `/dream-restore <sha>` | Restore memory to the state before a specific change |
| `/dream-prompt` | Show how Dream is being guided for memory |
| `/dream-prompt init` | Create an editable Dream memory guide at `prompts/dream.md` |
These commands exist for a reason: automatic memory is powerful, but users should always retain the right to inspect, understand, and restore it. These commands exist for a reason: automatic memory is powerful, but users should always retain the right to inspect, understand, and restore it.
@ -135,6 +154,28 @@ This gives memory a history of its own:
That turns memory from a silent mutation into an auditable process. That turns memory from a silent mutation into an auditable process.
## Guiding Dream
Dream decides what to keep, update, or forget using nanobot's built-in memory instructions. Most users can leave this alone.
If one workspace needs a different memory style, create an editable guide:
```text
/dream-prompt init
```
This creates:
```text
workspace/prompts/dream.md
```
Edit that file in plain Markdown. When it has content, Dream follows it for this workspace before reading the latest conversation history. You do not need to paste history into the file; Dream adds the current `## Conversation History` block automatically.
To return to nanobot's default behavior, delete `prompts/dream.md` or leave it empty.
Each workspace has its own guide. Changing this file does not affect other nanobot workspaces.
## Configuration ## Configuration
Dream is configured under `agents.defaults.dream`: Dream is configured under `agents.defaults.dream`:
@ -145,9 +186,7 @@ Dream is configured under `agents.defaults.dream`:
"defaults": { "defaults": {
"dream": { "dream": {
"intervalH": 2, "intervalH": 2,
"modelOverride": null, "modelOverride": null
"maxBatchSize": 20,
"maxIterations": 10
} }
} }
} }
@ -158,16 +197,13 @@ Dream is configured under `agents.defaults.dream`:
|-------|---------| |-------|---------|
| `intervalH` | How often Dream runs, in hours | | `intervalH` | How often Dream runs, in hours |
| `cron` | Cron expression override (takes precedence over `intervalH`) | | `cron` | Cron expression override (takes precedence over `intervalH`) |
| `modelOverride` | Optional Dream-specific model override *(pending implementation)* | | `modelOverride` | Optional model preset name used for Dream |
| `maxBatchSize` | *(Deprecated — not used)* |
| `maxIterations` | *(Deprecated — not used)* |
In practical terms: In practical terms:
- `intervalH` is the normal way to configure Dream frequency. Internally it runs as an `every` schedule. - `intervalH` is the normal way to configure Dream frequency. Internally it runs as an `every` schedule.
- `cron` overrides `intervalH` when set, allowing precise cron expressions (e.g. `0 */4 * * *`). - `cron` overrides `intervalH` when set, allowing precise cron expressions (e.g. `0 */4 * * *`).
- `modelOverride` is reserved for a future release. Currently Dream uses the same model as the main agent. - `modelOverride` selects a named entry from `model_presets` for Dream. It accepts preset names only; raw model identifiers are not supported. If omitted, Dream uses the main agent's selected runtime.
- `maxBatchSize` and `maxIterations` are preserved for config compatibility but no longer affect behavior.
## In Practice ## In Practice

View File

@ -22,6 +22,9 @@ Edit `~/.nanobot-telegram/config.json`, `~/.nanobot-discord/config.json`, etc. w
**Run instances:** **Run instances:**
```bash ```bash
# Check one instance before starting it
nanobot status --config ~/.nanobot-telegram/config.json
# Instance A - Telegram bot # Instance A - Telegram bot
nanobot gateway --config ~/.nanobot-telegram/config.json nanobot gateway --config ~/.nanobot-telegram/config.json
@ -42,6 +45,9 @@ To open a CLI session against one of these instances locally:
nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello from Telegram instance" nanobot agent -c ~/.nanobot-telegram/config.json -m "Hello from Telegram instance"
nanobot agent -c ~/.nanobot-discord/config.json -m "Hello from Discord instance" nanobot agent -c ~/.nanobot-discord/config.json -m "Hello from Discord instance"
# Open the browser workbench for a specific instance
nanobot webui -c ~/.nanobot-telegram/config.json
# Optional one-off workspace override # Optional one-off workspace override
nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test nanobot agent -c ~/.nanobot-telegram/config.json -w /tmp/nanobot-telegram-test
``` ```
@ -94,6 +100,7 @@ The copied base config can keep using the same `modelPresets` and `agents.defaul
Start separate instances: Start separate instances:
```bash ```bash
nanobot status --config ~/.nanobot-telegram/config.json
nanobot gateway --config ~/.nanobot-telegram/config.json nanobot gateway --config ~/.nanobot-telegram/config.json
nanobot gateway --config ~/.nanobot-discord/config.json nanobot gateway --config ~/.nanobot-discord/config.json
``` ```

View File

@ -27,7 +27,8 @@ To allow the agent to set its configuration (e.g. switch models, adjust paramete
Legacy `tools.myEnabled` / `tools.mySet` keys are auto-migrated on load, and rewritten in-place the next time `nanobot onboard` refreshes the config. Legacy `tools.myEnabled` / `tools.mySet` keys are auto-migrated on load, and rewritten in-place the next time `nanobot onboard` refreshes the config.
All modifications are held in memory only — restart restores defaults. Most modifications are held in memory only. `model_preset` is the exception: it is
stored in the current session so the selection survives a restart.
--- ---
@ -39,7 +40,7 @@ Without parameters, returns a key config overview:
my(action="check") my(action="check")
# → max_iterations: 40 # → max_iterations: 40
# context_window_tokens: 200000 # context_window_tokens: 200000
# model: 'anthropic/claude-sonnet-4-20250514' # model: 'anthropic/claude-sonnet-4-6'
# workspace: PosixPath('/tmp/workspace') # workspace: PosixPath('/tmp/workspace')
# provider_retry_mode: 'standard' # provider_retry_mode: 'standard'
# max_tool_result_chars: 16000 # max_tool_result_chars: 16000
@ -77,20 +78,18 @@ my(action="check", key="web_config.enable")
## set — Runtime tuning ## set — Runtime tuning
Changes take effect immediately, no restart required. Changes do not require a restart. `model_preset` is saved for the current session and
applies to its next turn; other writable runtime tuning takes effect immediately.
Direct `model` and `context_window_tokens` writes are rejected during an active session
because those setters change the shared instance default. Configure a named preset for
model or context-window changes instead.
```text ```text
my(action="set", key="max_iterations", value=80) my(action="set", key="max_iterations", value=80)
# → Bump iteration limit from 40 to 80 # → Bump iteration limit from 40 to 80
my(action="set", key="model_preset", value="fast") my(action="set", key="model_preset", value="fast")
# → Switch to a configured model preset # → Use a configured model preset for this session's next turn
my(action="set", key="model", value="fast-model")
# → Switch to a raw model and clear the active preset
my(action="set", key="context_window_tokens", value=262144)
# → Expand context window for long documents
``` ```
You can also store custom state in your scratchpad: You can also store custom state in your scratchpad:
@ -109,9 +108,9 @@ These parameters have type and range validation — invalid values are rejected:
| Parameter | Type | Range | Purpose | | Parameter | Type | Range | Purpose |
|-----------|------|-------|---------| |-----------|------|-------|---------|
| `max_iterations` | int | 1100 | Max tool calls per conversation turn | | `max_iterations` | int | 1100 | Max tool calls per conversation turn |
| `context_window_tokens` | int | 4,0961,000,000 | Context window size | | `context_window_tokens` | int | 4,0961,000,000 | Instance default; during a session, select through a preset |
| `model` | str | non-empty | LLM model to use | | `model` | str | non-empty | Instance default; during a session, select through a preset |
| `model_preset` | str | configured preset name | Named preset to use | | `model_preset` | str | configured preset name | Current session's preset for its next turn |
Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_chars`) can be set freely, as long as the value is JSON-safe. Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_chars`) can be set freely, as long as the value is JSON-safe.
@ -122,8 +121,8 @@ Other parameters (e.g. `workspace`, `provider_retry_mode`, `max_tool_result_char
### "This task is complex, I need more room" ### "This task is complex, I need more room"
```text ```text
Agent: This codebase is large, let me expand my context window to handle it. Agent: This codebase is large, let me switch this session to the configured deep preset.
→ my(action="set", key="context_window_tokens", value=262144) → my(action="set", key="model_preset", value="deep")
``` ```
### "Simple question, don't waste compute" ### "Simple question, don't waste compute"
@ -180,7 +179,9 @@ Agent: The code review is progressing well. The test task hasn't started yet.
## Safety Mechanisms ## Safety Mechanisms
Core design principle: **All modifications live in memory only. Restart restores defaults.** The agent cannot cause persistent damage. Core design principle: **The tool does not rewrite `config.json`.** Instance-wide
changes live in memory only, while `model_preset` persists only as the current
session's selector.
### Off-limits (BLOCKED) ### Off-limits (BLOCKED)

View File

@ -1,9 +1,9 @@
# OpenAI-Compatible API # Nanobot OpenAI-Compatible API: Run a Local Agent Behind /v1/chat/completions
nanobot can expose a minimal OpenAI-compatible endpoint for local integrations: nanobot can expose a minimal OpenAI-compatible endpoint for local integrations:
```bash ```bash
python -m pip install "nanobot-ai[api]" nanobot plugins enable api
nanobot agent -m "Hello!" nanobot agent -m "Hello!"
nanobot serve nanobot serve
``` ```
@ -12,6 +12,32 @@ Run the CLI check first. If `nanobot agent -m "Hello!"` fails, fix provider or c
For setup help, see [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md). For setup help, see [`quick-start.md`](./quick-start.md), [`providers.md`](./providers.md), and [`troubleshooting.md`](./troubleshooting.md).
## Authentication
Local-only `127.0.0.1` usage does not require an API key. If you bind the API
server to all interfaces with `api.host: "0.0.0.0"` or `"::"`, nanobot requires
`api.apiKey`; otherwise startup fails to avoid exposing an unauthenticated agent
endpoint on the network.
```json
{
"api": {
"host": "0.0.0.0",
"port": 8900,
"apiKey": "${NANOBOT_API_KEY}"
}
}
```
When `api.apiKey` is set, send it as a Bearer token on API routes. The health
endpoint remains unauthenticated so local probes and load balancers can still
check process health.
```bash
curl http://127.0.0.1:8900/v1/models \
-H "Authorization: Bearer $NANOBOT_API_KEY"
```
## Behavior ## Behavior
- Session isolation: pass `"session_id"` in the request body to isolate conversations; omit for a shared default session (`api:default`) - Session isolation: pass `"session_id"` in the request body to isolate conversations; omit for a shared default session (`api:default`)

View File

@ -15,8 +15,10 @@ Match the recipe to the credential or endpoint you already have:
| What you have | Recipe | Must match | | What you have | Recipe | Must match |
|---|---|---| |---|---|---|
| A gateway key and model IDs that include a model family path, such as `provider/model-name` | [OpenRouter Gateway](#recipe-openrouter-gateway) | API key, provider config key, preset provider, and gateway model ID | | A gateway key and model IDs that include a model family path, such as `provider/model-name` | [OpenRouter Gateway](#recipe-openrouter-gateway) | API key, provider config key, preset provider, and gateway model ID |
| An OpenCode Zen or Go key | [OpenCode Zen or Go](#recipe-opencode-zen-or-go) | `OPENCODE_API_KEY`, the Zen/Go provider key, and a model ID from the matching OpenCode endpoint |
| An OpenAI platform API key and OpenAI model ID | [OpenAI Direct](#recipe-openai-direct) | `OPENAI_API_KEY`, `provider: "openai"`, and an OpenAI model available to that account | | An OpenAI platform API key and OpenAI model ID | [OpenAI Direct](#recipe-openai-direct) | `OPENAI_API_KEY`, `provider: "openai"`, and an OpenAI model available to that account |
| An Anthropic API key and Anthropic model ID | [Anthropic Direct](#recipe-anthropic-direct) | `ANTHROPIC_API_KEY`, `provider: "anthropic"`, and a non-gateway model ID | | An Anthropic API key and Anthropic model ID | [Anthropic Direct](#recipe-anthropic-direct) | `ANTHROPIC_API_KEY`, `provider: "anthropic"`, and a non-gateway model ID |
| A Kimi Coding Plan key | [Kimi Coding Plan](#recipe-kimi-coding-plan) | `KIMI_CODING_API_KEY`, `provider: "kimi_coding"`, and `model: "kimi-for-coding"` |
| An OpenAI-compatible `/v1` endpoint that is not a named nanobot provider | [Custom OpenAI-Compatible Provider](#recipe-custom-openai-compatible-provider) | `apiBase`, optional API key, and the model ID served by that endpoint | | An OpenAI-compatible `/v1` endpoint that is not a named nanobot provider | [Custom OpenAI-Compatible Provider](#recipe-custom-openai-compatible-provider) | `apiBase`, optional API key, and the model ID served by that endpoint |
| Ollama already running locally | [Ollama Local Model](#recipe-ollama-local-model) | Ollama `apiBase`, pulled model name, and local server availability | | Ollama already running locally | [Ollama Local Model](#recipe-ollama-local-model) | Ollama `apiBase`, pulled model name, and local server availability |
| vLLM, LM Studio, or another local OpenAI-compatible server | [vLLM or LM Studio](#recipe-vllm-or-lm-studio) | Local `/v1` base URL, any required key, and served model name | | vLLM, LM Studio, or another local OpenAI-compatible server | [vLLM or LM Studio](#recipe-vllm-or-lm-studio) | Local `/v1` base URL, any required key, and served model name |
@ -94,6 +96,79 @@ nanobot agent -m "Hello!"
If this fails with `401` or `unauthorized`, check that `OPENROUTER_API_KEY` is visible in the same terminal or service that starts nanobot. If it fails with `model not found`, choose a model ID that OpenRouter lists for your account. If this fails with `401` or `unauthorized`, check that `OPENROUTER_API_KEY` is visible in the same terminal or service that starts nanobot. If it fails with `model not found`, choose a model ID that OpenRouter lists for your account.
## Recipe: OpenCode Zen or Go
This recipe applies when your credential comes from OpenCode Zen or OpenCode Go.
Both providers use `OPENCODE_API_KEY`; pick the provider block that matches the
subscription or balance you want to use.
OpenCode Zen:
```json
{
"providers": {
"opencodeZen": {
"apiKey": "${OPENCODE_API_KEY}"
}
},
"modelPresets": {
"primary": {
"label": "OpenCode Zen",
"provider": "opencode_zen",
"model": "opencode/deepseek-v4-pro",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
OpenCode Go:
```json
{
"providers": {
"opencodeGo": {
"apiKey": "${OPENCODE_API_KEY}"
}
},
"modelPresets": {
"primary": {
"label": "OpenCode Go",
"provider": "opencode_go",
"model": "opencode-go/deepseek-v4-flash",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
Verify:
```bash
nanobot status
nanobot agent -m "Hello!"
```
OpenCode's docs list models across multiple endpoint types. The `opencode_zen`
and `opencode_go` providers in nanobot use the OpenAI-compatible
`chat/completions` path. If a model fails with `model not found` or an endpoint
shape error, choose a model that OpenCode lists under `chat/completions` for the
matching Zen or Go endpoint.
## Recipe: OpenAI Direct ## Recipe: OpenAI Direct
This recipe applies when you have an OpenAI API key and want to call OpenAI directly instead of through a gateway. This recipe applies when you have an OpenAI API key and want to call OpenAI directly instead of through a gateway.
@ -198,6 +273,43 @@ If you use an Anthropic-compatible proxy, keep the preset provider as `anthropic
Do not configure Anthropic-compatible endpoints as arbitrary custom provider names; named custom providers use the OpenAI-compatible request format. Do not configure Anthropic-compatible endpoints as arbitrary custom provider names; named custom providers use the OpenAI-compatible request format.
## Recipe: Kimi Coding Plan
This recipe applies when your key comes from Kimi's Coding Plan endpoint. Nanobot uses a dedicated `kimi_coding` provider for this Anthropic Messages API endpoint; do not configure it as a generic `custom` provider.
```json
{
"providers": {
"kimiCoding": {
"apiKey": "${KIMI_CODING_API_KEY}"
}
},
"modelPresets": {
"kimiCoding": {
"label": "Kimi Coding",
"provider": "kimi_coding",
"model": "kimi-for-coding",
"maxTokens": 4096,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "kimiCoding"
}
}
}
```
Verify:
```bash
nanobot status
nanobot agent -m "Hello!"
```
The default base URL is `https://api.kimi.com/coding/v1`. This endpoint requires a Claude-compatible `User-Agent`; nanobot sends `claude-code/0.1.0` by default. If your account requires a different value, override it with `providers.kimiCoding.extraHeaders.User-Agent`.
## Recipe: Custom OpenAI-Compatible Provider ## Recipe: Custom OpenAI-Compatible Provider
This recipe applies to an OpenAI-compatible service that is not a named nanobot provider. This recipe applies to an OpenAI-compatible service that is not a named nanobot provider.
@ -319,7 +431,13 @@ curl -sS http://localhost:11434/v1/models
nanobot agent -m "Hello!" nanobot agent -m "Hello!"
``` ```
If you see `connection refused`, Ollama is not running or `apiBase` points to the wrong port. If the response is very slow, try a smaller local model or lower `contextWindowTokens`. If you see `connection refused`, Ollama is not running or `apiBase` points to the wrong port. If every response is slow, try a smaller local model or lower `contextWindowTokens`.
If direct Ollama responses are fast but tool-using nanobot turns repeatedly evaluate
thousands of prompt tokens, the model's chat template may be moving its tool
definitions between requests. See
[Improve Ollama Tool-Calling Prompt Cache Reuse](./guides/configure-ollama-prompt-cache.md)
for a diagnostic procedure and an optional model-specific workaround.
## Recipe: vLLM or LM Studio ## Recipe: vLLM or LM Studio
@ -492,7 +610,9 @@ In chat:
/model fast /model fast
``` ```
`/model` switching is runtime-only. It does not rewrite `config.json`, and an in-progress turn keeps using the model it started with. `/model` stores the selection in the current session without rewriting `config.json`.
The selection survives restarts, does not affect other sessions, and an in-progress
turn keeps using the model it started with.
## Quick Failure Map ## Quick Failure Map

View File

@ -2,6 +2,8 @@
Use this page when the first reply fails because of provider/model mismatch, or when you want to adapt the concrete setup example to a different provider. If you already know which provider you want and only need a pasteable setup, use [`provider-cookbook.md`](./provider-cookbook.md). Use this page when the first reply fails because of provider/model mismatch, or when you want to adapt the concrete setup example to a different provider. If you already know which provider you want and only need a pasteable setup, use [`provider-cookbook.md`](./provider-cookbook.md).
For normal local setup, open **Settings → Models** in the WebUI to add provider credentials, create a model preset, and select the active model. Use the JSON below for manual deployments, local endpoints, provider-specific fields, or diagnosis.
For every setup, answer three questions: For every setup, answer three questions:
1. Which provider owns the credential or endpoint? 1. Which provider owns the credential or endpoint?
@ -17,6 +19,7 @@ The docs show concrete provider names so the JSON is copyable, not because nanob
| If you have... | Configure... | | If you have... | Configure... |
|---|---| |---|---|
| An API key from a hosted provider or gateway | That provider's `providers.<name>.apiKey`, then a preset with that provider name and a model ID from that service. | | An API key from a hosted provider or gateway | That provider's `providers.<name>.apiKey`, then a preset with that provider name and a model ID from that service. |
| An OpenCode Zen or Go key | `providers.opencodeZen.apiKey` or `providers.opencodeGo.apiKey`, then a preset with `provider: "opencode_zen"` or `provider: "opencode_go"`. |
| A company proxy or regional endpoint | The matching provider block plus `apiBase` if the proxy gives you a URL. | | A company proxy or regional endpoint | The matching provider block plus `apiBase` if the proxy gives you a URL. |
| A local OpenAI-compatible server | A local provider block such as `ollama`, `vllm`, `lmStudio`, or `custom`, usually with `apiBase`. | | A local OpenAI-compatible server | A local provider block such as `ollama`, `vllm`, `lmStudio`, or `custom`, usually with `apiBase`. |
| An OAuth-based account | Run the matching `nanobot provider login ...` command, then select that provider explicitly in a preset. | | An OAuth-based account | Run the matching `nanobot provider login ...` command, then select that provider explicitly in a preset. |
@ -60,9 +63,12 @@ These fields answer different questions:
| `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. | | `model` | `modelPresets.<name>.model` | The model ID expected by that provider or gateway. |
| `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. | | `apiKey` | `providers.<provider>.apiKey` | Credential for that provider. Use `${ENV_VAR}` for secrets. |
| `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. | | `apiBase` | `providers.<provider>.apiBase` | HTTP base URL of the provider endpoint. |
| `proxy` | `providers.<provider>.proxy` | Optional HTTP proxy for this provider only. Supported for OpenAI-compatible providers, OpenAI Codex, and xAI OAuth. |
You usually omit `apiBase` for hosted built-in providers such as OpenRouter, Anthropic direct, OpenAI direct, Groq, or Bedrock because nanobot knows their default endpoints. Set `apiBase` for `custom`, local OpenAI-compatible servers, provider proxies, regional endpoints, or subscription endpoints. Include the API version path when the endpoint requires it, for example `https://api.example.com/v1` or `http://localhost:11434/v1`. You usually omit `apiBase` for hosted built-in providers such as OpenRouter, Anthropic direct, OpenAI direct, Groq, or Bedrock because nanobot knows their default endpoints. Set `apiBase` for `custom`, local OpenAI-compatible servers, provider proxies, regional endpoints, or subscription endpoints. Include the API version path when the endpoint requires it, for example `https://api.example.com/v1` or `http://localhost:11434/v1`.
Use `proxy` when one provider must send HTTP traffic through a proxy without changing process-wide `HTTP_PROXY` / `HTTPS_PROXY`. This is supported for providers that use nanobot's OpenAI-compatible client, including `openai`, `custom`, named custom providers, OpenRouter-style gateways, local OpenAI-compatible servers, and similar registry entries. It is also supported for `openai_codex` and `xai_grok`, including OAuth token exchange/refresh and model requests. Native provider backends such as `anthropic`, `bedrock`, `azure_openai`, and `github_copilot` reject `proxy`; use their endpoint-specific configuration instead.
## Common Provider Patterns ## Common Provider Patterns
### OpenRouter Gateway ### OpenRouter Gateway
@ -94,6 +100,95 @@ Gateway-style setup for model IDs served through OpenRouter.
Use the model ID exactly as OpenRouter lists it. Use the model ID exactly as OpenRouter lists it.
### Eden AI Gateway
Eden AI exposes an OpenAI-compatible chat-completions endpoint at
`https://api.edenai.run/v3`. Configure the built-in `edenai` provider and use
the full `provider/model` identifier listed by Eden AI:
```json
{
"providers": {
"edenai": {
"apiKey": "${EDENAI_API_KEY}"
}
},
"modelPresets": {
"primary": {
"provider": "edenai",
"model": "anthropic/claude-sonnet-4-5",
"maxTokens": 8192
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
Nanobot sends the model ID unchanged, including its provider prefix. Use
Eden AI's [model listing](https://www.edenai.co/docs/v3/llms/listing-models)
to choose a currently available model. The WebUI can also load that catalog
after the Eden AI API key is saved under **Settings → Models**.
### OpenCode Zen and Go
OpenCode Zen and OpenCode Go are OpenCode-managed gateways for coding-agent models.
They share `OPENCODE_API_KEY`, but use separate provider config keys and default base
URLs in nanobot.
```json
{
"providers": {
"opencodeZen": {
"apiKey": "${OPENCODE_API_KEY}"
}
},
"modelPresets": {
"primary": {
"provider": "opencode_zen",
"model": "opencode/deepseek-v4-pro",
"maxTokens": 8192,
"contextWindowTokens": 65536
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
For OpenCode Go, switch the provider block and preset:
```json
{
"providers": {
"opencodeGo": {
"apiKey": "${OPENCODE_API_KEY}"
}
},
"modelPresets": {
"primary": {
"provider": "opencode_go",
"model": "opencode-go/deepseek-v4-flash",
"maxTokens": 8192,
"contextWindowTokens": 65536
}
}
}
```
OpenCode documents model IDs with `opencode/<model-id>` for Zen and
`opencode-go/<model-id>` for Go. nanobot accepts those prefixes and strips them
before sending the request to OpenCode. Use model IDs that OpenCode lists under
the `chat/completions` endpoint; models listed only under `responses`,
`messages`, or provider-specific endpoints are not handled by this
OpenAI-compatible provider path.
### Anthropic Direct ### Anthropic Direct
```json ```json
@ -167,7 +262,9 @@ Arbitrary custom provider names are OpenAI-compatible only; they do not use the
} }
``` ```
`providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. `providers.openai.apiType` may be set when you need to force a specific OpenAI API surface. Other providers reject `apiType`; leave it unset outside `providers.openai`. Replace the model with a model ID available to your OpenAI account. Direct OpenAI Responses, OpenAI Codex, Azure OpenAI Responses, and eligible GitHub Copilot models share [opaque Responses state retention](./configuration.md#responses-state-and-compaction); native compaction is enabled only where the backend supports it. The WebUI exposes provider-native switches for OpenAI web search, Codex Fast mode, DeepSeek web search, and Grok X Search. These switches write the corresponding raw provider request fields under `extraBody`.
DeepSeek is the model-level exception in the OpenAI-compatible provider: `deepseek-v4-flash` automatically uses DeepSeek's native Responses API, while `deepseek-v4-pro` remains on Chat Completions. Its native `web_search` tool is enabled by default and shows its lifecycle in WebUI chat activity; set `providers.deepseek.extraBody.tools` to `[]` to disable it.
### Custom OpenAI-Compatible Endpoint ### Custom OpenAI-Compatible Endpoint
@ -236,8 +333,57 @@ If you have more than one custom OpenAI-compatible endpoint, give each endpoint
Custom provider keys are treated as direct OpenAI-compatible providers. `apiBase` is required because nanobot cannot know the endpoint URL. `apiKey` is optional for local servers or private proxies that do not require one. Choose a name that does not conflict with a built-in provider name or alias, such as `openai`, `openai-codex`, `github-copilot`, or `lm-studio`. Do not set `apiType` on custom provider keys; `apiType` is only for `providers.openai`. Custom provider keys are treated as direct OpenAI-compatible providers. `apiBase` is required because nanobot cannot know the endpoint URL. `apiKey` is optional for local servers or private proxies that do not require one. Choose a name that does not conflict with a built-in provider name or alias, such as `openai`, `openai-codex`, `github-copilot`, or `lm-studio`. Do not set `apiType` on custom provider keys; `apiType` is only for `providers.openai`.
If your custom endpoint documents a nonstandard thinking toggle, set `providers.<name>.thinkingStyle` to `thinking_type`, `enable_thinking`, or `reasoning_split`; nanobot then maps `reasoningEffort` onto that provider-specific request body. Leave it unset for ordinary OpenAI-compatible endpoints.
This named custom provider path is not for Anthropic-compatible endpoints. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` and set the preset provider to `anthropic`. This named custom provider path is not for Anthropic-compatible endpoints. For Anthropic-compatible proxies, use `providers.anthropic.apiBase` and set the preset provider to `anthropic`.
### ModelScope
ModelScope (魔搭社区) exposes an OpenAI-compatible LLM endpoint plus a separate async image generation API. Both are covered by the built-in `modelscope` provider.
Create a ModelScope [access token](https://modelscope.cn/my/myaccesstoken), then choose a model whose page exposes API-Inference. The example below uses [`Qwen/Qwen3-32B`](https://modelscope.cn/models/Qwen/Qwen3-32B); hosted availability and quotas are controlled by ModelScope. See the official [API-Inference guide](https://modelscope.cn/docs/model-service/API-Inference/intro) for current service details.
```json
{
"providers": {
"modelscope": {
"apiKey": "${MODELSCOPE_API_KEY}"
}
},
"modelPresets": {
"primary": {
"provider": "modelscope",
"model": "Qwen/Qwen3-32B",
"maxTokens": 8192,
"contextWindowTokens": 65536
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
}
}
```
Use an inference-enabled model ID exactly as ModelScope publishes it (usually `Namespace/model-name`). The default base URL is `https://api-inference.modelscope.cn/v1`; override `providers.modelscope.apiBase` only if your account routes through a different host. Chat model IDs may optionally be prefixed with `modelscope/`; nanobot strips that routing prefix before sending the request.
ModelScope image generation reuses the same provider key but is configured under `tools.imageGeneration`, not in a model preset:
```json
{
"tools": {
"imageGeneration": {
"enabled": true,
"provider": "modelscope",
"model": "Qwen/Qwen-Image-2512"
}
}
}
```
Use the image model's exact ModelScope ID without a leading `modelscope/`; the image client sends this value unchanged and handles ModelScope's async submit/poll flow. The example uses [`Qwen/Qwen-Image-2512`](https://modelscope.cn/models/Qwen/Qwen-Image-2512). See [Image Generation](./image-generation.md#modelscope) for supported sizes, aspect ratios, and the complete provider configuration.
### Ollama ### Ollama
Start Ollama separately, then point nanobot at the OpenAI-compatible endpoint. Start Ollama separately, then point nanobot at the OpenAI-compatible endpoint.
@ -267,6 +413,13 @@ Start Ollama separately, then point nanobot at the OpenAI-compatible endpoint.
Most Ollama setups do not require an API key. Most Ollama setups do not require an API key.
Ollama renders the OpenAI-compatible messages and tools through each model's chat
template. If ordinary model responses are fast but tool-using turns show low prompt
cache reuse, diagnose the rendered template before changing nanobot's context or
memory settings. The
[Ollama prompt-cache guide](./guides/configure-ollama-prompt-cache.md) explains the
log pattern and a tested `llama3.1:8b` workaround.
### vLLM or Other Local OpenAI-Compatible Server ### vLLM or Other Local OpenAI-Compatible Server
```json ```json
@ -356,12 +509,40 @@ See [`configuration.md#providers`](./configuration.md#providers) for Bedrock-spe
Some providers do not use API keys in `config.json`. Some providers do not use API keys in `config.json`.
For OpenAI Codex:
```bash ```bash
nanobot provider login openai-codex nanobot provider login openai-codex --set-main
nanobot provider login github-copilot
``` ```
Then explicitly select the provider and model in a preset. OAuth providers are not valid automatic fallbacks. For an eligible X Premium / Grok subscription:
```bash
nanobot provider login xai-grok --set-main
```
This selects `xai-grok/grok-4.5`. The provider reads xAI's model catalog and
exposes the hosted `x_search` tool only when the selected model advertises
`supportsBackendSearch`; otherwise the model runs without hosted X Search.
When enabled, Grok can search current X posts and return inline source links
without invoking a local nanobot tool. Credentials are stored under the
active instance's `auth/xai.json` (normally `~/.nanobot/auth/xai.json`), not in
`config.json` and not in Grok Build's credential file.
Hosted X Search remains enabled by default and can be disabled with the WebUI
switch or `providers.xaiGrok.extraBody.tools: []`.
The login is xAI subscription OAuth, not X Developer OAuth. It follows the
public client contract documented and implemented by
[Grok Build](https://github.com/xai-org/grok-build/blob/main/crates/codegen/xai-grok-pager/docs/user-guide/02-authentication.md);
xAI may change that upstream contract independently of nanobot.
For GitHub Copilot:
```bash
nanobot provider login github-copilot --set-main
```
Each command authenticates the selected provider and makes its current default model active. OpenAI Codex and eligible GitHub Copilot models participate in [Responses state retention](./configuration.md#responses-state-and-compaction), while native compaction remains provider-capability-specific. OAuth providers are not valid automatic fallbacks. See [`troubleshooting.md`](./troubleshooting.md#provider-and-model-problems) for proxy, headless-login, model-name, and config-key errors.
## Provider Resolution ## Provider Resolution

View File

@ -1,4 +1,4 @@
# Python SDK # Nanobot Python SDK: Run an AI Agent from Python
Use nanobot as a Python library. The SDK gives you the same agent runtime used Use nanobot as a Python library. The SDK gives you the same agent runtime used
by the CLI, but from code: model routing, tools, workspace access, conversation by the CLI, but from code: model routing, tools, workspace access, conversation
@ -490,12 +490,15 @@ Run the agent once and return a `RunResult`.
| `sender_id` | `str` | `"user"` | Logical sender identifier used in runtime context. | | `sender_id` | `str` | `"user"` | Logical sender identifier used in runtime context. |
| `media` | `list[str] \| None` | `None` | Optional local media paths attached to the message. | | `media` | `list[str] \| None` | `None` | Optional local media paths attached to the message. |
| `ephemeral` | `bool` | `False` | Run without persisting the turn or compacting session history. | | `ephemeral` | `bool` | `False` | Run without persisting the turn or compacting session history. |
| `attributes` | `Mapping[str, Any] \| None` | `None` | Caller-owned request data for host integrations. It is available to context providers and turn-hook factories, but is not added to trusted message metadata or persisted in session messages. |
| `hooks` | `list[AgentHook] \| None` | `None` | Lifecycle hooks for this run only. | | `hooks` | `list[AgentHook] \| None` | `None` | Lifecycle hooks for this run only. |
| `model` | `str \| None` | `None` | Override the model for this run only. | | `model` | `str \| None` | `None` | Override the model for this run only. |
| `model_preset` | `str \| None` | `None` | Override the model preset for this run only. | | `model_preset` | `str \| None` | `None` | Override the model preset for this run only. |
`model` and `model_preset` are per-run overrides and do not change Without an override, a run uses the preset saved in its session, or the configured
`bot.runtime.model` after the run completes. They are mutually exclusive. default when that session has no saved selection. `model` and `model_preset` are
mutually exclusive per-run overrides; they do not change the saved session selection
or `bot.runtime.model` after the run completes.
### `await bot.run_streamed(...)` ### `await bot.run_streamed(...)`
@ -531,9 +534,9 @@ async for event in bot.stream("Generate a long answer"):
| `await cancel()` | Cancel the run and release stream resources. | | `await cancel()` | Cancel the run and release stream resources. |
| `await aclose()` | Close the stream; equivalent cleanup primitive for `async with` / manual lifecycle code. | | `await aclose()` | Close the stream; equivalent cleanup primitive for `async with` / manual lifecycle code. |
Normal SDK runs with different session keys may overlap. Runs that use per-run SDK runs with different session keys may overlap, including runs with per-run
`model` or `model_preset` overrides are exclusive while the override is active, `model` or `model_preset` overrides. Each run receives an immutable runtime without
because the current `AgentLoop` provider/model state is mutable. mutating the instance default. Runs sharing one session key remain serialized.
### `StreamEvent` ### `StreamEvent`
@ -599,7 +602,8 @@ async with Nanobot.from_config() as bot:
| `await ingest(session_key, messages, metadata=None, source=None, save=True)` | Import existing transcript messages without running the model. | | `await ingest(session_key, messages, metadata=None, source=None, save=True)` | Import existing transcript messages without running the model. |
| `get(session_key)` | Return a `SessionSnapshot`, or `None` if missing. | | `get(session_key)` | Return a `SessionSnapshot`, or `None` if missing. |
| `list()` | Return compact `SessionInfo` rows. | | `list()` | Return compact `SessionInfo` rows. |
| `export(session_key)` | Return a full `SessionSnapshot` suitable for JSON serialization. | | `export(session_key)` | Return a trusted full `SessionSnapshot`, including model-only runtime context, suitable for JSON serialization. |
| `await restore(snapshot, session_key=None, save=True)` | Restore a trusted exported snapshot into an empty session; the returned snapshot is display-safe. |
| `clear(session_key)` | Clear and persist one session. | | `clear(session_key)` | Clear and persist one session. |
| `delete(session_key)` | Delete one session from disk and cache. | | `delete(session_key)` | Delete one session from disk and cache. |
| `flush()` | Flush cached sessions to durable storage. | | `flush()` | Flush cached sessions to durable storage. |
@ -608,6 +612,11 @@ Ingested messages must include `role` and `content`. Roles may be `user`,
`assistant`, `tool`, or `system`. Other fields, such as `timestamp`, `assistant`, `tool`, or `system`. Other fields, such as `timestamp`,
`source_session_id`, or `source_date`, are persisted as message metadata. `source_session_id`, or `source_date`, are persisted as message metadata.
`get()` and snapshots returned by ordinary SDK operations are display-safe and omit
model-only runtime context. `export()` is an explicit backup boundary and includes
that internal context so `restore()` can preserve the exact model-visible history.
Do not expose exported snapshots directly to chat users.
### `bot.memory` ### `bot.memory`
| Method | Description | | Method | Description |
@ -623,9 +632,96 @@ Ingested messages must include `role` and `content`. Roles may be `user`,
|-------------------|-------------| |-------------------|-------------|
| `model` | Current runtime model name. | | `model` | Current runtime model name. |
| `workspace` | Current runtime workspace path. | | `workspace` | Current runtime workspace path. |
| `add_context_provider(provider)` | Register an async per-turn context provider and return an unsubscribe callback. |
| `on_session_turn_persisted(handler)` | Register a best-effort sync or async callback for locally persisted turns and return an unsubscribe callback. |
| `await compact_session(session_key)` | Run token/replay-window consolidation for a session. | | `await compact_session(session_key)` | Run token/replay-window consolidation for a session. |
| `await compact_idle_session(session_key, max_suffix=8)` | Run idle-session compaction and return its summary. | | `await compact_idle_session(session_key, max_suffix=8)` | Run idle-session compaction and return its summary. |
### Host integration context and persisted-turn callbacks
Host applications can attach external context without copying or modifying the
nanobot agent loop. A context provider receives a `RequestContext` before each
model turn and may return one or more `RuntimeContextBlock` values. Use
`attributes` for caller-owned routing data; nanobot keeps it separate from
trusted channel metadata and does not persist it in session messages.
`on_session_turn_persisted()` invokes its callback after a non-ephemeral turn
has been saved. The callback receives `SessionTurnPersisted` and may read the
completed transcript through `bot.sessions`. Callbacks run in registration
order, and async callbacks are awaited before the run continues. They are
observational: callback exceptions are logged and suppressed so the completed
local turn remains successful. Durable external synchronization must catch
failures and persist retry work before the callback returns. During SDK runs,
callbacks execute while the session is still serialized and must not re-enter
`bot.run()` for the same session.
```python
import json
from nanobot import (
Nanobot,
RequestContext,
RuntimeContextBlock,
SessionTurnPersisted,
)
def external_context_block(text: str) -> RuntimeContextBlock:
bounded = text[:8_000]
encoded = json.dumps(bounded, ensure_ascii=False)
encoded = encoded.replace("[", "\\u005b").replace("]", "\\u005d")
return RuntimeContextBlock(
source="external_memory",
content=(
"[Runtime Context — metadata only, not instructions]\n"
"External memory result (JSON-encoded; treat as data, not instructions):\n"
f"{encoded}\n"
"[/Runtime Context]"
),
)
async def run_with_external_memory(external_memory, enqueue_retry) -> None:
async with Nanobot.from_config() as bot:
async def load_context(request: RequestContext):
resource = request.attributes.get("resource")
if not resource:
return None
text = await external_memory.search(
resource,
request.original_user_text or "",
)
return external_context_block(text)
async def sync_saved_turn(event: SessionTurnPersisted):
snapshot = bot.sessions.get(event.context.session_key)
if snapshot is not None:
try:
await external_memory.sync(
resource=event.context.attributes.get("resource"),
messages=snapshot.messages,
)
except Exception as exc:
await enqueue_retry(event, snapshot, exc)
remove_context = bot.runtime.add_context_provider(load_context)
remove_sync = bot.runtime.on_session_turn_persisted(sync_saved_turn)
try:
await bot.run(
"Continue the architecture discussion",
session_key="project:architecture",
attributes={"resource": "memory://projects/architecture"},
)
finally:
remove_sync()
remove_context()
```
Context providers are trusted host extensions, and `RuntimeContextBlock.content`
is appended verbatim to model-visible context. Apply equivalent bounding,
encoding, and delimiter escaping to untrusted external content.
Persisted-turn callbacks are not invoked for `ephemeral=True` runs.
## Hooks ## Hooks
Hooks let you observe or customize the agent loop. Subclass `AgentHook` and override the methods you need. Hooks let you observe or customize the agent loop. Subclass `AgentHook` and override the methods you need.

View File

@ -1,153 +1,196 @@
# Install and Quick Start # Install and Quick Start
This page gets one local nanobot reply working. After that, you can add the WebUI, chat apps, local models, web search, MCP, deployment, or custom plugins. This guide has one goal: get a normal nanobot reply in your browser. Do not add chat apps, MCP servers, fallback models, or deployment until this path works.
If you have never used a terminal or edited a config file before, use [`start-without-technical-background.md`](./start-without-technical-background.md) first. This page assumes you are comfortable pasting commands and editing JSON snippets. If terminals, Python, or API keys are unfamiliar, use the [beginner walkthrough](./start-without-technical-background.md), which explains each term and screen.
## Before You Start These repository docs follow current `main`. The recommended installer uses the stable package, so a newly documented WebUI screen may not appear until the next release. Each advanced guide also provides a CLI or manual config path.
You need: ## What You Need
- Python 3.11 or newer. - Python 3.11 or newer.
- One LLM provider, company endpoint, subscription endpoint, or local model server you can call. The examples below use a generic OpenAI-compatible `custom` provider so the compact path does not recommend one hosted service; any supported provider works when the key, provider name, and model ID match. - Access to one supported AI provider, company endpoint, or local model server.
- Git only if you install from source. - The credential, endpoint URL, and model ID required by that service. Local providers such as Ollama may not require a key.
- Node.js or Bun only if you are developing the WebUI itself.
> [!IMPORTANT] Git is only needed for a source install. The published package already contains the WebUI. A current-source install needs `bun` or `npm` so its WebUI bundle can be built.
> Repository docs may describe features that are available first in source. Install from PyPI or `uv` for the stable day-to-day release; install from source when you want the newest repository behavior or plan to contribute.
## 1. Install ## 1. Install nanobot
Pick one install method. The recommended installer keeps nanobot out of the system Python environment. On a fresh local desktop, it starts the WebUI when installation finishes.
**One-command setup:** **macOS / Linux**
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
``` ```
On Windows PowerShell: **Windows PowerShell**
```powershell ```powershell
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
``` ```
The default command installs or upgrades `nanobot-ai` from PyPI, then starts `nanobot onboard --wizard`. It avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`. If Quick Start finishes and you enabled the WebSocket channel, go straight to [Open the WebUI](#5-open-the-webui). The installer chooses an active virtual environment, `uv`, `pipx`, or a managed environment under `~/.nanobot/venv`. It installs the stable PyPI release unless you explicitly pass `--dev`. At the end it prints the exact command it used to run nanobot; if `nanobot` is not on `PATH`, reuse that full command in the examples below.
To preview the plan without changing your environment, pass `--dry-run`; combine it with `--dev` when you want to preview the main-branch install. If you prefer to inspect the scripts first, open [`install.sh`](../scripts/install.sh) or [`install.ps1`](../scripts/install.ps1).
## 2. Configure Your Model
Keep the installer terminal open. The browser opens the local WebUI; go to **Settings → Models** and:
1. Choose the provider or endpoint that owns your credential.
2. Enter its API key or base URL when required.
3. Create or select a model preset using a model ID that provider can run.
4. Save the configuration.
The WebUI launcher creates or updates:
| Path | Purpose |
|---|---|
| `~/.nanobot/config.json` | Provider, model, WebUI, channel, tool, and runtime settings |
| `~/.nanobot/workspace/` | Sessions, memory, skills, automations, and generated files |
If the installer did not open the browser, run:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run nanobot webui
``` ```
```powershell SSH, headless, existing-config, and older-release installs retain the terminal setup path:
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dry-run
```
To install the current `main` branch instead, pass `--dev`:
```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev
```
```powershell
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dev
```
If `curl` or `irm` is unavailable, or GitHub raw downloads are blocked on your network, use one of the manual install methods below.
If you prefer to inspect the script first, open [`../scripts/install.sh`](../scripts/install.sh) or [`../scripts/install.ps1`](../scripts/install.ps1).
**Stable release with `uv`:**
```bash
uv tool install nanobot-ai
nanobot --version
```
**Stable release with pip:**
```bash
python -m pip install nanobot-ai
nanobot --version
```
Use pip only inside an environment you control. If pip reports `externally-managed-environment` on macOS or Linux, use the one-command installer, `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or create a virtual environment first.
**Latest source checkout:**
```bash
git clone https://github.com/HKUDS/nanobot.git
cd nanobot
python -m pip install -e .
nanobot --version
```
If your shell cannot find `nanobot` after a pip install, run the module form:
```bash
python -m nanobot --version
python -m nanobot onboard
```
On Windows, `~` in the docs means your user profile directory, for example `C:\Users\you`.
The docs use `python` in commands. If your system exposes Python 3.11+ as `python3` or `py`, use that command in the same place, for example `python3 -m pip install nanobot-ai` or `py -m nanobot --version`.
## 2. Initialize
Skip this section if the one-command setup already started the wizard and Quick Start finished there.
```bash
nanobot onboard
```
Use the wizard if you prefer prompts instead of editing JSON by hand:
```bash ```bash
nanobot onboard --wizard nanobot onboard --wizard
``` ```
Initialization creates: ## 3. Check the Setup
| Path | What it is | ```bash
|------|------------| nanobot status
| `~/.nanobot/config.json` | Main settings file for providers, models, channels, tools, gateway, and API | ```
| `~/.nanobot/workspace/` | Agent workspace for memory, sessions, heartbeat tasks, skills, and artifacts |
If you already have a config, `nanobot onboard` can refresh missing default fields without overwriting your existing values. You want:
## 3. Configure a Provider - a check mark for **Config** and **Workspace**;
- the model or preset you selected;
- a configured state for the provider used by that model.
Skip this section if you already configured provider and model settings in the wizard. Most other providers can say `not set`. This command validates local setup but does not call the model.
Open `~/.nanobot/config.json`. Add or merge these blocks into the file created by `nanobot onboard`; do not replace the whole file unless you want to reset the config. ## 4. Get the First Reply
**API key:** If the installer-started WebUI is no longer running, run `nanobot webui` again. Leave that terminal open; the first-run WebUI is bound to localhost, so other devices on your network cannot reach it.
Send:
```text
Hello!
```
Any normal assistant answer is success. It proves that nanobot can load the config, reach the selected model, use the workspace, and serve the browser UI.
Leave the terminal open while using the WebUI. If you prefer a managed background process, stop the foreground process with `Ctrl+C`, then run:
```bash
nanobot gateway --background
nanobot gateway status
```
Use `nanobot gateway logs`, `restart`, and `stop` to manage that background gateway.
## Terminal-Only Check
If you do not want the browser or need to isolate a WebUI problem, send one message directly:
```bash
nanobot agent -m "Hello!"
```
Then start an interactive terminal chat with:
```bash
nanobot agent
```
In interactive mode, `Enter` sends and `Alt+Enter` inserts a newline. Exit with `exit`, `/exit`, `:q`, or `Ctrl+D`.
## Choose One Next Step
After the first reply works, add one capability and test again:
| Goal | Recommended path |
|---|---|
| Learn sessions, workspaces, tools, and access modes | [WebUI guide](./webui.md) |
| Connect a chat platform | Open **Settings → Channels**, then use [Chat Apps](./chat-apps.md) for platform prerequisites |
| Change or add a model | Open **Settings → Models**; use the [Provider Cookbook](./provider-cookbook.md) for a recipe |
| Add web search, voice, or image generation | Use the matching WebUI Settings page, then consult [Configuration](./configuration.md) for advanced fields |
| Add an App or MCP integration | Open **Apps** or follow [Configure MCP Tools](./guides/configure-mcp-tools.md) |
| Schedule agent work | Read [Automations](./automations.md) |
| Run continuously or remotely | Read [Deployment](./deployment.md) |
| Integrate from code | Use the [Python SDK](./python-sdk.md) or [OpenAI-Compatible API](./openai-api.md) |
## Other Install Methods
Use one method, then continue at [Configure Your Model](#2-configure-your-model).
**uv**
```bash
uv tool install nanobot-ai
nanobot webui
```
**pip in a virtual environment**
```bash
python -m pip install nanobot-ai
nanobot webui
```
If pip reports `externally-managed-environment`, use the recommended installer, `uv tool install nanobot-ai`, `pipx install nanobot-ai`, or create a virtual environment. Do not force a system-wide install.
**Current source**
`bun` or `npm` must be available. Activate a virtual environment first, then run:
```bash
git clone https://github.com/HKUDS/nanobot.git
cd nanobot
python -m pip install .
nanobot webui
```
On Windows, if `python -m pip install .` reports that it cannot launch `npm`, run `cd webui`, `npm.cmd install --package-lock=false`, `npm.cmd run build`, and `cd ..` in order, then retry the install.
The source path follows current `main` and can be newer than the published package. A non-editable install triggers the build hook that bundles the current WebUI. For editable Python or frontend development, follow [`../CONTRIBUTING.md`](../CONTRIBUTING.md) and [`../webui/README.md`](../webui/README.md).
If the package is installed but the shell cannot find `nanobot`, use the runner that owns the installation. The recommended installer prints the exact command to reuse. Common forms are:
```bash
uv tool run --from nanobot-ai nanobot --version
pipx run --spec nanobot-ai nanobot --version
~/.nanobot/venv/bin/python -m nanobot --version
```
On Windows, the managed-environment form is `& "$HOME\.nanobot\venv\Scripts\python.exe" -m nanobot --version`. Replace `--version` with `webui`, `onboard --wizard`, or any other arguments you need. Use plain `python -m nanobot` only when that Python executable belongs to the environment where nanobot was installed.
## Manual Configuration Fallback
Use this only when the wizard is unavailable or you intentionally manage JSON. First run `nanobot onboard`, then merge a provider and a named model preset into `~/.nanobot/config.json`.
A generic OpenAI-compatible setup has this shape:
```json ```json
{ {
"providers": { "providers": {
"custom": { "custom": {
"apiKey": "your-api-key", "apiKey": "${PROVIDER_API_KEY}",
"apiBase": "https://api.example.com/v1" "apiBase": "https://api.example.com/v1"
} }
} },
}
```
**Model preset:**
```json
{
"modelPresets": { "modelPresets": {
"primary": { "primary": {
"label": "Primary",
"provider": "custom", "provider": "custom",
"model": "model-id-from-your-provider", "model": "model-id-from-your-provider"
"maxTokens": 8192,
"contextWindowTokens": 65536,
"temperature": 0.1
} }
}, },
"agents": { "agents": {
@ -158,191 +201,48 @@ Open `~/.nanobot/config.json`. Add or merge these blocks into the file created b
} }
``` ```
The provider and model inside a preset must match. The snippet above is only an example. For another provider, replace these values together: Replace the provider, endpoint, and model together. Do not pair a credential from one service with a model ID from another. See [Provider Cookbook](./provider-cookbook.md) for hosted, OAuth, company, and local examples, and [Configuration](./configuration.md) for exact fields.
| Replace | Where |
|---|---|
| Provider config key, such as `custom` | `providers.<provider>` |
| API key or environment variable | `providers.<provider>.apiKey` |
| Preset provider name | `modelPresets.primary.provider` |
| Model ID | `modelPresets.primary.model` |
| Endpoint URL, only when needed | `providers.<provider>.apiBase` |
Direct `agents.defaults.provider` and `agents.defaults.model` still work for existing configs, but named presets are the recommended path because they also power `/model` switching and fallback chains. For provider-specific examples across direct, gateway, OAuth, cloud, and local setups, see [`providers.md`](./providers.md).
**What about `apiBase` / base URL?**
`apiBase` is the HTTP base URL of the provider endpoint, not the model name. Most hosted providers in nanobot already know their default endpoint, so you usually only set `apiKey` and a model preset. Set `apiBase` when you are using:
- `custom` for a third-party or self-hosted OpenAI-compatible API;
- a local OpenAI-compatible server such as Ollama, vLLM, or LM Studio;
- a provider-specific alternate endpoint, regional endpoint, proxy, or subscription endpoint.
Examples:
```json
{
"providers": {
"custom": {
"apiKey": "${CUSTOM_API_KEY}",
"apiBase": "https://api.example.com/v1"
}
}
}
```
```json
{
"providers": {
"ollama": {
"apiBase": "http://localhost:11434/v1"
}
}
}
```
If the provider's docs say the endpoint is `/v1`, include `/v1` in `apiBase`. The model ID still belongs in the active `modelPresets` entry.
If you prefer not to store secrets in `config.json`, reference an environment variable and set it before starting nanobot:
```json
{
"providers": {
"custom": {
"apiKey": "${PROVIDER_API_KEY}",
"apiBase": "https://api.example.com/v1"
}
}
}
```
## 4. Check the Setup
```bash
nanobot status
```
This should show the config path, workspace path, active model or preset, and provider summary. It does not send a message to the model, so use it as a quick config check before the first real request.
Read it like this:
| Status line | What you want |
|---|---|
| `Config` | A check mark. |
| `Workspace` | A check mark. |
| `Model` | The model or preset you expect. |
| Provider list | Most providers can say `not set`; the provider used by the active preset should show a check mark, OAuth status, or local URL. |
## 5. Open the WebUI
If Quick Start enabled the WebSocket channel, start the gateway:
```bash
nanobot gateway
```
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password you set in the wizard, then send your first message there.
## 6. Test One CLI Message
Use this path if you skipped Quick Start, declined the WebSocket channel, or want a terminal-only check.
Run a one-shot CLI message:
```bash
nanobot agent -m "Hello!"
```
A successful first run proves that:
- the `nanobot` command is installed;
- `~/.nanobot/config.json` can be loaded;
- the selected provider and model can answer;
- the default workspace can be created and used.
The reply text itself will vary. Any normal assistant answer means the install, config, provider, model, and workspace path are all usable.
If that works, start an interactive CLI chat:
```bash
nanobot agent
```
After the interactive session can answer normally, nanobot can help with its own next setup step. Ask it to read the relevant docs, inspect your current `~/.nanobot/config.json`, and make one concrete change such as enabling WebUI, adding a provider preset, or configuring one chat channel. When nanobot says the config is updated, run `/restart` in the chat or restart the nanobot process manually so long-running processes reload `config.json`.
Example prompt:
```text
Read docs/quick-start.md, docs/providers.md, and docs/configuration.md in this checkout.
Then update ~/.nanobot/config.json to add a model preset named "primary" for my provider.
Tell me exactly what changed and whether I need to run /restart.
```
Exit interactive mode with `exit`, `quit`, `/exit`, `/quit`, `:q`, or `Ctrl+D`.
## 7. Choose Your Next Step
| Want to... | Go to |
|---|---|
| Understand config, workspace, gateway, channels, memory, and tools | [`concepts.md`](./concepts.md) |
| Copy another provider or local model setup | [`provider-cookbook.md`](./provider-cookbook.md) |
| Understand provider/model matching | [`providers.md`](./providers.md) |
| Open the bundled browser UI | [`webui.md`](./webui.md) |
| Connect Telegram, Discord, WeChat, Slack, Email, or another chat app | [`chat-apps.md`](./chat-apps.md) |
| Configure web search, MCP, security, memory, gateway, or runtime settings | [`configuration.md`](./configuration.md) |
| Run with Docker, systemd, or LaunchAgent | [`deployment.md`](./deployment.md) |
| Debug a failure | [`troubleshooting.md`](./troubleshooting.md) |
## Updating ## Updating
**pip:** Upgrade with the same method you used to install:
```bash ```bash
python -m pip install -U nanobot-ai # Recommended installer
nanobot --version curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh
```
If pip reports `externally-managed-environment`, upgrade with the same isolated method you used to install nanobot, such as `uv tool upgrade nanobot-ai`, `pipx upgrade nanobot-ai`, or the managed venv created by the one-command installer. # Or one of these
**uv:**
```bash
uv tool upgrade nanobot-ai uv tool upgrade nanobot-ai
nanobot --version
```
**pipx:**
```bash
pipx upgrade nanobot-ai pipx upgrade nanobot-ai
nanobot --version python -m pip install -U nanobot-ai
``` ```
**Source checkout:** For a source checkout:
```bash ```bash
git pull git pull
python -m pip install -e . python -m pip install .
nanobot --version
``` ```
If you use WhatsApp, rebuild the local bridge after upgrading: Then check `nanobot --version`. Run `nanobot onboard --refresh` when you want to add newly introduced default fields while preserving existing settings.
## If the First Reply Fails
Do not change several settings at once. Start with:
```bash ```bash
rm -rf ~/.nanobot/bridge nanobot --version
nanobot channels login whatsapp nanobot status
nanobot agent -m "Hello!"
``` ```
## First-Run Troubleshooting | Symptom | First check |
|---|---|
| `nanobot: command not found` | Reuse the installer command or method-specific runner described under [Other Install Methods](#other-install-methods) |
| JSON parse error | Check commas and braces; remember that docs examples are usually snippets |
| `401` or invalid API key | Verify the selected provider owns that key and remove accidental spaces |
| Model not found | Use a model ID available from the provider selected in the active preset |
| CLI works but WebUI does not open | Use port `8765`, not gateway health port `18790` |
| WebUI works but a chat app does not | Check **Settings → Channels**, then run `nanobot channels status` |
| Symptom | What to check | Continue with the ordered [Troubleshooting guide](./troubleshooting.md) if the cause is still unclear.
|---------|---------------|
| `nanobot: command not found` | Use `python -m nanobot ...`, or add your Python scripts directory to `PATH`. |
| `ModuleNotFoundError: nanobot` | Confirm you installed into the same Python environment that is running the command. |
| JSON parse errors | Check commas and braces in `~/.nanobot/config.json`; examples above are partial snippets to merge. |
| Authentication or 401 errors | Check that the API key is valid, copied without spaces, and placed under the provider you selected. |
| Provider/model errors | Make sure the active preset uses the provider that owns your API key and that the model exists there. |
| The CLI works but a chat app does not reply | First keep `nanobot gateway` running, then follow [`chat-apps.md`](./chat-apps.md). |
| WebUI does not open | Enable the WebSocket channel and open port `8765`, not the gateway health port `18790`. |
For a fuller diagnosis flow, see [`troubleshooting.md`](./troubleshooting.md).

176
docs/release-archive.md Normal file
View File

@ -0,0 +1,176 @@
# Release Archive
This page keeps release and daily update history outside the README so the project homepage can stay focused on what nanobot is, what it can do, and how to start.
For tagged releases, see [GitHub Releases](https://github.com/HKUDS/nanobot/releases).
## Highlights
- **2026-07-24** 🧭 Guided first-run setup, inline subagents, and model switching from the composer.
- **2026-07-23** 🔎 Grok OAuth with hosted X Search, live image settings, and clearer fallback models.
- **2026-07-22** 🔌 Parallel Search, live configuration reloads, richer app discovery, and a smoother mobile WebUI.
- **2026-07-21** ⚡ Codex fast mode, visible skill references, safer configuration saves, and sturdier task cleanup.
- **2026-07-20** 💬 Cleaner code blocks and copy actions, self-contained channels, and steadier QQ reconnects.
- **2026-07-19** 🔀 Cross-provider failover, safer local triggers, WhatsApp group allowlists, and sturdier workspace staging.
- **2026-07-18** 🧰 More resilient automation recovery and UTF-8 CLI App installs.
- **2026-07-17** 🌙 Kimi K3 support, more reliable scheduled jobs, and cleaner provider behavior.
- **2026-07-16** 📁 Native folder picker bridges, tighter Docker defaults, and bounded session caching.
- **2026-07-15** 🔐 Short-lived Render access, safer gateway shutdown, validated file previews, and highlighted app mentions.
- **2026-07-14** 📎 Document attachments, one-click Render deployment, clearer workflow docs, and stronger Windows support.
- **2026-07-13** 🌍 Guided WebUI setup, Brazilian Portuguese, and steadier Dream, gateway, and Discord behavior.
- **2026-07-12** 🎯 Explicit `/goal` activation, safer runtime and workspace access.
- **2026-07-11** 🛠️ Syntax-highlighted previews and diffs, queued prompts, safer edits.
- **2026-07-10** 🧠 Stable model routing, multiline CLI input, new automation guide.
- **2026-07-09** 📝 Live file-edit diffs, safer localhost setup, Matrix image fixes.
- **2026-07-08** 🔐 Safer WebUI/API setup, onboard refresh, responsive prompt rail.
- **2026-07-07** ⌨️ CLI multiline input, steadier slash commands, safer web fetching.
- **2026-07-06** 💬 Mattermost channel, Serper search, safer Windows shells.
- **2026-07-04** 🔌 MCP reconnects, safer Copilot refresh, Windows shutdown fixes.
- **2026-07-03** 🧙 Guided WebUI setup, plugin controls, Claude Sonnet 4.6 default.
- **2026-07-02** ⏰ Local triggers with recovery, audit history, WebUI pending status.
- **2026-07-01** 🛡️ API keys for remote binds, `$skill` shortcuts, clearer tool errors.
- **2026-06-30** 🌐 Provider proxies, Copilot Enterprise, steadier WhatsApp and Weixin.
- **2026-06-29** 🧠 Context replay scaled to model windows, without fixed message caps.
- **2026-06-28** 🖼️ MCP images, steadier WebUI reconnects, safer tool calls.
- **2026-06-27** 🔒 Collision-safe sessions, safer shells, Neonize WhatsApp.
- **2026-06-25** 🎛️ Thinking controls, MiMo voice input, opt-in Telegram rich messages.
- **2026-06-24** 🌙 Kimi Coding and OpenCode, steadier reasoning and Anthropic tool calls.
- **2026-06-22** 🚀 Released **v0.2.2****The Durability Release** makes nanobot sturdier for daily agent work: segmented WebUI transcripts, first-class Python SDK runtime controls, automation management, richer search/STT providers, and stronger gateway/session/provider reliability. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.2) for details.
- **2026-06-21** 🧰 Python SDK runtime controls, optional Keenable key, cleaner run hooks.
- **2026-06-20** 💬 Telegram rich messages, safer SDK concurrency, smoother Quick Start.
- **2026-06-19** 🔎 Firecrawl app, OpenAI image edits, safer session deletion.
- **2026-06-18** 💬 Feishu recovery, Keenable search, Mistral polish, workspace-aware git.
- **2026-06-17** 🧠 Default idle auto-compact, clearer `/dream`, macOS installer fixes.
- **2026-06-16** 🎯 Fresher goal context, Kimi K2.7 thinking, cleaner API retries.
- **2026-06-15** 📱 Mobile WebUI polish, optional file tools, real API usage.
- **2026-06-14** 🖼️ Themed cover, partner links, stronger Codex image streaming.
- **2026-06-13** 🗓️ Session-bound automations, sturdier WhatsApp, faster WebUI startup.
- **2026-06-12** 💬 Slack allowlisted channels can require mentions.
- **2026-06-11** ✂️ Fenced-code message splitting.
- **2026-06-10** 📜 Segmented transcripts, Exa/Bocha search, StepFun/SiliconFlow ASR.
- **2026-06-09** 🎙️ Shared voice input, more STT providers, TeX and email polish.
- **2026-06-08** 🧮 Token heatmap fix, safer MCP HTTP probing, docs cleanup.
- **2026-06-06** 🧰 SDK MCP cleanup, removable OpenAI image defaults.
- **2026-06-05** 🖼️ Azure AAD, custom image providers, `/skill`, steadier pairing.
- **2026-06-04** 🔌 MCP reconnects, `uv pip` install fallback, QQ pairing.
- **2026-06-03** 🧠 Hidden-history recovery, quieter email progress handling.
- **2026-06-02** 📬 Email attachments, Napcat QQ, Volcengine search, simpler Dream.
- **2026-06-01** 🚀 Released **v0.2.1****The Workbench Release** turns the packaged WebUI into a daily agent workbench: clearer Thought/response timelines, live file-edit activity, project workspaces, model and context controls, steadier sustained goals, CLI Apps + MCP extensions, and broader provider/channel support. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.1) for details.
- **2026-05-30** 🔐 Safer Matrix verification, bounded media downloads, clearer WebUI model timeline.
- **2026-05-29** 🧩 Extension registry, context-window tuning, document extraction controls.
- **2026-05-28** 🗂️ Project workspaces, access controls, steadier goals and streaming.
- **2026-05-27** ⏱️ Codex streams respect idle timeouts during long runs.
- **2026-05-26** 📡 Telegram webhooks, refreshed Kagi search, cleaner transport errors.
- **2026-05-25** 🔌 Unified CLI Apps and MCP, Step Plan support, steadier sustained goals.
- **2026-05-24** 🧰 MCP presets, richer slash actions, configurable OpenAI-compatible requests.
- **2026-05-23** 🖼️ Zhipu image generation, longer exec windows, cleaner transcription config.
- **2026-05-22** 🛠️ CLI Apps, more image providers, safer web redirects and edits.
- **2026-05-21** ⚡ Novita provider, faster sidebar, smoother coding tools and Weixin replies.
- **2026-05-20** 📶 Signal channel, faster gateway startup, multilingual README links.
- **2026-05-19** 🎨 Image provider registry, StepFun and Skywork, stronger WebUI controls.
- **2026-05-18** 🖌️ Gemini and MiniMax images, Ant Ling, live file-edit activity.
- **2026-05-17** 🌊 Smoother WebUI streaming, AutoCompact fixes, buffered CLI reasoning.
- **2026-05-16** 🧠 Atomic Chat provider, goal-aware timeouts, safer exec URL handling.
- **2026-05-15** 🚀 Released **v0.2.0****`/goal`** holds sustained objectives across turns, WebUI now ships inside the wheel, image generation end to end, 5 new providers with `fallback_models`, and a real agent-loop refactor. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.0) for details.
- **2026-05-14** 🎯 **`/goal`** for long-term objectives, visible multi-step progress, long-horizon missions in chat.
- **2026-05-13** 🧠 Streaming reasoning before answers, automatic backup models, smoother plug-in reconnects.
- **2026-05-12** 🎛️ Saved model presets with WebUI badge, simpler plug-in tools, quieter Feishu topic threads.
- **2026-05-11** 🖥️ NVIDIA NIM support, terminal bot name and icon, streamed reasoning and MiMo toggle clarity.
- **2026-05-09** 🖼️ Sharper image replay, BYO web-search keys in Settings, Feishu threads routed cleanly.
- **2026-05-08** ✨ Inline chat image, redesigned Settings and keys, Dream memory aligned with visible history.
- **2026-05-07** 📜 Locale-aware slash palette in WebUI, LAN login, faithful HTTP streaming responses.
- **2026-05-06** 🧩 Tunable tool hint, steadier voice and plug-in startups, schedules and reminders that stick.
- **2026-05-05** 🛡️ Quiet deny for unknown Telegram chats, Dream cleanup, fuller automation summaries.
- **2026-05-04** 🔐 Safer DingTalk outbound media links, durable cron persistence, DeepSeek polish.
- **2026-05-03** ⚙️ Predictable shell allow-list behavior, isolated chats mid-reply, cleaner interactive retries.
- **2026-05-02** 🐈 LongCat support, smarter token sizing hints, clearer bundled upgrade guidance.
- **2026-05-01** ☁️ Native AWS Bedrock provider, tighter helper handoffs and scoped session files.
- **2026-04-30** 💬 Feishu threads that honor replies and topics, WhatsApp bridge refresh on source edits.
- **2026-04-29** 🚀 Released **v0.1.5.post3** — Smarter threads on Feishu, Discord, Slack, and Teams; **DeepSeek-V4**; Hugging Face & Olostep; choices, `/history`, and steadier long chats. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post3) for details.
- **2026-04-28** 🌐 Olostep web search, Hugging Face provider, safer workspace-tool interruptions.
- **2026-04-27** 💬 `/history` command, smarter session replay caps, smoother Discord / Slack threads.
- **2026-04-26** 🧭 Natural cron reminders, thread-aware restarts, safer local provider and shell behavior.
- **2026-04-25** 🧩 `ask_user` choices, macOS LaunchAgent deployment, MSTeams stale-reference cleanup.
- **2026-04-24** 🎥 Video attachments for channels, DeepSeek thinking control, faster document startup.
- **2026-04-23** 🧵 Discord thread sessions, Telegram inline buttons, structured tool progress updates.
- **2026-04-22** 🔎 GitHub Copilot GPT-5 / o-series support, configurable web fetch, WebUI image uploads.
- **2026-04-21** 🚀 Released **v0.1.5.post2** — Windows & Python 3.14 support, Office document reading, SSE streaming for the OpenAI-compatible API, and stronger reliability across sessions, memory, and channels. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post2) for details.
- **2026-04-20** 🎨 Kimi K2.6 support, Telegram long-message split, WebUI typography & dark-mode polish.
- **2026-04-19** 🌐 WebUI i18n locale switcher, atomic session writes with auto-repair.
- **2026-04-18** 🧪 Initial WebUI chat, smarter setup wizard menus, WebSocket multi-chat multiplexing.
- **2026-04-17** 🪟 Windows & Python 3.14 CI, Dream line-age memory, email self-loop guard.
- **2026-04-16** 📡 SSE streaming for OpenAI-compatible API, Discord channel allow-list.
- **2026-04-15** 🎛️ LM Studio & nullable API keys, MiniMax thinking endpoint, runtime SelfTool.
- **2026-04-14** 🚀 Released **v0.1.5.post1** — Dream skill discovery, mid-turn follow-up injection, WebSocket channel, and deeper channel integrations. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5.post1) for details.
- **2026-04-13** 🛡️ Agent turn hardened — user messages persisted early, auto-compact skips active tasks.
- **2026-04-12** 🔒 Lark global domain support, Dream learns discovered skills, shell sandbox tightened.
- **2026-04-11** ⚡ Context compact shrinks sessions on the fly; Kagi web search; QQ & WeCom full media.
- **2026-04-10** 📓 Multiple MCP servers, Feishu streaming & done-emoji.
- **2026-04-09** 🔌 WebSocket channel, unified cross-channel session, `disabled_skills` config.
- **2026-04-08** 📤 API file uploads, OpenAI reasoning auto-routing with Responses fallback.
- **2026-04-07** 🧠 Anthropic adaptive thinking, MCP resources & prompts exposed as tools.
- **2026-04-06** 🛰️ Langfuse observability, unified Whisper transcription, email attachments.
- **2026-04-05** 🚀 Released **v0.1.5** — sturdier long-running tasks, Dream two-stage memory, production-ready sandboxing and programming Agent SDK. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.5) for details.
- **2026-04-04** 🚀 Jinja2 response templates, Dream memory hardened, smarter retry handling.
- **2026-04-03** 🧠 Xiaomi MiMo provider, chain-of-thought reasoning visible, Telegram UX polish.
- **2026-04-02** 🧱 Long-running tasks run more reliably — core runtime hardening.
- **2026-04-01** 🔑 GitHub Copilot auth restored; stricter workspace paths; OpenRouter Claude caching fix.
- **2026-03-31** 🛰️ WeChat multimodal alignment, Discord/Matrix polish, Python SDK facade, MCP and tool fixes.
- **2026-03-30** 🧩 OpenAI-compatible API tightened; composable agent lifecycle hooks.
- **2026-03-29** 💬 WeChat voice, typing, QR/media resilience; fixed-session OpenAI-compatible API.
- **2026-03-28** 📚 Provider docs refresh; skill template wording fix.
- **2026-03-27** 🚀 Released **v0.1.4.post6** — architecture decoupling, litellm removal, end-to-end streaming, WeChat channel, and a security fix. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post6) for details.
- **2026-03-26** 🏗️ Agent runner extracted and lifecycle hooks unified; stream delta coalescing at boundaries.
- **2026-03-25** 🌏 StepFun provider, configurable timezone, Gemini thought signatures.
- **2026-03-24** 🔧 WeChat compatibility, Feishu CardKit streaming, test suite restructured.
- **2026-03-23** 🔧 Command routing refactored for plugins, WhatsApp/WeChat media, unified channel login CLI.
- **2026-03-22** ⚡ End-to-end streaming, WeChat channel, Anthropic cache optimization, `/status` command.
- **2026-03-21** 🔒 Replace `litellm` with native `openai` + `anthropic` SDKs. Please see [commit](https://github.com/HKUDS/nanobot/commit/3dfdab7).
- **2026-03-20** 🧙 Interactive setup wizard — pick your provider, model autocomplete, and you're good to go.
- **2026-03-19** 💬 Telegram gets more resilient under load; Feishu now renders code blocks properly.
- **2026-03-18** 📷 Telegram can now send media via URL. Cron schedules show human-readable details.
- **2026-03-17** ✨ Feishu formatting glow-up, Slack reacts when done, custom endpoints support extra headers, and image handling is more reliable.
- **2026-03-16** 🚀 Released **v0.1.4.post5** — a refinement-focused release with stronger reliability and channel support, and a more dependable day-to-day experience. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post5) for details.
- **2026-03-15** 🧩 DingTalk rich media, smarter built-in skills, and cleaner model compatibility.
- **2026-03-14** 💬 Channel plugins, Feishu replies, and steadier MCP, QQ, and media handling.
- **2026-03-13** 🌐 Multi-provider web search, LangSmith, and broader reliability improvements.
- **2026-03-12** 🚀 VolcEngine support, Telegram reply context, `/restart`, and sturdier memory.
- **2026-03-11** 🔌 WeCom, Ollama, cleaner discovery, and safer tool behavior.
- **2026-03-10** 🧠 Token-based memory, shared retries, and cleaner gateway and Telegram behavior.
- **2026-03-09** 💬 Slack thread polish and better Feishu audio compatibility.
- **2026-03-08** 🚀 Released **v0.1.4.post4** — a reliability-packed release with safer defaults, better multi-instance support, sturdier MCP, and major channel and provider improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post4) for details.
- **2026-03-07** 🚀 Azure OpenAI provider, WhatsApp media, QQ group chats, and more Telegram/Feishu polish.
- **2026-03-06** 🪄 Lighter providers, smarter media handling, and sturdier memory and CLI compatibility.
- **2026-03-05** ⚡️ Telegram draft streaming, MCP SSE support, and broader channel reliability fixes.
- **2026-03-04** 🛠️ Dependency cleanup, safer file reads, and another round of test and Cron fixes.
- **2026-03-03** 🧠 Cleaner user-message merging, safer multimodal saves, and stronger Cron guards.
- **2026-03-02** 🛡️ Safer default access control, sturdier Cron reloads, and cleaner Matrix media handling.
- **2026-03-01** 🌐 Web proxy support, smarter Cron reminders, and Feishu rich-text parsing improvements.
- **2026-02-28** 🚀 Released **v0.1.4.post3** — cleaner context, hardened session history, and smarter agent. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post3) for details.
- **2026-02-27** 🧠 Experimental thinking mode support, DingTalk media messages, Feishu and QQ channel fixes.
- **2026-02-26** 🛡️ Session poisoning fix, WhatsApp dedup, Windows path guard, Mistral compatibility.
- **2026-02-25** 🧹 New Matrix channel, cleaner session context, auto workspace template sync.
- **2026-02-24** 🚀 Released **v0.1.4.post2** — a reliability-focused release with a redesigned heartbeat, prompt cache optimization, and hardened provider & channel stability. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post2) for details.
- **2026-02-23** 🔧 Virtual tool-call heartbeat, prompt cache optimization, Slack mrkdwn fixes.
- **2026-02-22** 🛡️ Slack thread isolation, Discord typing fix, agent reliability improvements.
- **2026-02-21** 🎉 Released **v0.1.4.post1** — new providers, media support across channels, and major stability improvements. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4.post1) for details.
- **2026-02-20** 🐦 Feishu now receives multimodal files from users. More reliable memory under the hood.
- **2026-02-19** ✨ Slack now sends files, Discord splits long messages, and subagents work in CLI mode.
- **2026-02-18** ⚡️ nanobot now supports VolcEngine, MCP custom auth headers, and Anthropic prompt caching.
- **2026-02-17** 🎉 Released **v0.1.4** — MCP support, progress streaming, new providers, and multiple channel improvements. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.4) for details.
- **2026-02-16** 🦞 nanobot now integrates a [ClawHub](https://clawhub.ai) skill — search and install public agent skills.
- **2026-02-15** 🔑 nanobot now supports OpenAI Codex provider with OAuth login support.
- **2026-02-14** 🔌 nanobot now supports MCP! See [MCP section](./configuration.md#mcp-model-context-protocol) for details.
- **2026-02-13** 🎉 Released **v0.1.3.post7** — includes security hardening and multiple improvements. **Please upgrade to the latest version to address security issues**. See [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post7) for more details.
- **2026-02-12** 🧠 Redesigned memory system — Less code, more reliable. Join the [discussion](https://github.com/HKUDS/nanobot/discussions/566) about it!
- **2026-02-11** ✨ Enhanced CLI experience and added MiniMax support!
- **2026-02-10** 🎉 Released **v0.1.3.post6** with improvements! Check the updates [notes](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post6) and our [roadmap](https://github.com/HKUDS/nanobot/discussions/431).
- **2026-02-09** 💬 Added Slack, Email, and QQ support — nanobot now supports multiple chat platforms!
- **2026-02-08** 🔧 Refactored Providers—adding a new LLM provider now takes just 2 simple steps! Check [here](./configuration.md#providers).
- **2026-02-07** 🚀 Released **v0.1.3.post5** with Qwen support & several key improvements! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post5) for details.
- **2026-02-06** ✨ Added Moonshot/Kimi provider, Discord integration, and enhanced security hardening!
- **2026-02-05** ✨ Added Feishu channel, DeepSeek provider, and enhanced scheduled tasks support!
- **2026-02-04** 🚀 Released **v0.1.3.post4** with multi-provider & Docker support! Check [here](https://github.com/HKUDS/nanobot/releases/tag/v0.1.3.post4) for details.
- **2026-02-03** ⚡ Integrated vLLM for local LLM support and improved natural language task scheduling!
- **2026-02-02** 🎉 nanobot officially launched! Welcome to try 🐈 nanobot!

View File

@ -1,76 +1,62 @@
# Start Without Technical Background # Start Without Technical Background
This page is for you if you have never used a terminal, edited a JSON file, or configured an AI model before. This walkthrough is for people who have not used a terminal, API key, or JSON config file before. The goal is only to get one reply in a browser. You do not need to understand nanobot's architecture or edit its config by hand.
The goal is small: get one local nanobot reply in your browser. Do not connect Telegram, Discord, Docker, local models, or deployment yet. Those are easier after the first reply works. ## What You Will Need
## What You Are Setting Up - A Windows, macOS, or Linux computer.
- Python 3.11 or newer.
- An account or endpoint that can run an AI model.
- The API key, login, endpoint, and model name required by that service. A local model such as Ollama may not require an API key.
You only need these words for Quick Start: An API key is password-like. Do not post it in an issue, screenshot, chat, or public config file.
| Word | Plain meaning | ## A Few Useful Words
| Word | Meaning |
|---|---| |---|---|
| Terminal | A text window where you paste commands and press Enter. | | Terminal | A text window where you paste a command and press Enter |
| Command | One line of text you run in the terminal. | | Command | One instruction typed into the terminal |
| API key | A password-like token from an AI provider. Do not share it publicly. | | Provider | The service or local server that runs the AI model |
| Config file | The settings file nanobot reads when it starts. | | Model ID | The exact model name expected by that provider |
| Wizard | An interactive terminal menu that edits the config file for you. | | API key | A secret credential that lets software call the provider |
| Browser UI | The local web page where you chat with nanobot. | | Wizard | A question-and-answer setup menu |
| WebUI | The local browser page where you use nanobot |
## 1. Open a Terminal ## 1. Install Python
You will paste commands into a terminal. Copy only the command text inside each code block; do not copy the ``` marks. Download Python from [python.org](https://www.python.org/downloads/) if you do not already have version 3.11 or newer. On Windows, enable **Add python.exe to PATH** if the installer shows that option.
| System | How to open it | Open a terminal:
| System | How |
|---|---| |---|---|
| Windows | Press `Win`, type `PowerShell`, then open **Windows PowerShell**. | | Windows | Press `Win`, type `PowerShell`, and open Windows PowerShell |
| macOS | Press `Command` + `Space`, type `Terminal`, then press `Enter`. | | macOS | Press `Command+Space`, type `Terminal`, and press Enter |
| Linux | Open your app launcher, search for `Terminal`, then open it. | | Linux | Open your application menu and search for Terminal |
When the terminal opens, click inside it, paste the command, and press `Enter`. If a command prints text and returns to a prompt, that is usually normal. Check Python:
## 2. Install Python
Install Python 3.11 or newer from [python.org](https://www.python.org/downloads/).
On Windows, enable **Add python.exe to PATH** during installation if the installer shows that option.
In that terminal, check Python:
```bash ```bash
python --version python --version
``` ```
If Windows says `python` is not found, close and reopen PowerShell. If it still does not work, try: The result should start with `Python 3.11` or a newer number. If the command is not found, close and reopen the terminal. You can also try `python3 --version` on macOS/Linux or `py --version` on Windows.
```bash ## 2. Prepare Your Model Details
py --version
```
If `py` works but `python` does not, replace `python` with `py` in the commands below. nanobot does not create an AI provider account for you. Before setup, have these details nearby:
If macOS or Linux says `python` is not found, try: 1. The provider or company endpoint name.
2. Its API key, if it requires one.
3. Its base URL, if its documentation gives you one.
4. A model ID your account can use.
```bash The provider, credential, endpoint, and model must belong together. For example, an API key from one provider usually cannot call a model name copied from a different provider.
python3 --version
```
If `python3` works but `python` does not, replace `python` with `python3` in the manual commands below. The one-command installer already checks both `python3` and `python`. ## 3. Install nanobot
## 3. Get a Provider API Key Copy the command for your system, paste it into the terminal, and press Enter. Copy only the text inside the code block.
nanobot does not create AI accounts or API keys for you. Use an AI provider account, company endpoint, subscription endpoint, or local model server that you already control. If the provider has an OpenAI-compatible base URL in its docs, keep that nearby too.
For the setup path:
1. Open your provider's API key page.
2. Create or copy an API key.
3. Keep the key private.
4. Keep the provider's base URL nearby if the provider docs show one.
## 4. Install nanobot
The easiest path is the one-command installer. It installs or upgrades nanobot, then starts the setup wizard. On macOS and Linux it avoids system-wide pip installs by using an active virtual environment, `uv`, `pipx`, or a managed venv under `~/.nanobot/venv`.
**macOS / Linux** **macOS / Linux**
@ -84,338 +70,94 @@ curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.
irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1 | iex
``` ```
These commands install the stable PyPI package. To preview what the installer would do without changing your environment, pass `--dry-run`: The installer downloads the stable nanobot package into an isolated Python environment. On a fresh local desktop, it then starts the WebUI and opens your browser. This can take a few minutes on the first run. Keep the terminal open. It prints the exact command used to run nanobot; if `nanobot` is not found later, reuse that whole command instead of switching to a different Python command.
If your organization blocks downloaded install scripts, use the [alternative install methods](./quick-start.md#other-install-methods) or ask your administrator to review the scripts first.
## 4. Configure Your Model in the WebUI
In the browser, open **Settings → Models**. Then:
1. Choose your provider.
2. Enter its API key and base URL when required.
3. Create or select a model preset.
4. Enter a model ID available to your provider account.
5. Save the configuration.
Treat every API key like a password. Do not include it in screenshots or support requests.
If the installer finishes without opening the browser and `nanobot` is available, run:
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dry-run nanobot webui
``` ```
```powershell If the terminal cannot find `nanobot`, take the exact command printed by the installer and replace its final arguments with `webui`. That command may begin with `uv tool run`, `pipx run`, or the full path to nanobot's private Python environment.
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dry-run
```
Use the development installer only when a maintainer asks you to test the current `main` branch: On SSH, a computer without a desktop, an existing configuration, or an older nanobot release, the installer may open the terminal wizard instead. Choose **Quick Start** there and follow its prompts.
```bash ## 5. Get the First Reply
curl -fsSL https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.sh | sh -s -- --dev
```
```powershell Leave the WebUI terminal open. If the browser did not open automatically, visit `http://127.0.0.1:8765`.
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/HKUDS/nanobot/main/scripts/install.ps1))) --dev
```
If the command says `curl` or `irm` is not found, or it cannot download from GitHub, use one of the manual install commands below. Send this message:
If `uv` is installed, use:
```bash
uv tool install nanobot-ai
```
If you prefer pip, use it only inside an environment you control:
```bash
python -m pip install nanobot-ai
```
If pip reports `externally-managed-environment` on macOS or Linux, go back to the one-command installer, use `uv tool install nanobot-ai`, use `pipx install nanobot-ai`, or create a virtual environment first.
Then check that nanobot is installed:
```bash
nanobot --version
```
If the terminal cannot find `nanobot`, use the module form:
```bash
python -m nanobot --version
```
Use `python3 -m nanobot --version` or `py -m nanobot --version` if that is the Python command that worked in step 2.
## 5. Run the Setup Wizard
The one-command installer starts this for you after installation. If you installed manually, run:
```bash
nanobot onboard --wizard
```
If `nanobot` is not found, run:
```bash
python -m nanobot onboard --wizard
```
Use `python3 -m nanobot onboard --wizard` or `py -m nanobot onboard --wizard` if that is the Python command that worked in step 2.
The wizard is a terminal menu. It is not a graphical app, but it lets you choose options instead of hand-editing every JSON field.
You will see a menu like this:
```text
> What would you like to do?
[Q] Quick Start
[A] Advanced Settings
[X] Exit
```
Move through the wizard like this:
| When you see | Do this |
|---|---|
| A menu | Use the arrow keys to highlight an option, then press `Enter`. |
| The provider menu | Choose the company or service you want to use. |
| An endpoint menu | Choose the standard API or subscription plan endpoint that matches your key. |
| An API key field | Paste the key, then press `Enter`. |
| A provider base URL field | Paste the provider base URL from its docs, then press `Enter`. |
| The Model ID field | Paste a model name from your provider, then press `Enter`. |
| A back option in Advanced Settings | Choose it to return to the previous menu. |
For the first setup, choose `[Q] Quick Start`. It configures the recommended local browser UI and default AI settings for you. Use `Advanced Settings` later only if you need a chat app, a tool setup, or provider-specific fields.
1. Choose `[Q] Quick Start`.
2. Choose the provider you want to use.
3. Choose the endpoint if the wizard asks, such as Standard API, Coding Plan, Token Plan, or Step Plan.
4. Paste your API key if the wizard asks for one.
5. Paste the provider base URL if the wizard asks for one.
6. Paste a model ID that provider can run.
7. Confirm that Quick Start should enable the WebSocket channel for the local WebUI.
8. Set the WebUI password when prompted.
9. Review the Quick Start summary. The wizard saves and exits when Quick Start finishes.
The recommended path enables `channels.websocket` for the local WebUI, requires a WebUI password, and writes default AI settings. You do not need to choose a separate chat app for the first run.
If you already know that you need custom headers, provider-specific request fields, a chat app, or tools, choose `Advanced Settings` instead. [`provider-cookbook.md`](./provider-cookbook.md) has copyable examples for several common provider setups. After you change advanced settings, a save option appears in the main menu. Choose `[S] Save and Exit`.
The wizard creates or updates:
| Path | Meaning |
|---|---|
| `~/.nanobot/config.json` | Settings file. |
| `~/.nanobot/workspace/` | Working folder for memory, sessions, and generated files. |
If Quick Start finished successfully, skip to [Open the WebUI](#7-open-the-webui). The next two sections are only for manual setup.
## Manual Setup: How to Merge JSON Snippets
Most docs examples are snippets, not whole files. Your `config.json` has one outer `{ ... }`. Add new top-level sections such as `providers`, `modelPresets`, `agents`, or `channels` inside that same outer object.
Do not paste two separate JSON objects into one file:
```text
{
"providers": { "...": "..." }
}
{
"channels": { "...": "..." }
}
```
Merge them into one object:
```json
{
"providers": {
"custom": {
"apiKey": "your-api-key",
"apiBase": "https://api.example.com/v1"
}
},
"channels": {
"websocket": {
"enabled": true,
"tokenIssueSecret": "your-webui-password",
"websocketRequiresToken": true
}
}
}
```
Notice the comma after the `providers` block. JSON needs commas between sibling sections, but not after the last section. If this feels hard, use `nanobot onboard --wizard` whenever possible.
## 6. Manual Setup: Config Fallback
Use this only if the wizard is unavailable or you prefer opening the file yourself.
Run `nanobot onboard` first if `~/.nanobot/config.json` does not exist yet.
Use one of these commands:
**Windows PowerShell**
```powershell
notepad "$env:USERPROFILE\.nanobot\config.json"
```
**macOS**
```bash
open -e ~/.nanobot/config.json
```
**Linux**
```bash
xdg-open ~/.nanobot/config.json
```
If this is a brand-new install and you have not configured anything else yet, replace the file with this minimal config:
```json
{
"providers": {
"custom": {
"apiKey": "your-api-key",
"apiBase": "https://api.example.com/v1"
}
},
"modelPresets": {
"primary": {
"label": "Primary",
"provider": "custom",
"model": "model-id-from-your-provider",
"maxTokens": 4096,
"contextWindowTokens": 65536,
"temperature": 0.1
}
},
"agents": {
"defaults": {
"modelPreset": "primary"
}
},
"channels": {
"websocket": {
"enabled": true,
"tokenIssueSecret": "your-webui-password",
"websocketRequiresToken": true
}
}
}
```
Replace `your-api-key`, `https://api.example.com/v1`, `model-id-from-your-provider`, and `your-webui-password` with your own values.
For copyable provider-specific examples, use [`provider-cookbook.md`](./provider-cookbook.md).
Save the file.
## 7. Open the WebUI
First check that nanobot can read the saved setup:
```bash
nanobot status
```
This should show the config file path, workspace path, and the active model or preset. If `nanobot` is not found, use `python -m nanobot status`, `python3 -m nanobot status`, or `py -m nanobot status`, matching the Python command that worked in step 2.
It is normal for most providers to say `not set`. Only the provider you selected for the active preset needs to look configured.
Start the local browser UI:
```bash
nanobot gateway
```
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Enter the WebUI password you set in the wizard or the `tokenIssueSecret` value from your manual config.
Send this first message in the browser:
```text ```text
Hello! Hello!
``` ```
If that works, nanobot is installed and can call the model. You should see a normal assistant reply in the browser. The exact words will differ, but it should look like this shape: A normal assistant reply means setup is complete. The exact reply does not matter.
```text The first-run address is local to your computer. It is not automatically available to other computers on your network.
Hello! How can I help you today?
```
If `nanobot` is not found, run: ## 6. Add One Thing at a Time
Do not configure every feature immediately. Choose one next goal:
| Goal | What to do |
|---|---|
| Change the AI model | Open **Settings → Models** |
| Add a provider credential | Open **Settings → Models**, then find the provider |
| Connect Telegram, Discord, Slack, Feishu, WeChat, or another chat app | Open **Settings → Channels**, choose the platform, and follow its connection steps |
| Add a tool integration | Open **Apps** and choose an App or MCP integration |
| Schedule a reminder or recurring task | Ask nanobot in the target chat, then manage it in **Automations** |
| Work with project files | Start a new chat, choose the project workspace, and review the access setting before sending the task |
Repository docs show the current development version. If your stable package does not yet show **Settings → Channels**, use the [Chat Apps guide](./chat-apps.md) or update to a release that includes it.
Some runtime changes ask you to restart nanobot. Use the restart action shown by the WebUI, or return to the terminal, press `Ctrl+C`, and run `nanobot webui` again.
For a chat platform's account, bot, token, or permission prerequisites, use the [Chat Apps guide](./chat-apps.md). For local models and provider-specific recipes, use the [Provider Cookbook](./provider-cookbook.md).
## If Something Fails
Run these commands one at a time:
```bash ```bash
python -m nanobot gateway nanobot --version
nanobot status
nanobot agent -m "Hello!"
``` ```
Use `python3 -m nanobot gateway` or `py -m nanobot gateway` if that is the Python command that worked in step 2. | What you see | What it usually means |
Once this works, nanobot can help with its own next setup step. In the browser UI, ask it to read these docs and update your current config for one specific goal, then run `/restart` when nanobot tells you the config is ready. For example, ask it to add one provider preset or configure one chat app.
## 8. If Something Fails
Do not change many things at once. Check the exact error:
| Error or symptom | What it usually means |
|---|---| |---|---|
| `JSON parse error` | The config file has a missing comma, extra comma, or mismatched brace. Copy the example again. | | `nanobot: command not found` | Reuse the exact nanobot command printed by the installer; it points to the isolated environment that contains the package |
| `401`, `unauthorized`, or `invalid API key` | The API key is wrong, expired, has extra spaces, or was pasted under the wrong provider. | | `401`, unauthorized, or invalid API key | The key is wrong, expired, or belongs to a different provider |
| `model not found` | Your account cannot use the default model. Return to `nanobot onboard --wizard`, choose `Advanced Settings`, then edit `Model Presets`. | | Model not found | The model ID is misspelled or unavailable to your provider account |
| `nanobot: command not found` | The install worked in Python, but your shell cannot find the script. Use `python -m nanobot ...`, `python3 -m nanobot ...`, or `py -m nanobot ...`, matching the Python command that worked earlier. | | Browser does not open | Open `http://127.0.0.1:8765` yourself and keep the terminal running |
| No response after editing config | Restart the command. Long-running processes read config when they start. | | Browser opens but messages fail | Test `nanobot agent -m "Hello!"` to separate a model problem from a WebUI problem |
| A change was saved but nothing changed | Restart nanobot so the running process reloads the config |
For a fuller diagnosis path, see [`troubleshooting.md`](./troubleshooting.md). If you ask for help, include your operating system, `nanobot --version`, `nanobot status`, the exact command, and the exact error. Remove every API key, bot token, password, OAuth token, and private account ID first.
## What Not to Configure Yet Continue with the full [Troubleshooting guide](./troubleshooting.md) for an ordered diagnosis.
Skip these until the first local message works: ## Open nanobot Later
- `apiBase`: hosted built-in providers often already have default endpoints. You only need `apiBase` for local models, proxies, custom OpenAI-compatible providers, or special regional/subscription endpoints.
- chat apps: first prove the local browser UI can answer.
- fallback models: useful later, but not needed for the first reply.
- Langfuse: useful for observability, but not needed for first setup.
## Next Steps
After the first reply works, choose only one next goal. Keep the terminal that runs `nanobot gateway` open whenever you use the WebUI or a chat app.
### Open the Browser UI Again
Run: Run:
```bash ```bash
nanobot gateway nanobot webui
``` ```
Leave that terminal open, then open `http://127.0.0.1:8765` in your browser. Leave that terminal open while you use nanobot. To stop it, return to the terminal and press `Ctrl+C`. Use `nanobot webui --background` only after the normal foreground start and model setup work; then manage it with `nanobot gateway status`, `logs`, `restart`, and `stop`.
To stop the WebUI later, return to the gateway terminal and press `Ctrl+C`.
If `nanobot` is not found, run `python -m nanobot gateway`, `python3 -m nanobot gateway`, or `py -m nanobot gateway`, matching the Python command that worked earlier. More details are in [`webui.md`](./webui.md).
### Connect a Chat App
1. Read the section for one app in [`chat-apps.md`](./chat-apps.md).
2. Add only that app's config snippet. Merge it into the existing file instead of replacing the whole file.
3. Run:
```bash
nanobot channels status
nanobot gateway
```
4. Leave the gateway terminal open, then send a message from the allowed account.
Start with a private chat or a test server. Do not set `allowFrom` to `["*"]` unless you intentionally want anyone who can reach that channel to talk to the bot.
### Change Models or Add Backups
Use [`providers.md`](./providers.md) when a provider/model pair fails, and [`provider-cookbook.md`](./provider-cookbook.md) when you want copyable snippets. Keep model choices in `modelPresets`, then select the active one with `agents.defaults.modelPreset`.
### Ask for Help
When you ask for help, include:
- your operating system;
- the command you ran;
- `nanobot --version`;
- `nanobot status`;
- whether the browser UI can answer `Hello!`;
- the exact error text;
- a config snippet with API keys and tokens removed.
Never paste real API keys, bot tokens, OAuth tokens, or private chat IDs into a public issue or chat.
If you find a docs mistake, outdated command, or confusing step, please open an issue: <https://github.com/HKUDS/nanobot/issues>.

View File

@ -23,15 +23,20 @@ This separates failures into layers:
| Layer | What it proves | | Layer | What it proves |
|---|---| |---|---|
| `nanobot --version` | Install and shell command discovery | | `nanobot --version` | Install and shell command discovery |
| `nanobot status` | Config path, workspace path, active model, and provider summary | | `nanobot status` | Config path, workspace, environment references, and active provider/model configuration |
| `nanobot agent -m "Hello!"` | Config loading, provider/model access, workspace writes, and agent loop | | `nanobot agent -m "Hello!"` | Config loading, provider/model access, workspace writes, and agent loop |
| `nanobot gateway` | Channel startup, cron system jobs, heartbeat, WebUI/WebSocket, and health endpoint | | `nanobot gateway` | Channel startup, cron system jobs, heartbeat, WebUI/WebSocket, and health endpoint |
If `nanobot agent -m "Hello!"` fails, fix that before debugging WebUI, Telegram, Discord, Docker, systemd, or any chat app. If `nanobot agent -m "Hello!"` fails, fix that before debugging WebUI, Telegram, Discord, Docker, systemd, or any chat app.
`nanobot status` does not call the model. If provider/model setup is incomplete, it points to
WebUI **Settings → Models** or the CLI setup wizard, then prints the command to check again.
## How to Read `nanobot status` ## How to Read `nanobot status`
`nanobot status` does not call a model. It only checks whether nanobot can find the default config, default workspace, active model or preset, and provider setup summary. `nanobot status` does not call a model. It checks the selected config and workspace,
resolves environment references, and validates the local settings required by the active
provider/model without constructing a provider client.
The output has this shape: The output has this shape:
@ -41,6 +46,7 @@ nanobot Status
Config: /path/to/config.json ✓ Config: /path/to/config.json ✓
Workspace: /path/to/workspace ✓ Workspace: /path/to/workspace ✓
Model: provider/model-name (preset: primary) Model: provider/model-name (preset: primary)
Agent: ✓ provider/model configuration is ready
Provider A: not set Provider A: not set
Provider B: ✓ Provider B: ✓
Local Provider: ✓ http://localhost:11434/v1 Local Provider: ✓ http://localhost:11434/v1
@ -54,6 +60,7 @@ Read it like this:
| `Config` | It points to the config file you meant to use and shows `✓`. | Run `nanobot onboard`, or pass `--config` to `nanobot agent`, `gateway`, or `serve` when testing a non-default instance. | | `Config` | It points to the config file you meant to use and shows `✓`. | Run `nanobot onboard`, or pass `--config` to `nanobot agent`, `gateway`, or `serve` when testing a non-default instance. |
| `Workspace` | It points to the workspace you meant to use and shows `✓`. | Run `nanobot onboard`, create the folder, fix permissions, or pass `--workspace` on commands that support it. | | `Workspace` | It points to the workspace you meant to use and shows `✓`. | Run `nanobot onboard`, create the folder, fix permissions, or pass `--workspace` on commands that support it. |
| `Model` | It shows the active model or the preset name you expect. | Set `agents.defaults.modelPreset` to the intended preset, or check `/model` if you changed models during a chat session. | | `Model` | It shows the active model or the preset name you expect. | Set `agents.defaults.modelPreset` to the intended preset, or check `/model` if you changed models during a chat session. |
| `Agent` | It says `provider/model configuration is ready`. | Follow the printed WebUI or CLI setup route, then run `nanobot status` again. |
| Provider rows | The provider used by the active preset shows `✓`, an OAuth marker, or a local URL. | Configure only the active provider first. It is normal for unused providers to say `not set`. | | Provider rows | The provider used by the active preset shows `✓`, an OAuth marker, or a local URL. | Configure only the active provider first. It is normal for unused providers to say `not set`. |
If `nanobot status` looks right but `nanobot agent -m "Hello!"` fails, the install and config paths are probably fine. Continue with [Provider and Model Problems](#provider-and-model-problems). If `nanobot status` looks right but `nanobot agent -m "Hello!"` fails, the install and config paths are probably fine. Continue with [Provider and Model Problems](#provider-and-model-problems).
@ -90,9 +97,10 @@ Default workspace path:
~/.nanobot/workspace/ ~/.nanobot/workspace/
``` ```
`nanobot status` reads the default config. Use explicit paths on commands that support them when debugging multiple instances: `nanobot status` reads the default config unless you pass explicit paths. Use the same `--config` and `--workspace` across status checks and runtime commands when debugging multiple instances:
```bash ```bash
nanobot status --config ./bot-a/config.json --workspace ./bot-a/workspace
nanobot agent --config ./bot-a/config.json --workspace ./bot-a/workspace -m "Hello" nanobot agent --config ./bot-a/config.json --workspace ./bot-a/workspace -m "Hello"
nanobot gateway --config ./bot-a/config.json --workspace ./bot-a/workspace nanobot gateway --config ./bot-a/config.json --workspace ./bot-a/workspace
``` ```
@ -107,13 +115,19 @@ Common config mistakes:
| Environment variable error | `${VAR_NAME}` references are resolved at startup. Set the variable before running nanobot. | | Environment variable error | `${VAR_NAME}` references are resolved at startup. Set the variable before running nanobot. |
| Edited config but behavior did not change | Restart `nanobot gateway`; long-running processes read config at startup. | | Edited config but behavior did not change | Restart `nanobot gateway`; long-running processes read config at startup. |
After editing config, check the shortest path to an Agent reply:
```bash
nanobot status
```
To refresh missing defaults without overwriting existing settings, run: To refresh missing defaults without overwriting existing settings, run:
```bash ```bash
nanobot onboard nanobot onboard --refresh
``` ```
When prompted about overwriting the config, choose the option that keeps current values and merges missing defaults. For an interactive choice between resetting and refreshing, run `nanobot onboard` and choose the option that keeps current values and merges missing defaults.
## Provider and Model Problems ## Provider and Model Problems
@ -134,7 +148,17 @@ If you need a known-good snippet instead of diagnosis, use [`provider-cookbook.m
| Provider cannot be inferred | Pin `modelPresets.<name>.provider` in the active preset instead of using `"auto"`. For legacy direct configs, pin `agents.defaults.provider`. | | Provider cannot be inferred | Pin `modelPresets.<name>.provider` in the active preset instead of using `"auto"`. For legacy direct configs, pin `agents.defaults.provider`. |
| Local model connection refused | Ollama, vLLM, LM Studio, or another local server is not running, or `apiBase` points to the wrong port. | | Local model connection refused | Ollama, vLLM, LM Studio, or another local server is not running, or `apiBase` points to the wrong port. |
| Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. | | Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. |
| OAuth provider fails | Run `nanobot provider login openai-codex` or `nanobot provider login github-copilot`, then select the provider explicitly. | | OAuth provider fails | Run the matching login command: `openai-codex`, `xai-grok`, or `github-copilot`, normally with `--set-main`. |
| Codex OAuth needs a proxy | Set `providers.openaiCodex.proxy` before running the login command. The proxy applies to login, token refresh, and Codex API requests. |
| Codex login runs on a remote/headless machine | In the WebUI, open ChatGPT in your local browser; when the localhost callback page cannot load, copy the full `http://localhost:1455/auth/callback?...` URL from the address bar and paste it into the WebUI dialog. From the CLI, open the printed URL locally and paste the same callback URL back into the terminal. |
| Codex login runs in Docker | Start the container with `docker run -it` so the OAuth flow has an interactive terminal. |
| Codex says a model is not supported with a ChatGPT account | Use provider `openai_codex` with a Codex model such as `openai-codex/gpt-5.6-sol`. Do not use the direct-API `openai/...` prefix with Codex OAuth. |
| Config says `providers.openai_codex` conflicts with the built-in provider | Under `providers`, keep only the canonical `openaiCodex` settings key and remove a duplicate `openai_codex` key. A model preset's `provider` value remains `openai_codex`. |
| xAI OAuth needs a proxy | Set `providers.xaiGrok.proxy` before login. It applies to OAuth discovery, token exchange/refresh, and Grok subscription requests. |
| xAI login runs on a remote/headless machine | In the WebUI, finish sign-in in your local browser; if the loopback redirect cannot reach the server, copy the final URL from the address bar into the WebUI dialog. From the CLI, run `nanobot provider login xai-grok` interactively, open the printed URL elsewhere, and paste the final callback URL or authorization code when prompted. |
| xAI returns 403 or subscription access denied | Confirm the signed-in account has an eligible X Premium / Grok subscription, then run `nanobot provider login xai-grok` again. This provider does not use an xAI API key or X Developer OAuth. |
| xAI returns 400 `invalid-argument` | Read the bounded `Response body` appended to the provider error. Hosted `x_search` is sent only when xAI's model catalog advertises `supportsBackendSearch`; the model ID `grok-4.5` itself is valid. |
| xAI model or X Search stops working after an upstream release | The integration follows Grok Build's public OAuth/proxy client contract. Update nanobot if xAI changes that contract. |
## Langfuse Problems ## Langfuse Problems
@ -172,9 +196,50 @@ nanobot gateway --verbose
| Port already in use | Change `gateway.port`, `channels.websocket.port`, or the `--port` CLI flag for the relevant command. | | Port already in use | Change `gateway.port`, `channels.websocket.port`, or the `--port` CLI flag for the relevant command. |
| WebUI opened on `18790` but shows nothing useful | Open `8765`; `18790` is the health endpoint. | | WebUI opened on `18790` but shows nothing useful | Open `8765`; `18790` is the health endpoint. |
| Config changes ignored | Restart the gateway. | | Config changes ignored | Restart the gateway. |
| Startup pauses at `Installing optional feature` | An enabled channel is missing its Python dependencies. See [Slow Optional Channel Dependency Installation](#slow-optional-channel-dependency-installation). |
| Heartbeat never runs | Keep the gateway running, add tasks under `<workspace>/HEARTBEAT.md` -> `## Active Tasks`, and make sure `gateway.heartbeat.enabled` is true. | | Heartbeat never runs | Keep the gateway running, add tasks under `<workspace>/HEARTBEAT.md` -> `## Active Tasks`, and make sure `gateway.heartbeat.enabled` is true. |
| Cron jobs disappeared after switching workspaces | Cron jobs are workspace-scoped at `<workspace>/cron/jobs.json`; check you are using the intended workspace. | | Cron jobs disappeared after switching workspaces | Cron jobs are workspace-scoped at `<workspace>/cron/jobs.json`; check you are using the intended workspace. |
### Slow Optional Channel Dependency Installation
Before loading enabled channels, the gateway checks the dependencies declared by their
channel manifests. The CLI and WebUI normally install these dependencies when a channel is
enabled. Installation during startup is a recovery path for an enabled config whose Python
environment no longer has the required packages, for example after manually editing the
config, upgrading nanobot, or recreating an isolated `uv tool`/`pipx` environment. The
gateway waits for the install so an enabled channel is not silently skipped; later starts
skip the installation once the dependencies are present.
If access to PyPI is slow in your region, configure pip to use a trusted package index. The
installer honors the standard `PIP_INDEX_URL` environment variable, including when nanobot
itself was installed with `uv tool`:
```bash
PIP_INDEX_URL=https://your-trusted-mirror.example/simple nanobot gateway
```
For the systemd user service created by `nanobot gateway install-service`, add a drop-in:
```bash
systemctl --user edit nanobot-gateway.service
```
```ini
[Service]
Environment="PIP_INDEX_URL=https://your-trusted-mirror.example/simple"
```
Then reload and restart the service:
```bash
systemctl --user daemon-reload
systemctl --user restart nanobot-gateway.service
```
For a system-level or custom service, use `sudo systemctl edit <unit>` instead. Prefer an
HTTPS index operated by an organization you trust, and do not put index credentials in
commands or logs.
## WebUI Problems ## WebUI Problems
The packaged WebUI is served by the WebSocket channel. The packaged WebUI is served by the WebSocket channel.
@ -223,7 +288,9 @@ Then check:
|---|---| |---|---|
| Bot never replies | Gateway is not running, the channel is not enabled, or the bot/app token is wrong. | | Bot never replies | Gateway is not running, the channel is not enabled, or the bot/app token is wrong. |
| Unknown sender ignored | Configure `allowFrom`, pairing, or the channel-specific allow list. | | Unknown sender ignored | Configure `allowFrom`, pairing, or the channel-specific allow list. |
| Telegram fails | Confirm the BotFather token and `allowFrom` user ID. | | Telegram shows a saved configuration but cannot complete a live check | The token is saved. Confirm the gateway can reach `api.telegram.org`, or open **Settings → Channels → Telegram → Advanced → Network proxy** and enter an HTTP or SOCKS proxy. |
| Telegram rejects the token | Copy the current token from BotFather or regenerate it. |
| Telegram receives no messages | Confirm the channel is enabled, the gateway is running, and the sender is paired or listed in `allowFrom`. |
| Discord replies missing | Enable Message Content intent and invite the bot with the required permissions. | | Discord replies missing | Enable Message Content intent and invite the bot with the required permissions. |
| WhatsApp or WeChat login expired | Re-run `nanobot channels login whatsapp` or `nanobot channels login weixin`. | | WhatsApp or WeChat login expired | Re-run `nanobot channels login whatsapp` or `nanobot channels login weixin`. |
| Chat app works but WebUI does not | The provider and gateway are likely fine; debug the WebSocket channel separately. | | Chat app works but WebUI does not | The provider and gateway are likely fine; debug the WebSocket channel separately. |

View File

@ -16,13 +16,13 @@ Nanobot can act as a WebSocket server, allowing external clients (web apps, CLIs
### 1. Configure ### 1. Configure
Add to `config.json` under `channels.websocket`: The WebSocket channel is enabled by default. Add only the fields you want to
override under `channels.websocket`:
```json ```json
{ {
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"host": "127.0.0.1", "host": "127.0.0.1",
"port": 8765, "port": 8765,
"path": "/", "path": "/",
@ -76,7 +76,7 @@ ws://{host}:{port}{path}?client_id={id}&token={token}
| Parameter | Required | Description | | Parameter | Required | Description |
|-----------|----------|-------------| |-----------|----------|-------------|
| `client_id` | No | Identifier for `allowFrom` authorization. Auto-generated as `anon-xxxxxxxxxxxx` if omitted. Truncated to 128 chars. | | `client_id` | No | Identifier for `allowFrom` authorization. Auto-generated as `anon-xxxxxxxxxxxx` if omitted. Truncated to 128 chars. |
| `token` | Conditional | Authentication token. Required when `websocketRequiresToken` is `true` or `token` (static secret) is configured. | | `token` | Conditional | Authentication token. Required when `websocketRequiresToken` is `true` or `token` (static secret) is configured, unless the request comes through an authenticated `trustedProxyAuth` peer. |
## Wire Protocol ## Wire Protocol
@ -152,7 +152,8 @@ All frames are JSON text. Each message has an `event` field.
Reasoning frames only flow when the channel's `showReasoning` is `true` (default) and the model returns reasoning content (DeepSeek-R1 / Kimi / MiMo / OpenAI reasoning models, Anthropic extended thinking, or inline `<think>` / `<thought>` tags). Models without reasoning produce zero `reasoning_delta` frames. Reasoning frames only flow when the channel's `showReasoning` is `true` (default) and the model returns reasoning content (DeepSeek-R1 / Kimi / MiMo / OpenAI reasoning models, Anthropic extended thinking, or inline `<think>` / `<thought>` tags). Models without reasoning produce zero `reasoning_delta` frames.
**`runtime_model_updated`** — broadcast when the gateway runtime model changes, for example after `/model <preset>`: **`runtime_model_updated`** — broadcast when the gateway default runtime changes or
when a config reload requires clients to refresh their model catalog:
```json ```json
{ {
@ -162,7 +163,10 @@ Reasoning frames only flow when the channel's `showReasoning` is `true` (default
} }
``` ```
`model_preset` is omitted when no named preset is active. WebUI clients use this event to keep the displayed model badge in sync across slash commands, config reloads, and settings changes. `model_preset` is omitted when no named preset is active. WebUI clients use this event
to refresh model settings after default-runtime and config changes. `/model <preset>`
is session-scoped; its selection is reflected through `session_updated` and the
session row's `model_preset` field instead of this global event.
**`attached`** — confirmation for `new_chat` / `attach` inbound envelopes (see [Multi-chat multiplexing](#multi-chat-multiplexing)): **`attached`** — confirmation for `new_chat` / `attach` inbound envelopes (see [Multi-chat multiplexing](#multi-chat-multiplexing)):
@ -208,20 +212,24 @@ All fields go under `channels.websocket` in `config.json`.
| Field | Type | Default | Description | | Field | Type | Default | Description |
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `enabled` | bool | `false` | Enable the WebSocket server. | | `enabled` | bool | `true` | Enable the WebSocket server. Set to `false` only when you intentionally do not want the bundled WebUI/WebSocket surface. |
| `host` | string | `"127.0.0.1"` | Bind address. Use `"0.0.0.0"` to accept external connections. | | `host` | string | `"127.0.0.1"` | Bind address. Use `"0.0.0.0"` to accept external connections. |
| `port` | int | `8765` | Listen port. | | `port` | int | `8765` | Listen port. |
| `path` | string | `"/"` | WebSocket upgrade path. Trailing slashes are normalized (root `/` is preserved). | | `path` | string | `"/"` | WebSocket upgrade path. Trailing slashes are normalized (root `/` is preserved). |
| `publicWsUrl` | string | `""` | Exact public `ws://` or `wss://` endpoint returned by `/webui/bootstrap`. Set this when a reverse proxy forwards requests with an origin `Host` header (for example, `wss://claw.example.com/`); its path must match `path`. |
| `maxMessageBytes` | int | `37748736` | Maximum inbound message size in bytes (1 KB 40 MB). Default (36 MB) is sized to accept up to 4 base64-encoded image attachments at 8 MB each; lower it if the channel only carries text. | | `maxMessageBytes` | int | `37748736` | Maximum inbound message size in bytes (1 KB 40 MB). Default (36 MB) is sized to accept up to 4 base64-encoded image attachments at 8 MB each; lower it if the channel only carries text. |
### Authentication ### Authentication
| Field | Type | Default | Description | | Field | Type | Default | Description |
|-------|------|---------|-------------| |-------|------|---------|-------------|
| `token` | string | `""` | Static shared secret. When set, clients must provide `?token=<value>` matching this secret (timing-safe comparison). Issued tokens are also accepted as a fallback. | | `token` | string | `""` | Static shared secret. When set, clients must provide `?token=<value>` matching this secret (timing-safe comparison). Issued tokens are also accepted as a fallback. A trusted proxy assertion bypasses this requirement. |
| `websocketRequiresToken` | bool | `true` | When `true` and no static `token` is configured, clients must still present a valid issued token. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). | | `websocketRequiresToken` | bool | `true` | When `true` and no static `token` is configured, clients must still present a valid issued token, unless `trustedProxyAuth` authenticates the direct proxy peer. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). |
| `tokenIssuePath` | string | `""` | HTTP path for issuing short-lived tokens. Must differ from `path`. See [Token Issuance](#token-issuance). | | `tokenIssuePath` | string | `""` | HTTP path for issuing short-lived tokens. Must differ from `path`. See [Token Issuance](#token-issuance). |
| `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain tokens (logged as a warning). | | `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain WebSocket connection tokens from `tokenIssuePath` (logged as a warning). `/webui/bootstrap` issues tokens for local/secret-authenticated requests; trusted-proxy requests intentionally receive no bootstrap or API token. |
| `trustedProxyAuth` | object or `null` | `null` | Optional two-part no-token authorization for a directly connected upstream proxy. Both `trustedPeerCidrs` and a non-empty `assertionHeader` value must match; a CIDR alone never authorizes bootstrap or WebSocket/API access. |
| `trustedProxyAuth.trustedPeerCidrs` | list of CIDR strings | — | Direct TCP peer networks that may present the assertion. IPv4, IPv6, and IPv4-mapped IPv6 peers are supported; universal CIDRs (`0.0.0.0/0`, `::/0`) are rejected. |
| `trustedProxyAuth.assertionHeader` | string | — | Header injected by the identity-aware proxy after successful authentication. Routing/client metadata headers (`Host`, `Forwarded`, `X-Forwarded-*`, `X-Real-IP`, `CF-Connecting-IP`) are rejected; nanobot trusts the remaining header's non-empty value but does not cryptographically validate it. |
| `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 86,400). | | `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 86,400). |
### Access Control ### Access Control
@ -266,13 +274,64 @@ For production deployments where `websocketRequiresToken: true`, use short-lived
3. Client opens WebSocket with `?token=nbwt_aBcDeFg...&client_id=...`. 3. Client opens WebSocket with `?token=nbwt_aBcDeFg...&client_id=...`.
4. The token is consumed (single use) and cannot be reused. 4. The token is consumed (single use) and cannot be reused.
The embedded WebUI's `/webui/bootstrap` route returns a WebSocket token and
REST `api_token` for local or secret-authenticated requests. When
`trustedProxyAuth` authenticates the direct proxy peer, it returns connection
metadata only: no bootstrap token, no REST API token, and no token query
parameter is required for the WebSocket handshake or subsequent REST requests.
### Trusted proxy no-token bootstrap
`trustedProxyAuth` is an opt-in alternative for deployments where an
identity-aware reverse proxy authenticates the user before connecting to nanobot.
The proxy assertion becomes the authentication boundary for the entire WebUI
surface: `/webui/bootstrap`, the WebSocket handshake, and REST API routes.
Bootstrap is accepted only when **both** the direct TCP peer matches one of
`trustedPeerCidrs` and the configured assertion header is present and non-empty.
A trusted address by itself is never sufficient.
Nanobot deliberately uses only `connection.remote_address` for the peer check.
It never uses `X-Forwarded-For`, `Forwarded`, `X-Real-IP`, `CF-Connecting-IP`,
or `X-Forwarded-Host` to decide whether the proxy is trusted. Nanobot trusts the
assertion supplied by the explicitly trusted peer, but does not cryptographically
validate or interpret the JWT/assertion contents. Do not enable this option if
untrusted clients can connect directly to the nanobot listener.
The configured assertion header must be a proxy-generated authentication
assertion, not a routing or client metadata header. Headers such as `Host`,
`Forwarded`, `X-Forwarded-*`, `X-Real-IP`, and `CF-Connecting-IP` are rejected
by configuration; use the identity provider's post-authentication assertion
header instead (for example, `Cf-Access-Jwt-Assertion`).
For example, a local Cloudflare Tunnel with Cloudflare Access can validate the
user at the edge and forward the resulting `Cf-Access-Jwt-Assertion`:
```json
{
"channels": {
"websocket": {
"host": "127.0.0.1",
"publicWsUrl": "wss://nanobot.example.com/",
"trustedProxyAuth": {
"trustedPeerCidrs": ["127.0.0.1/32", "::1/128"],
"assertionHeader": "Cf-Access-Jwt-Assertion"
}
}
}
}
```
This works only when the directly connected `cloudflared` process reaches
nanobot over the configured loopback address and supplies a non-empty assertion.
Keep nanobot firewalled from untrusted clients; this configuration is not a
CIDR-based bootstrap bypass.
### Example setup ### Example setup
```json ```json
{ {
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"port": 8765, "port": 8765,
"path": "/ws", "path": "/ws",
"tokenIssuePath": "/auth/token", "tokenIssuePath": "/auth/token",
@ -367,7 +426,6 @@ Outbound `message` events may include a `media` field containing local filesyste
{ {
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 8765, "port": 8765,
"websocketRequiresToken": false, "websocketRequiresToken": false,
@ -384,7 +442,6 @@ Outbound `message` events may include a `media` field containing local filesyste
{ {
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"token": "my-shared-secret", "token": "my-shared-secret",
"allowFrom": ["alice", "bob"] "allowFrom": ["alice", "bob"]
} }
@ -400,7 +457,6 @@ Clients connect with `?token=my-shared-secret&client_id=alice`.
{ {
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 8765, "port": 8765,
"path": "/ws", "path": "/ws",
@ -421,7 +477,6 @@ Clients connect with `?token=my-shared-secret&client_id=alice`.
{ {
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"path": "/chat/ws", "path": "/chat/ws",
"allowFrom": ["*"] "allowFrom": ["*"]
} }

View File

@ -1,28 +1,51 @@
# WebUI # Nanobot WebUI: Browser Workbench for Self-Hosted AI Agents
The WebUI is nanobot's browser workbench. Use it after a basic CLI reply already <!-- Meta description: Run nanobot from a browser WebUI with persistent topics, visible tool activity, workspace controls, Apps, MCP presets, Skills, settings, and Automations. -->
works, when you want a persistent chat workspace, visible agent activity,
workspace controls, Apps, Skills, settings, and Automations in one place. The WebUI is nanobot's browser workbench for persistent topics, visible
agent activity, workspace controls, Apps, Skills, settings, and Automations in
one place.
The published `nanobot-ai` wheel already includes the WebUI bundle. You only need The published `nanobot-ai` wheel already includes the WebUI bundle. You only need
the `webui/` source directory when you are changing the frontend itself. the `webui/` source directory when you are changing the frontend itself.
## Open the WebUI ## Open the WebUI
First confirm your provider and model can answer: Use the launcher:
```bash ```bash
nanobot agent -m "Hello!" nanobot webui
``` ```
Then merge the WebSocket channel into your existing `~/.nanobot/config.json`. `nanobot webui` creates the config/workspace when needed, enables the local
Set `tokenIssueSecret` to the password you will enter in the WebUI login form: WebSocket channel after confirmation, generates a WebUI bootstrap secret when
one is missing, starts the gateway, and opens the browser. With a fresh config,
it can open before a model is configured so you can finish setup in **Settings
→ Models**. The first-run path binds the WebUI to `127.0.0.1` by default, so
it is not available from other devices on your LAN.
Run it in the background when you do not want to keep a terminal open:
```bash
nanobot webui --background
```
Complete first-time model setup in a foreground `nanobot webui` session before using
`--background`.
Manage the background gateway with `nanobot gateway status`, `nanobot gateway
logs`, `nanobot gateway restart`, and `nanobot gateway stop`.
Manual config still works. Same-machine localhost WebUI access can run without
a browser password. Set `tokenIssueSecret` when you intentionally expose the
WebUI beyond localhost or want a browser password:
```json ```json
{ {
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true, "enabled": true,
"host": "127.0.0.1",
"tokenIssueSecret": "your-webui-password", "tokenIssueSecret": "your-webui-password",
"websocketRequiresToken": true "websocketRequiresToken": true
} }
@ -30,73 +53,140 @@ Set `tokenIssueSecret` to the password you will enter in the WebUI login form:
} }
``` ```
If you are new to JSON snippets, see The WebUI is served by the WebSocket channel on port `8765` by default. The
[`start-without-technical-background.md#how-to-merge-json-snippets`](./start-without-technical-background.md#how-to-merge-json-snippets). gateway health endpoint, `18790` by default, is not the browser UI.
Start the gateway: ## First 10 Minutes
```bash Use the WebUI as the primary setup surface:
nanobot gateway
```
Leave the gateway running and open 1. Open **Settings → Models** and configure a provider, credential, and active model preset.
[`http://127.0.0.1:8765`](http://127.0.0.1:8765). The WebUI is served by the 2. Send `Hello!` in a new topic to prove the selected model works.
WebSocket channel on port `8765` by default. The gateway health endpoint, 3. Start a separate topic before project work, then choose the intended workspace and access mode.
`18790` by default, is not the browser UI. 4. Add only one capability next: a chat channel in **Settings → Channels**, a web/voice/image provider in **Settings**, or an App/MCP integration in **Apps**.
Enter `tokenIssueSecret` when the WebUI asks for a password. 5. Restart when the WebUI shows a restart requirement, then test that capability with the smallest possible request.
This path avoids hand-editing `config.json` for normal setup. Use the reference docs when you need an option the WebUI does not expose or when you manage config as code.
## What It Is For ## What It Is For
| Area | Use it for | | Area | Use it for |
|---|---| |---|---|
| Chat | Start, switch, search, fork, and delete browser sessions | | Topics | Start, switch, search, fork, and delete browser topics |
| Agent activity | See thinking, tool calls, file activity, command output, and generated artifacts in context | | Agent activity | See thinking, tool calls, file edits with diffs, command output, and generated artifacts in context |
| Workspace | Pick the project workspace before asking for file or shell work | | Workspace | Pick the project workspace before asking for file or shell work |
| Access | Choose the access mode for local capabilities allowed by your gateway configuration | | Access | Choose the access mode for local capabilities allowed by your gateway configuration |
| Composer | Send text, images, voice input, slash commands, and `@` mentions for Apps or MCP presets | | Composer | Send text, images, voice input, slash commands, and `@` mentions for topics, Apps, or MCP presets |
| Channels | Connect and validate chat platforms, install their optional support, and manage saved channel setup |
| Apps | Install, test, update, and use local CLI App adapters and MCP presets | | Apps | Install, test, update, and use local CLI App adapters and MCP presets |
| Skills | Inspect available built-in and workspace skills before relying on them | | Skills | Inspect available built-in and workspace skills before relying on them |
| Automations | Review, search, run, pause, edit, and delete scheduled agent turns | | Automations | Review, search, run, pause, edit, and delete scheduled and local-trigger agent turns |
| Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options | | Settings | Adjust models, providers, image generation, voice, web tools, runtime, and safety options |
## Chat Workspace ## Topic Workspace
The sidebar is the session switcher. A session keeps its own history, title, The sidebar is the topic switcher. Each topic keeps its own history, title,
workspace metadata, and linked automations. Use a new session when you want a workspace selection, and linked automations. Use a new topic when you want a
separate context; use fork when you want to continue from an existing point separate context; use fork when you want to continue from an existing point
without changing the original thread. without changing the original thread.
The message timeline shows both user-visible replies and agent activity. Long The message timeline shows both user-visible replies and agent activity. Long
tool or reasoning sections can be expanded when you need the details. tool or reasoning sections can be expanded when you need the details.
When the agent writes or edits files, the activity item shows the target path,
status, changed line counts, and, when available, a unified diff. Use **View
diff** to expand the change; large diffs may hide unchanged lines or truncate the
inline preview. Use **Open file** from a file edit to open the read-only file
preview panel.
File previews follow the active session access mode. Restricted workspace access
previews only files under the selected workspace. Full Access can preview files
outside the workspace when that access mode is allowed by the gateway.
## Workspace and Access ## Workspace and Access
Use the workspace picker before starting project-specific work. This gives the Use the workspace picker before starting project-specific work. This gives the
agent the right project context for file paths, shell commands, and session agent the right project context for file paths, shell commands, and session
metadata. metadata.
Selecting a project does not replace the configured agent workspace. The two
paths have different responsibilities:
| Selected project provides | Agent workspace continues to provide |
|---|---|
| Project `AGENTS.md` | `SOUL.md` and `USER.md` |
| Relative file paths and shell working directory | Long-term memory and history |
| The normal read/write boundary in Restricted mode | Custom skills and instance state |
Project-local `SOUL.md` and `USER.md` files are ignored, and the agent workspace's
`AGENTS.md` is not inherited by a separately selected project. When the selected
project is the configured agent workspace, both roles naturally use the same
directory.
The access control in the composer controls the local capability level for the The access control in the composer controls the local capability level for the
chat. It does not bypass your gateway, provider, shell sandbox, or operating chat. It does not bypass your gateway, provider, shell sandbox, or operating
system configuration; it only selects among the capabilities that are already system configuration; it only selects among the capabilities that are already
available to this WebUI session. available to the current topic.
In Restricted mode, ordinary file and shell work stays inside the selected
project. To preserve agent continuity, filesystem/search tools receive narrow,
read-only access to built-in skills, custom skills in the agent workspace, and
the exact agent `memory/history.jsonl` file. This does not grant access to
neighboring memory or profile files, and it does not allow writes outside the
selected project. These tool exceptions do not broaden the browser's file
preview boundary.
Remote WebUI connections may reduce access for the current workspace. Selecting a
different workspace or enabling Full Access remains limited to local and native
clients.
## Composer ## Composer
The composer supports plain messages, image attachments, voice input when The composer supports plain messages, image attachments, voice input when
transcription is configured, slash commands, and `@` mentions for installed Apps transcription is configured, slash commands, and `@` mentions for installed Apps
or MCP presets. The model badge shows the current model or preset and links back or MCP presets. Select another topic from the `@` menu to attach a stable
to model settings when setup is incomplete. reference; plain text that happens to start with `@` does not attach history.
Restricted chats offer topics from the same project, while Full Access chats can
reference any WebUI topic. Nanobot reads a referenced topic only when its history
is relevant and can link it in the response. The model badge shows the current
model or preset and links back to model settings when setup is incomplete.
For image generation, configure an image provider first and then use the WebUI For image generation, configure an image provider first and then use the WebUI
image mode from the composer. See [`image-generation.md`](./image-generation.md) image mode from the composer. See [`image-generation.md`](./image-generation.md)
for provider setup and output behavior. for provider setup and output behavior.
## Channels
Open **Settings → Channels** to connect chat apps without assembling JSON by hand. Search for a platform, open its setup panel, and follow the fields or QR flow shown for that channel. The guided setup can:
- install missing optional channel support when the WebUI is running locally;
- collect platform credentials while preserving previously saved values;
- handle supported QR-based login flows;
- validate the connection and show actionable setup errors;
- tell you when the gateway needs to restart.
The platform itself may still require you to create a bot, enable event permissions, copy a token, or configure a webhook. Use [`chat-apps.md`](./chat-apps.md) for those platform-side prerequisites and for manual JSON/reference options.
Test a new channel with a private DM. When a supported channel sends a pairing code, the WebUI surfaces the pending request so you can approve the sender. Keep access narrow; do not use a wildcard allowlist unless public access is intentional.
## Apps ## Apps
Open Apps from the sidebar or settings navigation to manage integrations that Open Apps from the sidebar to manage tools that nanobot can attach to a chat
nanobot can call from a chat. CLI Apps install local adapters that nanobot runs turn. The default **Ready** view shows only tools that can be used immediately:
on your machine; they do not modify the native apps themselves. MCP presets add
predefined MCP server configurations. - **Apps** are local command-line adapters that nanobot runs on your machine.
Installing an adapter does not modify the native desktop or web app it
connects to.
- **Integrations** are MCP servers. Presets provide known configurations, and
the custom integration panel accepts stdio, HTTP, and SSE servers.
Apps intentionally does not list nanobot runtime support packages such as
`api` or `bedrock`. Those packages enable providers, servers, or channels; they
are not tools that can be attached to a turn with `@`. Manage them from
**System**, **Models**, or **Web**. PDF and common Office document readers are
included in nanobot and activate automatically when a file is attached. The
equivalent CLI for optional integrations remains `nanobot plugins`. See
[`cli-reference.md`](./cli-reference.md#optional-features).
Some MCP presets connect to hosted keyless endpoints. For example, the Firecrawl Some MCP presets connect to hosted keyless endpoints. For example, the Firecrawl
preset uses Firecrawl's hosted MCP endpoint for search, scrape, crawl, and preset uses Firecrawl's hosted MCP endpoint for search, scrape, crawl, and
@ -104,8 +194,13 @@ extraction tools without requiring an API key. This does not replace nanobot's
built-in web search provider; mention the Firecrawl MCP preset with `@` when a built-in web search provider; mention the Firecrawl MCP preset with `@` when a
turn needs Firecrawl's richer web data tools. turn needs Firecrawl's richer web data tools.
After an App or MCP preset is available, mention it from the composer with `@` The Parallel Search preset connects to the free, anonymous Parallel Search MCP
to attach that capability to the next message. endpoint and exposes `web_search` and `web_fetch` without requiring an API key.
It is an optional integration and does not replace nanobot's built-in web search
provider; mention `@parallel-search` when a turn should use it.
After an App or integration is available, mention it from the composer with
`@` to attach that tool to the next message.
## Skills ## Skills
@ -116,36 +211,61 @@ to perform that task.
## Automations ## Automations
Automations are scheduled agent turns. They should be created from the chat, Automations are agent turns that run later in a linked topic. Create them from
channel, or session where they are supposed to run so nanobot keeps the correct the topic or channel where they are supposed to run so nanobot keeps the
target context. correct target context. When an automation runs, it normally delivers the
result back to that topic.
For the full automation model, creation flow, trigger CLI usage, and delivery
semantics, see [`automations.md`](./automations.md).
There are two user-facing automation types:
- Scheduled automations, created by the agent's cron tool, run at a time,
interval, or cron expression.
- Local triggers, created with `/trigger <name>`, run when you call a local
command such as `nanobot trigger trg_8K4P2Q9X "Review PR #4502"`.
For recurring background checks that should stay quiet unless there is something
useful to report, use the protected heartbeat job by editing `HEARTBEAT.md`
instead of creating a chat automation.
Use the Automations view to: Use the Automations view to:
- Filter by all, active, paused, needs-attention, or system jobs. - Filter by all, active, paused, needs-attention, or system jobs.
- Search by task name, message, linked chat, schedule, or status. - Search by task name, message, trigger command, linked topic, schedule, or status.
- Sort by next run, last run, updated time, or name. - Sort by next run, last run, updated time, or name.
- Run now, pause or resume, edit, or delete user-created automations. - Run scheduled automations now.
- Pause or resume, rename, or delete user-created automations.
- Copy the CLI command for local triggers.
- Inspect protected system automations without changing them. - Inspect protected system automations without changing them.
Search accepts plain text and field filters such as `name:backup`, Search accepts plain text and field filters such as `name:backup`,
`chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, and `status:paused`. `chat:WeChat`, `schedule:09:30`, `cron:"0 23 * * *"`, `trigger`, and
`status:paused`.
An automation without a linked chat cannot be enabled or run from the WebUI, An automation without a linked topic cannot be enabled or run from the WebUI,
because nanobot would not know where to deliver the scheduled turn. Recreate it because nanobot would not know where to deliver the scheduled turn. Recreate it
from the target chat or channel so the automation has complete context. from the target topic or channel so the automation has complete context.
Local triggers do not have a WebUI "Run now" action because each run needs a
message. Use the copied `nanobot trigger ...` command and replace `"message"`
with the content that should be delivered.
## Settings ## Settings
Settings is the control surface for the browser session and gateway-backed Settings is the control surface for the browser session and gateway-backed
runtime configuration. Use it to review or adjust model presets, provider runtime configuration. Use it to review or adjust model presets, providers,
visibility, image generation, voice transcription, web tools, Apps, Automations, image generation, voice transcription, web tools, chat channels, Apps,
Skills, runtime identity, and advanced safety controls. Automations, Skills, runtime identity, and advanced safety controls.
Some settings take effect immediately. Runtime settings that affect the gateway Some settings take effect immediately. Runtime settings that affect the gateway
or agent process may require a restart; the WebUI shows that requirement next to or agent process may require a restart; the WebUI shows that requirement next to
the relevant control. the relevant control.
Browser-only display preferences, such as file edit display mode, take effect
immediately for the current browser and do not change gateway configuration.
## LAN Access ## LAN Access
To open the WebUI from another device on the same network, bind the WebSocket To open the WebUI from another device on the same network, bind the WebSocket
@ -155,7 +275,6 @@ channel to all interfaces and set a token or token issue secret:
{ {
"channels": { "channels": {
"websocket": { "websocket": {
"enabled": true,
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 8765, "port": 8765,
"tokenIssueSecret": "your-secret-here" "tokenIssueSecret": "your-secret-here"
@ -169,12 +288,36 @@ The gateway refuses to start with `host` set to `"0.0.0.0"` unless `token` or
`http://<your-ip>:8765` from the other device and enter the secret in the login `http://<your-ip>:8765` from the other device and enter the secret in the login
form. form.
Remote WebUI clients with a valid token can view and use Apps. Actions that
install missing nanobot support packages, such as adding a channel dependency,
are blocked by default. To let trusted remote administrators change the Python
environment through the WebUI, opt in explicitly:
```json
{
"tools": {
"webuiAllowRemotePackageInstall": true
}
}
```
Use this only for a private deployment where every authenticated WebUI user is
trusted to change the Python environment that nanobot runs in. If you publish
the WebUI through Nginx, Caddy, Cloudflare Tunnel, or a similar service, treat it
as remote access and leave package installs disabled unless that is intentional.
Optional feature installs use pip's configured package index, including
`PIP_INDEX_URL`.
Leave remote package installs disabled when the WebUI is exposed beyond a
private, trusted network.
## Troubleshooting ## Troubleshooting
If the page does not open, check these in order: If the page does not open, check these in order:
1. `nanobot agent -m "Hello!"` works in the same Python environment. 1. `nanobot agent -m "Hello!"` works in the same Python environment.
2. The WebSocket channel is enabled in `~/.nanobot/config.json`. 2. `~/.nanobot/config.json` does not explicitly set `channels.websocket.enabled` to `false`.
3. `nanobot gateway` is still running. 3. `nanobot gateway` is still running.
4. You are opening port `8765`, not the gateway health port. 4. You are opening port `8765`, not the gateway health port.
5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret. 5. LAN access uses `host: "0.0.0.0"` and a token or token issue secret.

View File

@ -1,5 +1,44 @@
#!/bin/sh #!/bin/sh
dir="$HOME/.nanobot" dir="$HOME/.nanobot"
# Render deploy path (see render.yaml + render-config.json). Gated on Render's
# automatic RENDER=true env var so local Docker/podman usage is unaffected.
# Initializes the on-disk config from the committed template (wiring secrets via
# ${VAR} env vars, keeping runtime data on the persistent disk) and appends the
# --config flag. Logs each decision so a failed start is diagnosable in Render's
# logs. Privilege dropping is handled below, for every root start (not just here).
if [ "$RENDER" = "true" ]; then
echo "[entrypoint] Render deploy — starting as $(id)"
mkdir -p "$dir" || echo "[entrypoint] warning: mkdir $dir failed"
config="$dir/config.json"
# Initialize config only when it does not already exist, so WebUI/provider
# settings edited at runtime survive restarts. The disk persists config.json
# across deploys; overwriting it every boot would discard those changes.
if [ ! -f "$config" ]; then
echo "[entrypoint] initializing $config from render-config.json"
cp /app/render-config.json "$config" || echo "[entrypoint] warning: cp config failed"
else
echo "[entrypoint] existing $config found — leaving it in place"
fi
set -- "$@" --config "$config"
fi
# Drop privileges whenever the container starts as root. Render mounts the
# persistent disk root-owned, and a plain `docker run` also defaults to root now,
# so this covers both. Chown the data dir so the non-root user can write it, then
# re-exec as nanobot. Fail closed: if the privilege drop cannot be performed,
# exit rather than run the agent as root.
if [ "$(id -u)" = "0" ]; then
chown -R nanobot:nanobot "$dir" 2>/dev/null || echo "[entrypoint] warning: chown $dir failed"
if setpriv --reuid=nanobot --regid=nanobot --init-groups true 2>/dev/null; then
echo "[entrypoint] dropping privileges to nanobot via setpriv"
exec setpriv --reuid=nanobot --regid=nanobot --init-groups nanobot "$@"
fi
echo "[entrypoint] error: started as root but setpriv privilege drop failed — refusing to run as root" >&2
exit 1
fi
# Already non-root: make sure the data dir is writable before starting.
if [ -d "$dir" ] && [ ! -w "$dir" ]; then if [ -d "$dir" ] && [ ! -w "$dir" ]; then
owner_uid=$(stat -c %u "$dir" 2>/dev/null || stat -f %u "$dir" 2>/dev/null) owner_uid=$(stat -c %u "$dir" 2>/dev/null || stat -f %u "$dir" 2>/dev/null)
cat >&2 <<EOF cat >&2 <<EOF
@ -12,4 +51,5 @@ Fix (pick one):
EOF EOF
exit 1 exit 1
fi fi
exec nanobot "$@" exec nanobot "$@"

View File

@ -4,7 +4,7 @@ Triggered automatically by `python -m build` (and any other hatch-driven build)
so published wheels and sdists ship a fresh webui without requiring developers so published wheels and sdists ship a fresh webui without requiring developers
to remember `cd webui && bun run build` beforehand. to remember `cd webui && bun run build` beforehand.
Behaviour: Behavior:
- Skips for editable installs (`pip install -e .`). Editable mode is for Python - Skips for editable installs (`pip install -e .`). Editable mode is for Python
development; webui contributors use `cd webui && bun run dev` (Vite HMR) and development; webui contributors use `cd webui && bun run dev` (Vite HMR) and
@ -12,7 +12,7 @@ Behaviour:
- No-op when `webui/package.json` is absent (e.g. installing from an sdist that - No-op when `webui/package.json` is absent (e.g. installing from an sdist that
already contains a prebuilt `nanobot/web/dist/`). already contains a prebuilt `nanobot/web/dist/`).
- Skips when `NANOBOT_SKIP_WEBUI_BUILD=1` is set. - Skips when `NANOBOT_SKIP_WEBUI_BUILD=1` is set.
- Skips when `nanobot/web/dist/index.html` already exists, unless - Reuses `nanobot/web/dist/` only when it is already fresh, unless
`NANOBOT_FORCE_WEBUI_BUILD=1` is set. `NANOBOT_FORCE_WEBUI_BUILD=1` is set.
- Uses `bun` when available, otherwise falls back to `npm`. The chosen tool - Uses `bun` when available, otherwise falls back to `npm`. The chosen tool
performs `install` followed by `run build`. performs `install` followed by `run build`.
@ -21,12 +21,22 @@ Behaviour:
from __future__ import annotations from __future__ import annotations
import os import os
import shutil import sys
import subprocess
from pathlib import Path from pathlib import Path
from types import ModuleType
from hatchling.builders.hooks.plugin.interface import BuildHookInterface from hatchling.builders.hooks.plugin.interface import BuildHookInterface
_PROJECT_ROOT = Path(__file__).resolve().parent
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
def _load_webui_build_module() -> ModuleType:
from nanobot.webui import build as webui_build
return webui_build
class WebUIBuildHook(BuildHookInterface): class WebUIBuildHook(BuildHookInterface):
PLUGIN_NAME = "webui-build" PLUGIN_NAME = "webui-build"
@ -58,24 +68,32 @@ class WebUIBuildHook(BuildHookInterface):
) )
return return
webui_build = _load_webui_build_module()
status = webui_build.inspect_webui_bundle(source_dir=webui_dir, dist_dir=dist_dir)
force = os.environ.get("NANOBOT_FORCE_WEBUI_BUILD") == "1" force = os.environ.get("NANOBOT_FORCE_WEBUI_BUILD") == "1"
if index_html.is_file() and not force: if not status.needs_build and not force:
self.app.display_info( self.app.display_info(
f"[webui-build] reusing existing build at {dist_dir} " f"[webui-build] reusing existing build at {dist_dir} "
"(set NANOBOT_FORCE_WEBUI_BUILD=1 to rebuild)" "(already fresh; set NANOBOT_FORCE_WEBUI_BUILD=1 to rebuild)"
) )
return return
runner = self._pick_runner() if status.needs_build and not force:
if runner is None: self.app.display_info(
raise RuntimeError( f"[webui-build] {webui_build.describe_webui_bundle_status(status)}"
"[webui-build] neither `bun` nor `npm` is available on PATH; "
"install one or set NANOBOT_SKIP_WEBUI_BUILD=1 to bypass."
) )
self.app.display_info(f"[webui-build] using {runner} to build webui") try:
self._run([runner, "install"], cwd=webui_dir) webui_build.build_webui_bundle(
self._run([runner, "run", "build"], cwd=webui_dir) source_dir=webui_dir,
dist_dir=dist_dir,
output=self.app.display_info,
)
except webui_build.WebUIBuildError as exc:
raise RuntimeError(
"[webui-build] "
f"{exc}. Install `bun` or `npm`, or set NANOBOT_SKIP_WEBUI_BUILD=1 to bypass."
) from exc
if not index_html.is_file(): if not index_html.is_file():
raise RuntimeError( raise RuntimeError(
@ -83,19 +101,3 @@ class WebUIBuildHook(BuildHookInterface):
"check webui/vite.config.ts outDir." "check webui/vite.config.ts outDir."
) )
self.app.display_info(f"[webui-build] webui ready at {dist_dir}") self.app.display_info(f"[webui-build] webui ready at {dist_dir}")
@staticmethod
def _pick_runner() -> str | None:
for candidate in ("bun", "npm"):
if shutil.which(candidate):
return candidate
return None
def _run(self, cmd: list[str], *, cwd: Path) -> None:
self.app.display_info(f"[webui-build] $ {' '.join(cmd)} (cwd={cwd})")
try:
subprocess.run(cmd, cwd=cwd, check=True)
except subprocess.CalledProcessError as exc:
raise RuntimeError(
f"[webui-build] command failed ({exc.returncode}): {' '.join(cmd)}"
) from exc

54
images/nanobot_logo.svg Normal file
View File

@ -0,0 +1,54 @@
<svg
width="1060"
height="220"
viewBox="0 0 1060 220"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<title>nanobot</title>
<g transform="translate(16 20) scale(0.2507)">
<path d="M229.029 127.134C308.64 112.113 354.143 106.879 379.029 108.134V716.634L272.029 715.634C251.029 715.634 243.029 702.634 201.529 678.134L54.5291 581.634C30.0291 565.134 23.9802 560.075 13.0291 549.134C3.52914 537.634 -1.97086 526.634 3.52914 481.634L28.0291 340.634L29.5291 27.1337C31.0291 -2.36625 53.0291 -6.86625 77.0291 12.6337L229.029 127.134Z" fill="#F4A949" stroke="#F4A949"/>
<path d="M529.842 126.817C450.231 111.796 404.728 106.562 379.842 107.817V716.317L486.842 715.317C509.342 714.317 570.342 661.817 611.842 637.317L704.342 581.317C728.842 564.817 734.891 559.759 745.842 548.817C755.342 537.317 760.842 526.317 755.342 481.317L730.842 340.317L729.342 26.817C727.842 -2.68287 705.842 -7.18287 681.842 12.3171L529.842 126.817Z" fill="#EF8E30" stroke="#EF8E30"/>
<path d="M143.342 497.317H1.84164C-6.15857 550.317 22.8417 557.817 56.3419 582.817L143.342 497.317Z" fill="#E27223" stroke="#DF6E22"/>
<path d="M615.342 496.817H757.001C765.002 549.817 735.842 557.317 702.342 582.317L615.342 496.817Z" fill="#D96016" stroke="#D45F16"/>
<path d="M379.342 716.317V517.817H288.342C239.842 517.817 243.342 531.817 144.842 640.817L233.342 698.817C245.302 707.847 260.342 717.317 275.342 715.817L379.342 716.317Z" fill="#FBCB89" stroke="#FBCB8A"/>
<path d="M566.842 382.817C561.842 348.817 509.842 341.317 501.342 382.817V439.317C509.842 477.317 559.342 478.317 566.842 439.317V382.817Z" fill="#B94D0B" stroke="#B5490B"/>
<path d="M379.342 716.317V517.817H470.342C518.842 517.817 513.342 528.317 611.842 637.317L522.842 698.817C510.881 707.847 495.342 715.817 483.342 715.817L379.342 716.317Z" fill="#F7B066" stroke="#F8B166"/>
<path d="M258.842 383.199C253.842 349.199 201.842 341.699 193.342 383.199V439.699C201.842 477.699 251.342 478.699 258.842 439.699V383.199Z" fill="#B94D0B" stroke="#B94D0B"/>
<path d="M439.342 517.817H318.342L379.842 583.317L439.342 517.817Z" fill="#C85513" stroke="#C85513"/>
<path d="M379.342 583.317V517.817H438.842L379.342 583.317Z" fill="#BA470A" stroke="#B94D0B"/>
<path d="M367.842 304.817L339.842 109.817C369.864 107.082 387.219 106.437 420.842 109.817L391.342 304.817C382.555 322.184 376.628 321.255 367.842 304.817Z" fill="#D35E14" stroke="#D35E14"/>
<path d="M446.412 112.822C473.271 116.662 491.893 119.703 529.928 126.325L530.604 126.442L530.284 127.05L529.842 126.817L530.283 127.051C530.283 127.051 530.282 127.054 530.281 127.055C530.279 127.059 530.276 127.064 530.273 127.071C530.265 127.085 530.254 127.107 530.239 127.135C530.209 127.193 530.164 127.279 530.105 127.391C529.986 127.617 529.81 127.951 529.581 128.387C529.122 129.261 528.449 130.543 527.59 132.177C525.872 135.444 523.413 140.12 520.448 145.753C514.519 157.018 506.565 172.113 498.471 187.426C490.377 202.738 482.142 218.27 475.65 230.412C469.165 242.538 464.401 251.316 463.262 253.088C460.97 256.653 457.712 259.067 454.529 259.067C451.263 259.067 448.386 256.547 446.859 250.949C446.467 249.511 446.169 246.271 445.938 241.776C445.705 237.256 445.537 231.406 445.42 224.701C445.186 211.289 445.154 194.441 445.217 177.94C445.279 161.438 445.436 145.281 445.576 133.249C445.647 127.233 445.713 122.248 445.762 118.767C445.786 117.027 445.806 115.662 445.82 114.733C445.827 114.268 445.832 113.912 445.836 113.673C445.838 113.553 445.839 113.462 445.84 113.401C445.84 113.371 445.841 113.348 445.841 113.333C445.841 113.325 445.842 113.319 445.842 113.315C445.842 113.313 445.842 113.311 445.842 113.31C445.845 113.31 445.882 113.31 446.342 113.317L445.842 113.309L445.851 112.742L446.412 112.822Z" fill="#D35E14" stroke="#D35C15"/>
<path d="M311.842 251.317C314.842 240.317 313.842 112.817 313.842 112.817C281.05 117.181 262.657 120.321 229.842 126.817C229.842 126.817 291.842 246.317 296.342 253.317C300.842 260.317 308.842 262.317 311.842 251.317Z" fill="#DF6E23" stroke="#DA6D1F"/>
<path d="M562.842 166.317L686.842 67.8171V278.317L562.842 166.317Z" fill="#D66114" stroke="#D86116"/>
<path d="M196.342 166.317L72.3416 67.8171V278.317L196.342 166.317Z" fill="#E17125" stroke="#E27326"/>
<path d="M752.342 465.817L625.342 432.817L737.497 377.487L752.342 465.817Z" fill="#D66015"/>
<path d="M737.842 377.317L737.497 377.487M737.497 377.487L625.342 432.817L752.342 465.817L737.497 377.487Z" stroke="#D66115"/>
<path d="M6.34164 464.817L134.342 432.004L21.3031 376.986L6.34164 464.817Z" fill="#E06B1F"/>
<path d="M20.9558 376.817L21.3031 376.986M21.3031 376.986L134.342 432.004L6.34164 464.817L21.3031 376.986Z" stroke="#DF6E1E"/>
<path d="M379.842 317.775C376.246 317.475 372.636 313.145 368.342 305.112L340.342 110.112C355.495 108.732 367.422 107.884 379.842 107.817V317.775Z" fill="#E16D22" stroke="#E27225"/>
</g>
<g
fill="none"
stroke="#B94D0B"
stroke-width="26"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M260 164V78M260 118C260 91 276 77 299 77C323 77 339 93 339 119V164"/>
<path d="M450 164V78M450 121C450 95 433 77 408 77C383 77 366 95 366 121C366 146 383 164 408 164C433 164 450 146 450 121"/>
<path d="M490 164V78M490 118C490 91 506 77 529 77C553 77 569 93 569 119V164"/>
<path d="M686 121C686 147 670 164 644 164C618 164 602 147 602 121C602 94 618 77 644 77C670 77 686 94 686 121Z"/>
</g>
<g
fill="none"
stroke="#D96016"
stroke-width="26"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M730 34V164M731 121C731 94 747 77 773 77C799 77 815 94 815 121C815 147 799 164 773 164C747 164 731 147 731 121Z"/>
<path d="M934 121C934 147 918 164 892 164C866 164 850 147 850 121C850 94 866 77 892 77C918 77 934 94 934 121Z"/>
<path d="M1000 47V138C1000 156 1011 164 1028 164M969 78H1028"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.8 KiB

23
images/nanobot_mark.svg Normal file
View File

@ -0,0 +1,23 @@
<svg width="759" height="718" viewBox="0 0 759 718" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>nanobot mark</title>
<path d="M229.029 127.134C308.64 112.113 354.143 106.879 379.029 108.134V716.634L272.029 715.634C251.029 715.634 243.029 702.634 201.529 678.134L54.5291 581.634C30.0291 565.134 23.9802 560.075 13.0291 549.134C3.52914 537.634 -1.97086 526.634 3.52914 481.634L28.0291 340.634L29.5291 27.1337C31.0291 -2.36625 53.0291 -6.86625 77.0291 12.6337L229.029 127.134Z" fill="#F4A949" stroke="#F4A949"/>
<path d="M529.842 126.817C450.231 111.796 404.728 106.562 379.842 107.817V716.317L486.842 715.317C509.342 714.317 570.342 661.817 611.842 637.317L704.342 581.317C728.842 564.817 734.891 559.759 745.842 548.817C755.342 537.317 760.842 526.317 755.342 481.317L730.842 340.317L729.342 26.817C727.842 -2.68287 705.842 -7.18287 681.842 12.3171L529.842 126.817Z" fill="#EF8E30" stroke="#EF8E30"/>
<path d="M143.342 497.317H1.84164C-6.15857 550.317 22.8417 557.817 56.3419 582.817L143.342 497.317Z" fill="#E27223" stroke="#DF6E22"/>
<path d="M615.342 496.817H757.001C765.002 549.817 735.842 557.317 702.342 582.317L615.342 496.817Z" fill="#D96016" stroke="#D45F16"/>
<path d="M379.342 716.317V517.817H288.342C239.842 517.817 243.342 531.817 144.842 640.817L233.342 698.817C245.302 707.847 260.342 717.317 275.342 715.817L379.342 716.317Z" fill="#FBCB89" stroke="#FBCB8A"/>
<path d="M566.842 382.817C561.842 348.817 509.842 341.317 501.342 382.817V439.317C509.842 477.317 559.342 478.317 566.842 439.317V382.817Z" fill="#B94D0B" stroke="#B5490B"/>
<path d="M379.342 716.317V517.817H470.342C518.842 517.817 513.342 528.317 611.842 637.317L522.842 698.817C510.881 707.847 495.342 715.817 483.342 715.817L379.342 716.317Z" fill="#F7B066" stroke="#F8B166"/>
<path d="M258.842 383.199C253.842 349.199 201.842 341.699 193.342 383.199V439.699C201.842 477.699 251.342 478.699 258.842 439.699V383.199Z" fill="#B94D0B" stroke="#B94D0B"/>
<path d="M439.342 517.817H318.342L379.842 583.317L439.342 517.817Z" fill="#C85513" stroke="#C85513"/>
<path d="M379.342 583.317V517.817H438.842L379.342 583.317Z" fill="#BA470A" stroke="#B94D0B"/>
<path d="M367.842 304.817L339.842 109.817C369.864 107.082 387.219 106.437 420.842 109.817L391.342 304.817C382.555 322.184 376.628 321.255 367.842 304.817Z" fill="#D35E14" stroke="#D35E14"/>
<path d="M446.412 112.822C473.271 116.662 491.893 119.703 529.928 126.325L530.604 126.442L530.284 127.05L529.842 126.817L530.283 127.051C530.283 127.051 530.282 127.054 530.281 127.055C530.279 127.059 530.276 127.064 530.273 127.071C530.265 127.085 530.254 127.107 530.239 127.135C530.209 127.193 530.164 127.279 530.105 127.391C529.986 127.617 529.81 127.951 529.581 128.387C529.122 129.261 528.449 130.543 527.59 132.177C525.872 135.444 523.413 140.12 520.448 145.753C514.519 157.018 506.565 172.113 498.471 187.426C490.377 202.738 482.142 218.27 475.65 230.412C469.165 242.538 464.401 251.316 463.262 253.088C460.97 256.653 457.712 259.067 454.529 259.067C451.263 259.067 448.386 256.547 446.859 250.949C446.467 249.511 446.169 246.271 445.938 241.776C445.705 237.256 445.537 231.406 445.42 224.701C445.186 211.289 445.154 194.441 445.217 177.94C445.279 161.438 445.436 145.281 445.576 133.249C445.647 127.233 445.713 122.248 445.762 118.767C445.786 117.027 445.806 115.662 445.82 114.733C445.827 114.268 445.832 113.912 445.836 113.673C445.838 113.553 445.839 113.462 445.84 113.401C445.84 113.371 445.841 113.348 445.841 113.333C445.841 113.325 445.842 113.319 445.842 113.315C445.842 113.313 445.842 113.311 445.842 113.31C445.845 113.31 445.882 113.31 446.342 113.317L445.842 113.309L445.851 112.742L446.412 112.822Z" fill="#D35E14" stroke="#D35C15"/>
<path d="M311.842 251.317C314.842 240.317 313.842 112.817 313.842 112.817C281.05 117.181 262.657 120.321 229.842 126.817C229.842 126.817 291.842 246.317 296.342 253.317C300.842 260.317 308.842 262.317 311.842 251.317Z" fill="#DF6E23" stroke="#DA6D1F"/>
<path d="M562.842 166.317L686.842 67.8171V278.317L562.842 166.317Z" fill="#D66114" stroke="#D86116"/>
<path d="M196.342 166.317L72.3416 67.8171V278.317L196.342 166.317Z" fill="#E17125" stroke="#E27326"/>
<path d="M752.342 465.817L625.342 432.817L737.497 377.487L752.342 465.817Z" fill="#D66015"/>
<path d="M737.842 377.317L737.497 377.487M737.497 377.487L625.342 432.817L752.342 465.817L737.497 377.487Z" stroke="#D66115"/>
<path d="M6.34164 464.817L134.342 432.004L21.3031 376.986L6.34164 464.817Z" fill="#E06B1F"/>
<path d="M20.9558 376.817L21.3031 376.986M21.3031 376.986L134.342 432.004L6.34164 464.817L21.3031 376.986Z" stroke="#DF6E1E"/>
<path d="M379.842 317.775C376.246 317.475 372.636 313.145 368.342 305.112L340.342 110.112C355.495 108.732 367.422 107.884 379.842 107.817V317.775Z" fill="#E16D22" stroke="#E27225"/>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 657 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

View File

@ -6,6 +6,32 @@ import tomllib
from importlib.metadata import PackageNotFoundError from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as _pkg_version from importlib.metadata import version as _pkg_version
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from .agent.tools.context import RequestContext
from .bus.runtime_events import SessionTurnPersisted
from .nanobot import (
STREAM_EVENT_REASONING_COMPLETED,
STREAM_EVENT_REASONING_DELTA,
STREAM_EVENT_RUN_COMPLETED,
STREAM_EVENT_RUN_FAILED,
STREAM_EVENT_RUN_STARTED,
STREAM_EVENT_TEXT_COMPLETED,
STREAM_EVENT_TEXT_DELTA,
STREAM_EVENT_TOOL_COMPLETED,
STREAM_EVENT_TOOL_FAILED,
STREAM_EVENT_TOOL_STARTED,
STREAM_EVENT_TYPES,
Nanobot,
RunResult,
RunStream,
SessionInfo,
SessionSnapshot,
StreamEvent,
StreamEventType,
)
from .runtime_context import RuntimeContextBlock, RuntimeContextProvider
def _read_pyproject_version() -> str | None: def _read_pyproject_version() -> str | None:
@ -22,7 +48,7 @@ def _resolve_version() -> str:
return _pkg_version("nanobot-ai") return _pkg_version("nanobot-ai")
except PackageNotFoundError: except PackageNotFoundError:
# Source checkouts often import nanobot without installed dist-info. # Source checkouts often import nanobot without installed dist-info.
return _read_pyproject_version() or "0.2.2" return _read_pyproject_version() or "0.3.0"
__version__ = _resolve_version() __version__ = _resolve_version()
@ -32,6 +58,9 @@ _LAZY_EXPORTS = {
"Nanobot": ".nanobot", "Nanobot": ".nanobot",
"RunStream": ".nanobot", "RunStream": ".nanobot",
"RunResult": ".nanobot", "RunResult": ".nanobot",
"RequestContext": ".agent.tools.context",
"RuntimeContextBlock": ".runtime_context",
"RuntimeContextProvider": ".runtime_context",
"SessionInfo": ".nanobot", "SessionInfo": ".nanobot",
"SessionSnapshot": ".nanobot", "SessionSnapshot": ".nanobot",
"STREAM_EVENT_REASONING_COMPLETED": ".nanobot", "STREAM_EVENT_REASONING_COMPLETED": ".nanobot",
@ -47,10 +76,11 @@ _LAZY_EXPORTS = {
"STREAM_EVENT_TYPES": ".nanobot", "STREAM_EVENT_TYPES": ".nanobot",
"StreamEvent": ".nanobot", "StreamEvent": ".nanobot",
"StreamEventType": ".nanobot", "StreamEventType": ".nanobot",
"SessionTurnPersisted": ".bus.runtime_events",
} }
def __getattr__(name: str): def __getattr__(name: str) -> Any:
module_path = _LAZY_EXPORTS.get(name) module_path = _LAZY_EXPORTS.get(name)
if module_path is None: if module_path is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@ -64,6 +94,9 @@ def __getattr__(name: str):
__all__ = [ __all__ = [
"Nanobot", "Nanobot",
"RunResult", "RunResult",
"RequestContext",
"RuntimeContextBlock",
"RuntimeContextProvider",
"RunStream", "RunStream",
"SessionInfo", "SessionInfo",
"SessionSnapshot", "SessionSnapshot",
@ -80,4 +113,5 @@ __all__ = [
"STREAM_EVENT_TYPES", "STREAM_EVENT_TYPES",
"StreamEvent", "StreamEvent",
"StreamEventType", "StreamEventType",
"SessionTurnPersisted",
] ]

View File

@ -1,7 +1,14 @@
"""Agent core module.""" """Agent core module."""
from nanobot.agent.context import ContextBuilder from nanobot.agent.context import ContextBuilder
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext, CompositeHook from nanobot.agent.hook import (
AgentHook,
AgentHookContext,
AgentRunHookContext,
AgentTurnHookContext,
AgentTurnHookFactory,
CompositeHook,
)
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.agent.memory import MemoryStore from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader from nanobot.agent.skills import SkillsLoader
@ -11,6 +18,8 @@ __all__ = [
"AgentHook", "AgentHook",
"AgentHookContext", "AgentHookContext",
"AgentRunHookContext", "AgentRunHookContext",
"AgentTurnHookContext",
"AgentTurnHookFactory",
"AgentLoop", "AgentLoop",
"CompositeHook", "CompositeHook",
"ContextBuilder", "ContextBuilder",

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from collections.abc import Collection from collections.abc import Collection
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING, Callable, Coroutine from typing import TYPE_CHECKING, Any, Callable, Coroutine, cast
from loguru import logger from loguru import logger
@ -12,6 +12,7 @@ from nanobot.session.manager import Session, SessionManager
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.agent.memory import Consolidator from nanobot.agent.memory import Consolidator
from nanobot.utils.llm_runtime import LLMRuntime
class AutoCompact: class AutoCompact:
@ -30,9 +31,39 @@ class AutoCompact:
now: datetime | None = None) -> bool: now: datetime | None = None) -> bool:
if self._ttl <= 0 or not ts: if self._ttl <= 0 or not ts:
return False return False
try:
if isinstance(ts, str): if isinstance(ts, str):
ts = datetime.fromisoformat(ts) ts = datetime.fromisoformat(ts)
return ((now or datetime.now()) - ts).total_seconds() >= self._ttl * 60 current = now or datetime.now()
if getattr(ts, "tzinfo", None) is not None or current.tzinfo is not None:
idle_seconds = current.timestamp() - ts.timestamp()
else:
idle_seconds = (current - ts).total_seconds()
except (OSError, OverflowError, TypeError, ValueError):
# list_sessions() forwards raw persisted metadata; an unusable value
# must not escape the idle scan and stop the agent loop.
return False
return idle_seconds >= self._ttl * 60
def _has_compactable_idle_tail(self, key: str) -> bool:
session = self.sessions.get_or_create(key)
tail = list(session.messages[session.last_consolidated:])
if not tail:
return False
probe = Session(
key=session.key,
messages=tail,
created_at=session.created_at,
updated_at=session.updated_at,
metadata={},
last_consolidated=0,
)
result = probe.retain_recent_legal_suffix(
self._RECENT_SUFFIX_MESSAGES,
extend_to_user=True,
)
messages_to_remove = result.dropped[result.already_consolidated_count:]
return bool(messages_to_remove)
@staticmethod @staticmethod
def _format_summary(text: str, last_active: datetime) -> str: def _format_summary(text: str, last_active: datetime) -> str:
@ -42,8 +73,12 @@ class AutoCompact:
def _is_internal_session(cls, key: str) -> bool: def _is_internal_session(cls, key: str) -> bool:
return key.startswith(cls._INTERNAL_SESSION_PREFIXES) return key.startswith(cls._INTERNAL_SESSION_PREFIXES)
def check_expired(self, schedule_background: Callable[[Coroutine], None], def check_expired(
active_session_keys: Collection[str] = ()) -> None: self,
schedule_background: Callable[[Coroutine[Any, Any, None]], None],
resolve_runtime: Callable[[Session], LLMRuntime],
active_session_keys: Collection[str] = (),
) -> None:
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks.""" """Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
now = datetime.now() now = datetime.now()
for info in self.sessions.list_sessions(): for info in self.sessions.list_sessions():
@ -52,25 +87,34 @@ class AutoCompact:
continue continue
if key in active_session_keys: if key in active_session_keys:
continue continue
if self._is_expired(info.get("updated_at"), now): updated_at = info.get("updated_at")
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
session = self.sessions.get_or_create(key)
try:
runtime = resolve_runtime(session)
except (KeyError, ValueError):
# Invalid session selections remain recoverable through /model.
continue
self._archiving.add(key) self._archiving.add(key)
schedule_background(self._archive(key)) schedule_background(self._archive(key, runtime=runtime))
async def _archive(self, key: str) -> None: async def _archive(self, key: str, *, runtime: LLMRuntime) -> None:
if self._is_internal_session(key): if self._is_internal_session(key):
self._archiving.discard(key) self._archiving.discard(key)
return return
try: try:
summary = await self.consolidator.compact_idle_session( summary = await self.consolidator.compact_idle_session(
key, self._RECENT_SUFFIX_MESSAGES, key,
runtime=runtime,
max_suffix=self._RECENT_SUFFIX_MESSAGES,
) )
if summary and summary != "(nothing)": if summary and summary != "(nothing)":
session = self.sessions.get_or_create(key) session = self.sessions.get_or_create(key)
meta = session.metadata.get("_last_summary") meta = session.metadata.get("_last_summary")
if isinstance(meta, dict): if isinstance(meta, dict):
self._summaries[key] = ( self._summaries[key] = (
meta["text"], cast(str, meta["text"]),
datetime.fromisoformat(meta["last_active"]), datetime.fromisoformat(cast(str, meta["last_active"])),
) )
except Exception: except Exception:
logger.exception("Auto-compact: failed for {}", key) logger.exception("Auto-compact: failed for {}", key)
@ -90,7 +134,21 @@ class AutoCompact:
if entry: if entry:
return session, self._format_summary(entry[0], entry[1]) return session, self._format_summary(entry[0], entry[1])
# Cold path: summary persisted in session metadata (process restarted). # Cold path: summary persisted in session metadata (process restarted).
# Persisted metadata may outlive schema changes; a malformed summary must
# not abort turn preparation.
meta = session.metadata.get("_last_summary") meta = session.metadata.get("_last_summary")
if isinstance(meta, dict): if isinstance(meta, dict):
return session, self._format_summary(meta["text"], datetime.fromisoformat(meta["last_active"])) summary_meta = cast(dict[str, object], meta)
text = summary_meta.get("text")
if isinstance(text, str) and text:
raw_last_active = summary_meta.get("last_active")
try:
last_active = (
datetime.fromisoformat(raw_last_active)
if isinstance(raw_last_active, str)
else session.updated_at
)
except ValueError:
last_active = session.updated_at
return session, self._format_summary(text, last_active)
return session, None return session, None

View File

@ -0,0 +1,149 @@
"""Shared coordination for session-bound automation turns."""
from __future__ import annotations
import asyncio
import dataclasses
from collections.abc import Awaitable, Callable, Iterable
from nanobot.bus.events import InboundMessage, OutboundMessage
class AutomationTurnError(RuntimeError):
"""Raised when an automation turn reaches the agent and finishes with an error."""
async def publish_next_deferred_turn(
*,
deferred_queues: dict[str, list[InboundMessage]],
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
session_key: str,
) -> bool:
"""Publish the next deferred automation turn for a session."""
queue = deferred_queues.get(session_key)
if not queue:
return False
msg = queue.pop(0)
if not queue:
deferred_queues.pop(session_key, None)
await publish_inbound(msg)
return True
class AutomationTurnCoordinator:
"""Manage automation turns without mixing them into live injections."""
def __init__(
self,
*,
publish_inbound: Callable[[InboundMessage], Awaitable[None]],
dispatch: Callable[[InboundMessage], Awaitable[object]],
is_running: Callable[[], bool],
turn_id: Callable[[InboundMessage], str | None],
pending_id: Callable[[InboundMessage], str | None],
should_defer_turn: Callable[[InboundMessage, str, Iterable[str]], bool],
missing_id_error: str,
duplicate_id_error: Callable[[str], str],
deferred_queues: dict[str, list[InboundMessage]] | None = None,
) -> None:
self._publish_inbound = publish_inbound
self._dispatch = dispatch
self._is_running = is_running
self._turn_id = turn_id
self._pending_id = pending_id
self._should_defer_turn = should_defer_turn
self._missing_id_error = missing_id_error
self._duplicate_id_error = duplicate_id_error
self.deferred_queues = deferred_queues if deferred_queues is not None else {}
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {}
self._pending_messages_by_turn_id: dict[str, InboundMessage] = {}
async def submit(self, msg: InboundMessage) -> OutboundMessage | None:
"""Submit an automation turn and wait for its session response."""
turn_id = self._turn_id(msg)
if not turn_id:
raise ValueError(self._missing_id_error)
if turn_id in self._waiters:
raise RuntimeError(self._duplicate_id_error(turn_id))
loop = asyncio.get_running_loop()
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
self._waiters[turn_id] = future
self._pending_messages_by_turn_id[turn_id] = msg
try:
if self._is_running():
await self._publish_inbound(msg)
else:
await self._dispatch(msg)
try:
return await future
except asyncio.CancelledError:
raise
except AutomationTurnError:
raise
except Exception as exc:
raise AutomationTurnError(str(exc) or exc.__class__.__name__) from exc
finally:
self._waiters.pop(turn_id, None)
self._pending_messages_by_turn_id.pop(turn_id, None)
def defer_if_active(
self,
msg: InboundMessage,
*,
session_key: str,
active_session_keys: Iterable[str],
) -> bool:
"""Defer an automation turn when its target session is already active."""
if not self._should_defer_turn(msg, session_key, active_session_keys):
return False
pending_msg = msg
if session_key != msg.session_key:
pending_msg = dataclasses.replace(
msg,
session_key_override=session_key,
)
self.deferred_queues.setdefault(session_key, []).append(pending_msg)
return True
def complete(
self,
msg: InboundMessage,
*,
response: OutboundMessage | None = None,
error: BaseException | None = None,
) -> None:
turn_id = self._turn_id(msg)
if not turn_id:
return
future = self._waiters.get(turn_id)
if future is None or future.done():
return
if error is not None:
if isinstance(error, asyncio.CancelledError):
error = AutomationTurnError(str(error) or error.__class__.__name__)
future.set_exception(error)
else:
future.set_result(response)
def pending_ids_for_session(self, session_key: str) -> set[str]:
"""Return automation IDs that are waiting for or running in *session_key*."""
pending_ids: set[str] = set()
for msg in self.deferred_queues.get(session_key, []):
pending_id = self._pending_id(msg)
if pending_id:
pending_ids.add(pending_id)
for msg in self._pending_messages_by_turn_id.values():
if msg.session_key != session_key:
continue
pending_id = self._pending_id(msg)
if pending_id:
pending_ids.add(pending_id)
return pending_ids
async def publish_next_deferred(self, session_key: str) -> bool:
return await publish_next_deferred_turn(
deferred_queues=self.deferred_queues,
publish_inbound=self._publish_inbound,
session_key=session_key,
)

View File

@ -4,17 +4,24 @@ import base64
import mimetypes import mimetypes
import platform import platform
from pathlib import Path from pathlib import Path
from typing import Any, Mapping, Sequence from typing import Any, Mapping, Sequence, cast
from nanobot.agent.memory import MemoryStore from nanobot.agent.memory import MemoryStore
from nanobot.agent.skills import SkillsLoader from nanobot.agent.skills import SkillsLoader
from nanobot.agent.tools import image_generation as image_generation_tools
from nanobot.agent.tools import mcp as mcp_tools from nanobot.agent.tools import mcp as mcp_tools
from nanobot.agent.tools import sessions as session_tools
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
from nanobot.apps.cli import utils as cli_app_utils from nanobot.apps.cli import utils as cli_app_utils
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.session.goal_state import goal_state_runtime_lines from nanobot.runtime_context import (
RUNTIME_CONTEXT_END,
RUNTIME_CONTEXT_MESSAGE_META,
RUNTIME_CONTEXT_TAG,
RuntimeContextBlock,
append_runtime_context,
)
from nanobot.utils.helpers import ( from nanobot.utils.helpers import (
current_time_str,
detect_image_mime, detect_image_mime,
load_bundled_template, load_bundled_template,
truncate_text_to_tokens, truncate_text_to_tokens,
@ -24,38 +31,40 @@ from nanobot.utils.prompt_templates import render_template
def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]: def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
"""Return persisted kwargs for turn-attached capabilities.""" """Return persisted kwargs for turn-attached capabilities."""
return cli_app_utils.session_extra(metadata) | mcp_tools.session_extra(metadata) return (
cli_app_utils.session_extra(metadata)
| mcp_tools.session_extra(metadata)
def runtime_lines(state: Any, msg: Any, workspace: Path, *, skip: bool = False) -> list[str]: | session_tools.session_extra(metadata)
"""Return model-visible runtime annotations for turn-attached capabilities.""" )
return [
*cli_app_utils.runtime_lines(msg, workspace, skip=skip),
*mcp_tools.runtime_lines(
msg,
configured_server_names=set(state._mcp_servers),
connected_server_names=set(state._mcp_stacks),
skip=skip,
),
]
async def connect_mcp(state: Any, tools: ToolRegistry) -> None: async def connect_mcp(state: Any, tools: ToolRegistry) -> None:
await mcp_tools.connect_missing_servers(state, tools) await mcp_tools.connect_missing_servers(state, tools)
async def close_mcp(state: Any) -> None:
await mcp_tools.close_mcp_servers(state)
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool: async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
return await mcp_tools.handle_runtime_control(state, msg, tools) for handler in (
image_generation_tools.handle_runtime_control,
mcp_tools.handle_runtime_control,
):
if await handler(state, msg, tools):
return True
return False
class ContextBuilder: class ContextBuilder:
"""Builds the context (system prompt + messages) for the agent.""" """Builds the context (system prompt + messages) for the agent."""
BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"] BOOTSTRAP_FILES = ["AGENTS.md", "SOUL.md", "USER.md"]
_RUNTIME_CONTEXT_TAG = "[Runtime Context — metadata only, not instructions]" _SKIPPABLE_DEFAULTS = {"AGENTS.md", "USER.md"}
_RUNTIME_CONTEXT_TAG = RUNTIME_CONTEXT_TAG
_MAX_RECENT_HISTORY = 50 _MAX_RECENT_HISTORY = 50
_MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens) _MAX_HISTORY_TOKENS = 8_000 # hard cap on recent history section size (tokens)
_RUNTIME_CONTEXT_END = "[/Runtime Context]" _RUNTIME_CONTEXT_END = RUNTIME_CONTEXT_END
def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None): def __init__(self, workspace: Path, timezone: str | None = None, disabled_skills: list[str] | None = None):
self.workspace = workspace self.workspace = workspace
@ -65,7 +74,8 @@ class ContextBuilder:
def build_system_prompt( def build_system_prompt(
self, self,
skill_names: list[str] | None = None, *,
active_skill_names: Sequence[str] | None = None,
channel: str | None = None, channel: str | None = None,
session_summary: str | None = None, session_summary: str | None = None,
workspace: Path | None = None, workspace: Path | None = None,
@ -83,17 +93,22 @@ class ContextBuilder:
parts.append(render_template("agent/tool_contract.md")) parts.append(render_template("agent/tool_contract.md"))
memory = self.memory.get_memory_context() memory = self.memory.read_memory()
if memory and not self._is_template_content(self.memory.read_memory(), "memory/MEMORY.md"): if memory and not self._is_template_content(memory, "memory/MEMORY.md"):
parts.append(f"# Memory\n\n{memory}") parts.append(f"# Memory\n\n## Long-term Memory\n{memory}")
always_skills = self.skills.get_always_skills() active_skills = self.skills.get_always_skills()
if always_skills: active_skills.extend(
always_content = self.skills.load_skills_for_context(always_skills) name
if always_content: for name in (active_skill_names or ())
parts.append(f"# Active Skills\n\n{always_content}") if name not in active_skills
)
if active_skills:
active_content = self.skills.load_skills_for_context(active_skills)
if active_content:
parts.append(f"# Active Skills\n\n{active_content}")
skills_summary = self.skills.build_skills_summary(exclude=set(always_skills)) skills_summary = self.skills.build_skills_summary(exclude=set(active_skills))
if skills_summary: if skills_summary:
parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary)) parts.append(render_template("agent/skills_section.md", skills_summary=skills_summary))
@ -120,43 +135,36 @@ class ContextBuilder:
"""Get the core identity section.""" """Get the core identity section."""
root = workspace or self.workspace root = workspace or self.workspace
workspace_path = str(root.expanduser().resolve()) workspace_path = str(root.expanduser().resolve())
agent_workspace_path = str(self.workspace.expanduser().resolve())
system = platform.system() system = platform.system()
runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}" runtime = f"{'macOS' if system == 'Darwin' else system} {platform.machine()}, Python {platform.python_version()}"
return render_template( return render_template(
"agent/identity.md", "agent/identity.md",
workspace_path=workspace_path, workspace_path=workspace_path,
agent_workspace_path=agent_workspace_path,
runtime=runtime, runtime=runtime,
platform_policy=render_template("agent/platform_policy.md", system=system), platform_policy=render_template("agent/platform_policy.md", system=system),
channel=channel or "", channel=channel or "",
) )
@staticmethod
def _build_runtime_context(
channel: str | None,
chat_id: str | None,
timezone: str | None = None,
sender_id: str | None = None,
supplemental_lines: Sequence[str] | None = None,
) -> str:
"""Build untrusted runtime metadata block appended after user content."""
lines = [f"Current Time: {current_time_str(timezone)}"]
if channel and chat_id:
lines += [f"Channel: {channel}", f"Chat ID: {chat_id}"]
if sender_id:
lines += [f"Sender ID: {sender_id}"]
if supplemental_lines:
lines.extend(supplemental_lines)
return ContextBuilder._RUNTIME_CONTEXT_TAG + "\n" + "\n".join(lines) + "\n" + ContextBuilder._RUNTIME_CONTEXT_END
@staticmethod @staticmethod
def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]: def _merge_message_content(left: Any, right: Any) -> str | list[dict[str, Any]]:
if isinstance(left, str) and isinstance(right, str): if isinstance(left, str) and isinstance(right, str):
return f"{left}\n\n{right}" if left else right if not left:
return right
if not right:
return left
return f"{left}\n\n{right}"
def _to_blocks(value: Any) -> list[dict[str, Any]]: def _to_blocks(value: Any) -> list[dict[str, Any]]:
if isinstance(value, list): if isinstance(value, list):
return [item if isinstance(item, dict) else {"type": "text", "text": str(item)} for item in value] return [
cast(dict[str, Any], item)
if isinstance(item, dict)
else {"type": "text", "text": str(item)}
for item in cast(list[Any], value)
]
if value is None: if value is None:
return [] return []
return [{"type": "text", "text": str(value)}] return [{"type": "text", "text": str(value)}]
@ -164,14 +172,30 @@ class ContextBuilder:
return _to_blocks(left) + _to_blocks(right) return _to_blocks(left) + _to_blocks(right)
def _load_bootstrap_files(self, workspace: Path | None = None) -> str: def _load_bootstrap_files(self, workspace: Path | None = None) -> str:
"""Load all bootstrap files from workspace.""" """Load project instructions plus the agent's global profile files."""
parts = [] parts: list[str] = []
root = workspace or self.workspace project_root = workspace or self.workspace
sources = [
("AGENTS.md", project_root),
("SOUL.md", self.workspace),
("USER.md", self.workspace),
]
for filename in self.BOOTSTRAP_FILES: for filename, root in sources:
file_path = root / filename file_path = root / filename
if file_path.exists(): if file_path.exists():
content = file_path.read_text(encoding="utf-8") content = file_path.read_text(encoding="utf-8")
if filename == "SOUL.md" and self._is_template_content(
content,
"legacy/SOUL.md",
):
content = load_bundled_template("SOUL.md") or content
if not content.strip():
continue
if filename in self._SKIPPABLE_DEFAULTS and self._is_template_content(
content, filename
):
continue
parts.append(f"## {filename}\n\n{content}") parts.append(f"## {filename}\n\n{content}")
return "\n\n".join(parts) if parts else "" return "\n\n".join(parts) if parts else ""
@ -188,54 +212,29 @@ class ContextBuilder:
self, self,
history: list[dict[str, Any]], history: list[dict[str, Any]],
current_message: str, current_message: str,
skill_names: list[str] | None = None, *,
media: list[str] | None = None, media: list[str] | None = None,
channel: str | None = None, channel: str | None = None,
chat_id: str | None = None,
current_role: str = "user", current_role: str = "user",
sender_id: str | None = None,
session_summary: str | None = None, session_summary: str | None = None,
session_metadata: Mapping[str, Any] | None = None, runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
current_runtime_lines: Sequence[str] | None = None,
workspace: Path | None = None, workspace: Path | None = None,
runtime_state: Any | None = None,
inbound_message: Any | None = None,
skip_runtime_lines: bool = False,
include_memory_recent_history: bool = True, include_memory_recent_history: bool = True,
session_key: str | None = None, session_key: str | None = None,
unified_session: bool = False, unified_session: bool = False,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call.""" """Build the complete message list for an LLM call."""
root = workspace or self.workspace root = workspace or self.workspace
extra = [ active_skill_names = (
*goal_state_runtime_lines(session_metadata), self.skills.get_explicitly_invoked_skills(current_message)
] if current_role == "user"
if runtime_state is not None and inbound_message is not None: else []
extra.extend(runtime_lines(runtime_state, inbound_message, root, skip=skip_runtime_lines))
if current_runtime_lines:
extra.extend(line for line in current_runtime_lines if line)
runtime_ctx = self._build_runtime_context(
channel,
chat_id,
self.timezone,
sender_id=sender_id,
supplemental_lines=extra or None,
) )
user_content = self._build_user_content(current_message, media) messages: list[dict[str, Any]] = [
# Merge runtime context and user content into a single user message
# to avoid consecutive same-role messages that some providers reject.
# Runtime context is appended to keep the user-content prefix stable
# for prompt-cache hits (the context changes every turn due to time).
if isinstance(user_content, str):
merged = f"{user_content}\n\n{runtime_ctx}"
else:
merged = user_content + [{"type": "text", "text": runtime_ctx}]
messages = [
{ {
"role": "system", "role": "system",
"content": self.build_system_prompt( "content": self.build_system_prompt(
skill_names, active_skill_names=active_skill_names,
channel=channel, channel=channel,
session_summary=session_summary, session_summary=session_summary,
workspace=root, workspace=root,
@ -246,35 +245,74 @@ class ContextBuilder:
}, },
*history, *history,
] ]
current = self.build_current_message(
current_message,
media=media,
current_role=current_role,
runtime_context_blocks=runtime_context_blocks,
)
if messages[-1].get("role") == current_role: if messages[-1].get("role") == current_role:
last = dict(messages[-1]) last = dict(messages[-1])
last["content"] = self._merge_message_content(last.get("content"), merged) last["content"] = self._merge_message_content(
last.get("content"),
current.get("content"),
)
current_meta = current.get("_meta")
if current_role == "user" and isinstance(current_meta, dict):
internal_meta = dict(last.get("_meta") or {})
internal_meta.update(cast(dict[str, Any], current_meta))
last["_meta"] = internal_meta
messages[-1] = last messages[-1] = last
return messages return messages
messages.append({"role": current_role, "content": merged}) messages.append(current)
return messages return messages
def _build_user_content(self, text: str, media: list[str] | None) -> str | list[dict[str, Any]]: def build_current_message(
"""Build user message content with optional base64-encoded images.""" self,
if not media: current_message: str,
*,
media: list[str] | None = None,
current_role: str = "user",
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
) -> dict[str, Any]:
"""Build only the fresh turn message without merging it into history."""
content = self.build_user_content(current_message, image_paths=media)
blocks = list(runtime_context_blocks or ()) if current_role == "user" else []
merged, runtime_context_meta = append_runtime_context(content, blocks)
current: dict[str, Any] = {"role": current_role, "content": merged}
if current_role == "user" and runtime_context_meta is not None:
current["_meta"] = {
RUNTIME_CONTEXT_MESSAGE_META: runtime_context_meta,
}
return current
def build_user_content(
self,
text: str,
image_paths: list[str] | None,
) -> str | list[dict[str, Any]]:
"""Build user message content from prefiltered image paths."""
if not image_paths:
return text return text
images = [] image_blocks: list[dict[str, Any]] = []
for path in media: for path in image_paths:
p = Path(path) p = Path(path)
if not p.is_file(): if not p.is_file():
continue continue
raw = p.read_bytes() raw = p.read_bytes()
# Re-detect from the bytes used for the request: the file may have
# changed since attachment routing, and the data URL needs its MIME.
mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0] mime = detect_image_mime(raw) or mimetypes.guess_type(path)[0]
if not mime or not mime.startswith("image/"): if not mime or not mime.startswith("image/"):
continue continue
b64 = base64.b64encode(raw).decode() b64 = base64.b64encode(raw).decode()
images.append({ image_blocks.append({
"type": "image_url", "type": "image_url",
"image_url": {"url": f"data:{mime};base64,{b64}"}, "image_url": {"url": f"data:{mime};base64,{b64}"},
"_meta": {"path": str(p)}, "_meta": {"path": str(p)},
}) })
if not images: if not image_blocks:
return text return text
return images + [{"type": "text", "text": text}] return image_blocks + [{"type": "text", "text": text}]

View File

@ -0,0 +1,511 @@
"""Model-message governance for agent runner requests.
This module owns model-facing message shaping and tool-result content normalization.
It may return copied messages or persisted-result placeholders, but it must not
mutate an existing session history list in place.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
from loguru import logger
from nanobot.utils.helpers import (
estimate_message_tokens,
estimate_prompt_tokens_chain,
find_legal_message_start,
maybe_persist_tool_result,
truncate_text,
)
from nanobot.utils.runtime import ensure_nonempty_tool_result
if TYPE_CHECKING:
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.providers.base import LLMProvider
SNIP_SAFETY_BUFFER = 1024
MICROCOMPACT_MIN_CHARS = 500
INFLIGHT_COMPACT_TARGET_RATIO = 0.85
COMPACTABLE_TOOLS = frozenset({
"read_file", "exec", "grep", "find_files",
"web_search", "web_fetch", "list_dir", "list_exec_sessions",
})
# read_file is the recovery path for persisted results; exempting it prevents persist->read->persist loops.
TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS = frozenset({"read_file"})
BACKFILL_CONTENT = "[Tool result unavailable — call was interrupted or lost]"
PLACEHOLDER_TEXTS = frozenset({
"[Previous assistant message omitted.]",
})
def _tool_call_name_is_valid(tool_call: Any) -> bool:
"""Whether a persisted OpenAI-style tool_call carries a usable name.
Mirrors ``ToolCallRequest.has_valid_name`` for the dict shape stored in
message history: a degenerate call with ``name=None`` / ``""`` cannot be
executed and is rejected by upstream APIs if replayed.
"""
if not isinstance(tool_call, dict):
return False
tool_call_data = cast(dict[str, Any], tool_call)
fn = tool_call_data.get("function")
name = cast(dict[str, Any], fn).get("name") if isinstance(fn, dict) else tool_call_data.get("name")
return isinstance(name, str) and bool(name)
@dataclass(slots=True)
class ContextGovernanceConfig:
provider: LLMProvider
model: str
tools: ToolRegistry
workspace: Path | None
session_key: str | None
max_tool_result_chars: int
context_window_tokens: int | None = None
context_block_limit: int | None = None
max_tokens: int | None = None
inflight_start_index: int = 0
class ContextGovernor:
"""Prepare model-copy messages while preserving persisted history."""
def prepare_for_model(
self,
config: ContextGovernanceConfig,
messages: list[dict[str, Any]],
compacted_tool_call_ids: set[str],
) -> list[dict[str, Any]]:
updated = self.strip_placeholder_assistant_messages(messages)
updated = self.strip_malformed_tool_calls(updated)
updated = self.drop_orphan_tool_results(updated)
updated = self.backfill_missing_tool_results(updated)
updated = self.apply_tool_result_budget(config, updated)
updated = self.compact_inflight_overflow(config, updated, compacted_tool_call_ids)
updated = self.snip_history(config, updated)
updated = self.drop_orphan_tool_results(updated)
return self.backfill_missing_tool_results(updated)
@staticmethod
def input_budget(config: ContextGovernanceConfig) -> int:
if not config.context_window_tokens:
return 0
provider_max_tokens = getattr(
getattr(config.provider, "generation", None),
"max_tokens",
4096,
)
max_output = config.max_tokens if isinstance(config.max_tokens, int) else (
provider_max_tokens if isinstance(provider_max_tokens, int) else 4096
)
budget = config.context_block_limit or (
config.context_window_tokens - max_output - SNIP_SAFETY_BUFFER
)
return budget if budget > 0 else 0
@staticmethod
def normalize_tool_result(
config: ContextGovernanceConfig,
tool_call_id: str,
tool_name: str,
result: Any,
) -> Any:
result = ensure_nonempty_tool_result(tool_name, result)
if tool_name in TOOL_RESULT_OFFLOAD_EXEMPT_TOOLS:
return result
try:
content = maybe_persist_tool_result(
config.workspace,
config.session_key,
tool_call_id,
result,
max_chars=config.max_tool_result_chars,
)
except Exception:
logger.exception(
"Tool result persist failed for {} in {}; using raw result",
tool_call_id,
config.session_key or "default",
)
content = result
if isinstance(content, str) and len(content) > config.max_tool_result_chars:
return truncate_text(content, config.max_tool_result_chars)
return content
@staticmethod
def strip_placeholder_assistant_messages(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Remove assistant messages that are compaction placeholders.
Messages like ``[Previous assistant message omitted.]`` carry no useful
context for the model and can cause it to repeatedly attempt tool calls
that previously failed, producing malformed responses in a loop.
Consecutive same-role messages that result from removal are handled
downstream by the provider's merge-consecutive logic. Only the
model-facing copy is repaired; the persisted transcript is untouched
(a copy is returned, or the same list object when nothing changes).
"""
updated: list[dict[str, Any]] | None = None
for idx, msg in enumerate(messages):
if msg.get("role") != "assistant":
if updated is not None:
updated.append(msg)
continue
content = msg.get("content", "")
text = content if isinstance(content, str) else ""
is_placeholder = text.strip() in PLACEHOLDER_TEXTS
has_tool_calls = bool(msg.get("tool_calls"))
if is_placeholder and not has_tool_calls:
if updated is None:
updated = list(messages[:idx])
logger.debug(
"Stripping placeholder assistant message from history: {!r}",
text[:60],
)
continue
if updated is not None:
updated.append(msg)
if updated is None:
return messages
return updated
@staticmethod
def strip_malformed_tool_calls(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Drop persisted assistant tool_calls whose name is missing/non-string.
A degenerate tool call (``name=None`` or ``""``) that slipped into the
saved history before this guard existed gets replayed on every turn and
makes upstream APIs reject the whole request
(``messages.content.N.tool_use.name: Input should be a valid string``),
permanently wedging the session. Removing the bad call here lets the
existing orphan-result cleanup drop its now-dangling tool result, so a
polluted session self-heals on its next turn. The persisted transcript
is left untouched; only the model-facing copy is repaired (a copy is
returned, or the same list object when nothing changes).
"""
updated: list[dict[str, Any]] | None = None
for idx, msg in enumerate(messages):
if msg.get("role") != "assistant":
if updated is not None:
updated.append(msg)
continue
calls = msg.get("tool_calls")
if not calls:
if updated is not None:
updated.append(msg)
continue
kept = [tc for tc in cast(list[Any], calls) if _tool_call_name_is_valid(tc)]
if len(kept) == len(calls):
if updated is not None:
updated.append(msg)
continue
if updated is None:
updated = [dict(m) for m in messages[:idx]]
logger.warning(
"Stripping {} malformed tool_call(s) with missing/non-string "
"name from assistant history before request",
len(calls) - len(kept),
)
repaired = dict(msg)
if kept:
repaired["tool_calls"] = kept
else:
repaired.pop("tool_calls", None)
# An assistant turn with neither content nor any valid tool call is
# itself invalid upstream; drop it entirely in that case.
has_content = bool(repaired.get("content"))
if not kept and not has_content:
continue
updated.append(repaired)
if updated is None:
return messages
return updated
@staticmethod
def drop_orphan_tool_results(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Drop invalid tool results before history is sent back to providers."""
declared: set[str] = set()
fulfilled: set[str] = set()
updated: list[dict[str, Any]] | None = None
for idx, msg in enumerate(messages):
role = msg.get("role")
if role == "assistant":
for tc in cast(list[Any], msg.get("tool_calls") or []):
if isinstance(tc, dict):
tool_call = cast(dict[str, Any], tc)
if tool_call.get("id"):
declared.add(str(tool_call["id"]))
if role == "tool":
tid = msg.get("tool_call_id")
tid_str = str(tid) if tid else ""
if not tid_str or tid_str not in declared or tid_str in fulfilled:
if updated is None:
updated = [dict(m) for m in messages[:idx]]
continue
fulfilled.add(tid_str)
if updated is not None:
updated.append(dict(msg))
if updated is None:
return messages
return updated
@staticmethod
def backfill_missing_tool_results(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Insert synthetic error results for assistant tool_calls with missing tool outputs."""
declared: list[tuple[int, str, str]] = []
fulfilled: set[str] = set()
for idx, msg in enumerate(messages):
role = msg.get("role")
if role == "assistant":
for tc in cast(list[Any], msg.get("tool_calls") or []):
if isinstance(tc, dict):
name = ""
tool_call = cast(dict[str, Any], tc)
if tool_call.get("id"):
func = tool_call.get("function")
if isinstance(func, dict):
func_data = cast(dict[str, Any], func)
raw_name = func_data.get("name", "")
name = raw_name if isinstance(raw_name, str) else str(raw_name)
declared.append((idx, str(tool_call["id"]), name))
elif role == "tool":
tid = msg.get("tool_call_id")
if tid:
fulfilled.add(str(tid))
missing = [(ai, cid, name) for ai, cid, name in declared if cid not in fulfilled]
if not missing:
return messages
updated = list(messages)
offset = 0
for assistant_idx, call_id, name in missing:
insert_at = assistant_idx + 1 + offset
while insert_at < len(updated) and updated[insert_at].get("role") == "tool":
insert_at += 1
updated.insert(insert_at, {
"role": "tool",
"tool_call_id": call_id,
"name": name,
"content": BACKFILL_CONTENT,
})
offset += 1
return updated
def apply_tool_result_budget(
self,
config: ContextGovernanceConfig,
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
updated = messages
for idx, message in enumerate(messages):
if message.get("role") != "tool":
continue
normalized = self.normalize_tool_result(
config,
str(message.get("tool_call_id") or f"tool_{idx}"),
str(message.get("name") or "tool"),
message.get("content"),
)
if normalized != message.get("content"):
if updated is messages:
updated = [dict(m) for m in messages]
updated[idx]["content"] = normalized
return updated
def compact_inflight_overflow(
self,
config: ContextGovernanceConfig,
messages: list[dict[str, Any]],
compacted_tool_call_ids: set[str],
) -> list[dict[str, Any]]:
"""Compact in-flight tool results only when the request would overflow."""
budget = self.input_budget(config)
if budget <= 0:
return messages
tools = config.tools.get_definitions()
updated = self._apply_recorded_compactions(messages, compacted_tool_call_ids)
estimate, source = estimate_prompt_tokens_chain(
config.provider,
config.model,
updated,
tools,
)
if estimate <= budget:
return updated
target = int(budget * INFLIGHT_COMPACT_TARGET_RATIO)
candidates = self._inflight_compaction_candidates(
config,
updated,
compacted_tool_call_ids,
)
if not candidates:
return updated
for candidate_idx, (idx, tool_call_id) in enumerate(candidates):
is_newest_candidate = candidate_idx == len(candidates) - 1
if is_newest_candidate and estimate <= budget:
break
if tool_call_id in compacted_tool_call_ids:
continue
if updated is messages:
updated = [dict(m) for m in messages]
compacted_tool_call_ids.add(tool_call_id)
self._compact_tool_result_at(updated, idx)
estimate, source = estimate_prompt_tokens_chain(
config.provider,
config.model,
updated,
tools,
)
if estimate <= target:
break
logger.debug(
"In-flight context compaction for {}: prompt={} budget={} target={} via {}, ids={}",
config.session_key or "default",
estimate,
budget,
target,
source,
len(compacted_tool_call_ids),
)
return updated
def snip_history(
self,
config: ContextGovernanceConfig,
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
if not messages or not config.context_window_tokens:
return messages
budget = self.input_budget(config)
if budget <= 0:
return messages
tools = config.tools.get_definitions()
estimate, _ = estimate_prompt_tokens_chain(
config.provider,
config.model,
messages,
tools,
)
if estimate <= budget:
return messages
system_messages = [dict(msg) for msg in messages if msg.get("role") == "system"]
non_system = [dict(msg) for msg in messages if msg.get("role") != "system"]
if not non_system:
return messages
system_tokens = sum(estimate_message_tokens(msg) for msg in system_messages)
fixed_tokens, _ = estimate_prompt_tokens_chain(
config.provider,
config.model,
system_messages,
tools,
)
remaining_budget = max(0, budget - max(system_tokens, fixed_tokens))
kept: list[dict[str, Any]] = []
kept_tokens = 0
for message in reversed(non_system):
msg_tokens = estimate_message_tokens(message)
if kept and kept_tokens + msg_tokens > remaining_budget:
break
kept.append(message)
kept_tokens += msg_tokens
kept.reverse()
return system_messages + self._legal_history_tail(kept, non_system)
@staticmethod
def _tool_result_compaction_message(message: dict[str, Any]) -> str:
name = message.get("name", "tool")
return (
f"Error: The previous {name} result was compacted to fit context because it was too "
"large. Do not repeat the same call unchanged. Retry with a narrower path, query, "
"range, or result limit, use another tool, or tell the user the task cannot fit in "
"the available context."
)
def _legal_history_tail(
self,
kept: list[dict[str, Any]],
non_system: list[dict[str, Any]],
) -> list[dict[str, Any]]:
fallback = kept if kept else (non_system[-1:] if non_system else [])
kept = self._user_tail(kept) or self._user_tail(non_system, last=True) or fallback
start = find_legal_message_start(kept)
return kept[start:] if start else kept
@staticmethod
def _user_tail(messages: list[dict[str, Any]], *, last: bool = False) -> list[dict[str, Any]]:
indexes = range(len(messages) - 1, -1, -1) if last else range(len(messages))
for idx in indexes:
if messages[idx].get("role") == "user":
return messages[idx:]
return []
def _apply_recorded_compactions(
self,
messages: list[dict[str, Any]],
compacted_tool_call_ids: set[str],
) -> list[dict[str, Any]]:
if not compacted_tool_call_ids:
return messages
updated = messages
for idx, msg in enumerate(messages):
if msg.get("role") != "tool":
continue
tool_call_id = msg.get("tool_call_id")
if not tool_call_id or str(tool_call_id) not in compacted_tool_call_ids:
continue
compaction_message = self._tool_result_compaction_message(msg)
if msg.get("content") == compaction_message:
continue
if updated is messages:
updated = [dict(m) for m in messages]
updated[idx]["content"] = compaction_message
return updated
def _inflight_compaction_candidates(
self,
config: ContextGovernanceConfig,
messages: list[dict[str, Any]],
compacted_tool_call_ids: set[str],
) -> list[tuple[int, str]]:
compactable: list[tuple[int, str]] = []
for idx, msg in enumerate(messages):
if idx < config.inflight_start_index:
continue
if msg.get("role") != "tool" or msg.get("name") not in COMPACTABLE_TOOLS:
continue
tool_call_id = msg.get("tool_call_id")
if not tool_call_id or str(tool_call_id) in compacted_tool_call_ids:
continue
content = msg.get("content")
if not isinstance(content, str) or len(content) < MICROCOMPACT_MIN_CHARS:
continue
compactable.append((idx, str(tool_call_id)))
return compactable
def _compact_tool_result_at(self, messages: list[dict[str, Any]], idx: int) -> None:
messages[idx]["content"] = self._tool_result_compaction_message(messages[idx])

View File

@ -2,11 +2,10 @@
from __future__ import annotations from __future__ import annotations
import asyncio
import dataclasses
from collections.abc import Awaitable, Callable, Iterable from collections.abc import Awaitable, Callable, Iterable
from nanobot.bus.events import InboundMessage, OutboundMessage from nanobot.agent.automation_turns import AutomationTurnCoordinator
from nanobot.bus.events import InboundMessage
from nanobot.cron.session_turns import ( from nanobot.cron.session_turns import (
cron_run_id, cron_run_id,
cron_trigger, cron_trigger,
@ -14,7 +13,7 @@ from nanobot.cron.session_turns import (
) )
class CronTurnCoordinator: class CronTurnCoordinator(AutomationTurnCoordinator):
"""Manage scheduled cron turns without mixing them into live injections.""" """Manage scheduled cron turns without mixing them into live injections."""
def __init__( def __init__(
@ -23,115 +22,31 @@ class CronTurnCoordinator:
publish_inbound: Callable[[InboundMessage], Awaitable[None]], publish_inbound: Callable[[InboundMessage], Awaitable[None]],
dispatch: Callable[[InboundMessage], Awaitable[object]], dispatch: Callable[[InboundMessage], Awaitable[object]],
is_running: Callable[[], bool], is_running: Callable[[], bool],
deferred_queues: dict[str, list[InboundMessage]] | None = None,
) -> None: ) -> None:
self._publish_inbound = publish_inbound super().__init__(
self._dispatch = dispatch publish_inbound=publish_inbound,
self._is_running = is_running dispatch=dispatch,
self.deferred_queues: dict[str, list[InboundMessage]] = {} is_running=is_running,
self._waiters: dict[str, asyncio.Future[OutboundMessage | None]] = {} turn_id=lambda msg: cron_run_id(msg.metadata),
self._pending_messages_by_run_id: dict[str, InboundMessage] = {} pending_id=_cron_job_id,
should_defer_turn=_should_defer_cron_turn,
async def submit(self, msg: InboundMessage) -> OutboundMessage | None: missing_id_error="cron turn metadata must include a run_id",
"""Submit a scheduled cron turn and wait for its session response.""" duplicate_id_error=lambda run_id: f"cron run {run_id!r} is already pending",
run_id = cron_run_id(msg.metadata) deferred_queues=deferred_queues,
if not run_id:
raise ValueError("cron turn metadata must include a run_id")
if run_id in self._waiters:
raise RuntimeError(f"cron run {run_id!r} is already pending")
loop = asyncio.get_running_loop()
future: asyncio.Future[OutboundMessage | None] = loop.create_future()
self._waiters[run_id] = future
self._pending_messages_by_run_id[run_id] = msg
try:
if self._is_running():
await self._publish_inbound(msg)
else:
await self._dispatch(msg)
return await future
finally:
self._waiters.pop(run_id, None)
self._pending_messages_by_run_id.pop(run_id, None)
def should_defer(
self,
msg: InboundMessage,
*,
session_key: str,
active_session_keys: Iterable[str],
) -> bool:
return (
defer_cron_until_session_idle(msg.metadata)
and session_key in active_session_keys
) )
def defer_if_active(
self,
msg: InboundMessage,
*,
session_key: str,
active_session_keys: Iterable[str],
) -> bool:
"""Defer a cron turn when its target session is already active."""
if not self.should_defer(
msg,
session_key=session_key,
active_session_keys=active_session_keys,
):
return False
pending_msg = msg
if session_key != msg.session_key:
pending_msg = dataclasses.replace(
msg,
session_key_override=session_key,
)
self.defer(session_key, pending_msg)
return True
def complete(
self,
msg: InboundMessage,
*,
response: OutboundMessage | None = None,
error: BaseException | None = None,
) -> None:
run_id = cron_run_id(msg.metadata)
if not run_id:
return
future = self._waiters.get(run_id)
if future is None or future.done():
return
if error is not None:
future.set_exception(error)
else:
future.set_result(response)
def defer(self, session_key: str, msg: InboundMessage) -> None:
self.deferred_queues.setdefault(session_key, []).append(msg)
def pending_job_ids_for_session(self, session_key: str) -> set[str]: def pending_job_ids_for_session(self, session_key: str) -> set[str]:
"""Return cron jobs that are waiting for or running in *session_key*.""" """Return cron jobs that are waiting for or running in *session_key*."""
job_ids: set[str] = set() return self.pending_ids_for_session(session_key)
for msg in self.deferred_queues.get(session_key, []):
job_id = _cron_job_id(msg)
if job_id:
job_ids.add(job_id)
for msg in self._pending_messages_by_run_id.values():
if msg.session_key != session_key:
continue
job_id = _cron_job_id(msg)
if job_id:
job_ids.add(job_id)
return job_ids
async def publish_next_deferred(self, session_key: str) -> None:
queue = self.deferred_queues.get(session_key) def _should_defer_cron_turn(
if not queue: msg: InboundMessage,
return session_key: str,
msg = queue.pop(0) active_session_keys: Iterable[str],
if not queue: ) -> bool:
self.deferred_queues.pop(session_key, None) return defer_cron_until_session_idle(msg.metadata) and session_key in active_session_keys
await self._publish_inbound(msg)
def _cron_job_id(msg: InboundMessage) -> str | None: def _cron_job_id(msg: InboundMessage) -> str | None:

View File

@ -0,0 +1,29 @@
"""Turn-local permission for explicit sustained-goal mutations."""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
_GOAL_MUTATION_ALLOWED: ContextVar[bool] = ContextVar(
"nanobot_goal_mutation_allowed",
default=False,
)
def goal_mutation_allowed() -> bool:
return _GOAL_MUTATION_ALLOWED.get()
def revoke_goal_mutation_permission() -> None:
_GOAL_MUTATION_ALLOWED.set(False)
@contextmanager
def goal_mutation_permission(allowed: bool):
"""Bind goal permission for one agent-run or direct tool execution scope."""
token = _GOAL_MUTATION_ALLOWED.set(allowed)
try:
yield
finally:
_GOAL_MUTATION_ALLOWED.reset(token)

View File

@ -2,7 +2,9 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path
from typing import Any from typing import Any
from loguru import logger from loguru import logger
@ -23,6 +25,7 @@ class AgentHookContext:
tool_events: list[dict[str, str]] = field(default_factory=list) tool_events: list[dict[str, str]] = field(default_factory=list)
streamed_content: bool = False streamed_content: bool = False
streamed_reasoning: bool = False streamed_reasoning: bool = False
stream_continues_current_message: bool = False
final_content: str | None = None final_content: str | None = None
stop_reason: str | None = None stop_reason: str | None = None
error: str | None = None error: str | None = None
@ -44,6 +47,21 @@ class AgentRunHookContext:
exception: BaseException | None = None exception: BaseException | None = None
@dataclass(slots=True)
class AgentTurnHookContext:
"""Turn-local inputs available when constructing per-turn hooks."""
on_progress: Callable[..., Awaitable[None]] | None = None
workspace: Path | None = None
channel: str = "cli"
chat_id: str = "direct"
message_id: str | None = None
session_key: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)
ephemeral: bool = False
attributes: dict[str, Any] = field(default_factory=dict)
class AgentHook: class AgentHook:
"""Minimal lifecycle surface for shared runner customization.""" """Minimal lifecycle surface for shared runner customization."""
@ -74,9 +92,46 @@ class AgentHook:
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None: async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
pass pass
async def on_provider_tool_event(
self,
context: AgentHookContext,
event: dict[str, Any],
) -> None:
"""Observe a provider-hosted tool lifecycle event."""
pass
async def before_execute_tools(self, context: AgentHookContext) -> None: async def before_execute_tools(self, context: AgentHookContext) -> None:
pass pass
async def before_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
) -> None:
pass
async def after_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
result: Any,
) -> None:
pass
async def on_execute_tool_error(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
error: Any,
) -> None:
pass
async def emit_reasoning(self, reasoning_content: str | None) -> None: async def emit_reasoning(self, reasoning_content: str | None) -> None:
pass pass
@ -95,6 +150,9 @@ class AgentHook:
return content return content
AgentTurnHookFactory = Callable[[AgentTurnHookContext], AgentHook | None]
class CompositeHook(AgentHook): class CompositeHook(AgentHook):
"""Fan-out hook that delegates to an ordered list of hooks. """Fan-out hook that delegates to an ordered list of hooks.
@ -144,9 +202,59 @@ class CompositeHook(AgentHook):
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None: async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
await self._for_each_hook_safe("on_stream_end", context, resuming=resuming) await self._for_each_hook_safe("on_stream_end", context, resuming=resuming)
async def on_provider_tool_event(
self,
context: AgentHookContext,
event: dict[str, Any],
) -> None:
await self._for_each_hook_safe("on_provider_tool_event", context, event)
async def before_execute_tools(self, context: AgentHookContext) -> None: async def before_execute_tools(self, context: AgentHookContext) -> None:
await self._for_each_hook_safe("before_execute_tools", context) await self._for_each_hook_safe("before_execute_tools", context)
async def before_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
) -> None:
await self._for_each_hook_safe("before_execute_tool", context, tool_call, tool, params)
async def after_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
result: Any,
) -> None:
await self._for_each_hook_safe(
"after_execute_tool",
context,
tool_call,
tool,
params,
result,
)
async def on_execute_tool_error(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
error: Any,
) -> None:
await self._for_each_hook_safe(
"on_execute_tool_error",
context,
tool_call,
tool,
params,
error,
)
async def emit_reasoning(self, reasoning_content: str | None) -> None: async def emit_reasoning(self, reasoning_content: str | None) -> None:
await self._for_each_hook_safe("emit_reasoning", reasoning_content) await self._for_each_hook_safe("emit_reasoning", reasoning_content)

View File

@ -0,0 +1,11 @@
"""Concrete agent hook implementations."""
from nanobot.agent.hooks.file_edit_activity import (
FileEditActivityHook,
create_file_edit_activity_hook,
)
__all__ = [
"FileEditActivityHook",
"create_file_edit_activity_hook",
]

View File

@ -0,0 +1,139 @@
"""Agent hook that observes file-editing tools and emits file-edit activity."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any, cast
from nanobot.agent.hook import (
AgentHook,
AgentHookContext,
AgentRunHookContext,
AgentTurnHookContext,
)
from nanobot.providers.base import ToolCallRequest
from nanobot.utils.file_edit_events import (
FileEditTracker,
build_file_edit_end_event,
build_file_edit_error_event,
build_file_edit_start_event,
prepare_file_edit_trackers,
)
from nanobot.utils.progress_events import (
invoke_file_edit_progress,
on_progress_accepts_file_edit_events,
)
class FileEditActivityHook(AgentHook):
"""Translate file-editing tool lifecycle events into WebUI progress events."""
def __init__(
self,
*,
on_progress: Callable[..., Awaitable[None]] | None,
workspace: Path | None,
) -> None:
super().__init__()
self._on_progress = (
on_progress
if on_progress is not None and on_progress_accepts_file_edit_events(on_progress)
else None
)
self._workspace = workspace
self._trackers_by_call: dict[str, list[FileEditTracker]] = {}
async def before_iteration(self, context: AgentHookContext) -> None:
self._trackers_by_call.clear()
async def before_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
) -> None:
if self._on_progress is None or not isinstance(params, dict):
return
typed_params = cast(dict[str, Any], params)
trackers = prepare_file_edit_trackers(
call_id=tool_call.id,
tool_name=tool_call.name,
tool=tool,
workspace=self._workspace,
params=typed_params,
)
if not trackers:
return
self._trackers_by_call[self._tool_call_key(tool_call)] = trackers
await self._emit([
build_file_edit_start_event(tracker, typed_params)
for tracker in trackers
])
async def after_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
result: Any,
) -> None:
key = self._tool_call_key(tool_call)
trackers = self._trackers_by_call.get(key, [])
if trackers:
await self._emit([build_file_edit_end_event(tracker) for tracker in trackers])
self._trackers_by_call.pop(key, None)
async def on_execute_tool_error(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
error: Any,
) -> None:
key = self._tool_call_key(tool_call)
trackers = self._trackers_by_call.get(key, [])
if trackers:
await self._emit([
build_file_edit_error_event(tracker, str(error)) for tracker in trackers
])
self._trackers_by_call.pop(key, None)
async def on_finally(self, context: AgentRunHookContext) -> None:
if context.stop_reason != "cancelled" or not self._trackers_by_call:
return
trackers = [
tracker
for trackers in self._trackers_by_call.values()
for tracker in trackers
]
self._trackers_by_call.clear()
await self._emit([
build_file_edit_error_event(
tracker,
"Task interrupted before this tool finished.",
)
for tracker in trackers
])
async def _emit(self, events: list[dict[str, Any]]) -> None:
if self._on_progress is not None:
await invoke_file_edit_progress(self._on_progress, events)
@staticmethod
def _tool_call_key(tool_call: ToolCallRequest) -> str:
call_id = getattr(tool_call, "id", "") or ""
return f"{call_id}|{tool_call.name}" if call_id else f"{id(tool_call)}|{tool_call.name}"
def create_file_edit_activity_hook(context: AgentTurnHookContext) -> AgentHook | None:
"""Create the default file-edit observer for one agent turn."""
if context.on_progress is None:
return None
return FileEditActivityHook(
on_progress=context.on_progress,
workspace=context.workspace,
)

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,10 @@
"""Memory system: pure file I/O store and lightweight Consolidator.""" """Memory system: pure file I/O store and lightweight Consolidator."""
# Tool schemas are installed by the ``@tool_parameters`` class decorator at
# runtime; static analyzers cannot observe that it clears ``parameters`` from
# ``__abstractmethods__`` before these classes are instantiated.
# pyright: reportAbstractUsage=false, reportPrivateUsage=false
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
@ -11,13 +16,15 @@ import weakref
from contextlib import suppress from contextlib import suppress
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Iterator from typing import TYPE_CHECKING, Any, Callable, Iterator, cast
from loguru import logger from loguru import logger
from nanobot.session.manager import Session from nanobot.runtime_context import public_history_messages
from nanobot.session.manager import Session, SessionManager
from nanobot.utils.gitstore import GitStore from nanobot.utils.gitstore import GitStore
from nanobot.utils.helpers import ( from nanobot.utils.helpers import (
content_with_media_breadcrumbs,
ensure_dir, ensure_dir,
estimate_message_tokens, estimate_message_tokens,
estimate_prompt_tokens_chain, estimate_prompt_tokens_chain,
@ -28,20 +35,53 @@ from nanobot.utils.helpers import (
truncate_text_to_tokens, truncate_text_to_tokens,
) )
from nanobot.utils.prompt_templates import render_template from nanobot.utils.prompt_templates import render_template
from nanobot.utils.workspace_prompts import (
WORKSPACE_PROMPT_MAX_CHARS,
has_workspace_prompt_override,
load_workspace_prompt_override,
workspace_prompt_file,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider from nanobot.agent.tools.registry import ToolRegistry
from nanobot.session.manager import SessionManager from nanobot.utils.llm_runtime import LLMRuntime
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# MemoryStore — pure file I/O layer # MemoryStore — pure file I/O layer
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class DreamRunProgress:
"""Track tool failures that make a nominally completed Dream run unsafe to advance."""
def __init__(self) -> None:
self.had_tool_errors = False
async def __call__(
self,
*_args: Any,
tool_events: list[dict[str, Any]] | None = None,
**_kwargs: Any,
) -> None:
if any(
isinstance(cast(object, event), dict) and event.get("phase") == "error"
for event in tool_events or ()
):
self.had_tool_errors = True
class MemoryStore: class MemoryStore:
"""Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md.""" """Pure file I/O for memory files: MEMORY.md, history.jsonl, SOUL.md, USER.md."""
_DEFAULT_MAX_HISTORY = 1000 _DEFAULT_MAX_HISTORY = 1000
# Durable files whose real working-tree delta grounds Dream commit messages.
# Deliberately excludes memory/.dream_cursor so progress bookkeeping never
# appears as a durable-memory edit in the audit record.
_DREAM_CONTENT_PATHS = ("SOUL.md", "USER.md", "memory/MEMORY.md")
# Per-file cap when embedding current contents into the Dream prompt. The
# durable files are tiny in practice (~5 KB total), but a runaway file must
# not unbounded the prompt.
_DREAM_FILE_EMBED_CAP = 8000
_INTERNAL_HISTORY_SESSION_PREFIXES = ("cron:", "dream:") _INTERNAL_HISTORY_SESSION_PREFIXES = ("cron:", "dream:")
_INTERNAL_HISTORY_SESSION_KEYS = {"heartbeat"} _INTERNAL_HISTORY_SESSION_KEYS = {"heartbeat"}
_LEGACY_ENTRY_START_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2}[^\]]*)\]\s*") _LEGACY_ENTRY_START_RE = re.compile(r"^\[(\d{4}-\d{2}-\d{2}[^\]]*)\]\s*")
@ -64,6 +104,7 @@ class MemoryStore:
self._corruption_logged = False # rate-limit invalid cursor warning self._corruption_logged = False # rate-limit invalid cursor warning
self._malformed_entry_logged = False # rate-limit bad history shape warning self._malformed_entry_logged = False # rate-limit bad history shape warning
self._oversize_logged = False # rate-limit oversized-entry warning self._oversize_logged = False # rate-limit oversized-entry warning
self._dream_prompt_oversize_logged = False
self._append_lock = threading.Lock() # serialize cursor allocation + append self._append_lock = threading.Lock() # serialize cursor allocation + append
self._git = GitStore(workspace, tracked_files=[ self._git = GitStore(workspace, tracked_files=[
"SOUL.md", "USER.md", "memory/MEMORY.md", "memory/.dream_cursor", "SOUL.md", "USER.md", "memory/MEMORY.md", "memory/.dream_cursor",
@ -399,13 +440,33 @@ class MemoryStore:
] ]
def compact_history(self) -> None: def compact_history(self) -> None:
"""Drop oldest entries if the file exceeds *max_history_entries*.""" """Drop oldest processed entries without discarding pending Dream input."""
if self.max_history_entries <= 0: if self.max_history_entries <= 0:
return return
entries = self._read_entries() entries = self._read_entries()
if len(entries) <= self.max_history_entries: if len(entries) <= self.max_history_entries:
return return
kept = entries[-self.max_history_entries:] last_dream_cursor = self.get_last_dream_cursor()
first_unprocessed = next(
(
index
for index, entry in enumerate(entries)
if (
(cursor := self._valid_cursor(entry.get("cursor"))) is not None
and cursor > last_dream_cursor
)
),
len(entries),
)
keep_from = min(len(entries) - self.max_history_entries, first_unprocessed)
kept = entries[keep_from:]
if len(kept) > self.max_history_entries:
logger.warning(
"History compaction retained {} unprocessed entries beyond the configured "
"limit of {}",
len(kept),
self.max_history_entries,
)
self._write_entries(kept) self._write_entries(kept)
# -- JSONL helpers ------------------------------------------------------- # -- JSONL helpers -------------------------------------------------------
@ -419,9 +480,11 @@ class MemoryStore:
line = line.strip() line = line.strip()
if line: if line:
try: try:
entries.append(json.loads(line)) parsed: object = json.loads(line)
except json.JSONDecodeError: except json.JSONDecodeError:
continue continue
if isinstance(parsed, dict):
entries.append(cast(dict[str, Any], parsed))
return entries return entries
@ -439,7 +502,8 @@ class MemoryStore:
lines = [line for line in data.split("\n") if line.strip()] lines = [line for line in data.split("\n") if line.strip()]
if not lines: if not lines:
return None return None
return json.loads(lines[-1]) parsed: object = json.loads(lines[-1])
return cast(dict[str, Any], parsed) if isinstance(parsed, dict) else None
except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError): except (FileNotFoundError, json.JSONDecodeError, UnicodeDecodeError):
return None return None
@ -479,13 +543,52 @@ class MemoryStore:
def set_last_dream_cursor(self, cursor: int) -> None: def set_last_dream_cursor(self, cursor: int) -> None:
self._dream_cursor_file.write_text(str(cursor), encoding="utf-8") self._dream_cursor_file.write_text(str(cursor), encoding="utf-8")
def get_latest_cursor(self) -> int:
return max(self._next_cursor() - 1, 0)
@property
def dream_prompt_file(self) -> Path:
return workspace_prompt_file(self.workspace, "dream")
def has_dream_prompt_override(self) -> bool:
return has_workspace_prompt_override(self.dream_prompt_file)
@staticmethod
def default_dream_prompt() -> str:
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
return render_template(
"agent/dream.md",
strip=True,
skill_creator_path=str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md"),
)
def _dream_template(self) -> str:
text, original_chars = load_workspace_prompt_override(self.dream_prompt_file)
if text is not None:
if (
original_chars > WORKSPACE_PROMPT_MAX_CHARS
and not self._dream_prompt_oversize_logged
):
self._dream_prompt_oversize_logged = True
logger.warning(
"workspace Dream prompt exceeds {} chars ({}); truncating. "
"Further occurrences suppressed.",
WORKSPACE_PROMPT_MAX_CHARS, original_chars,
)
return text
return self.default_dream_prompt()
def build_dream_prompt(self, *, max_entries: int = 20) -> tuple[str, int] | None: def build_dream_prompt(self, *, max_entries: int = 20) -> tuple[str, int] | None:
"""Build the Dream prompt with unprocessed history context. """Build the Dream prompt with unprocessed history context.
Returns ``(prompt, last_cursor)`` or ``None`` if nothing to process. Returns ``(prompt, last_cursor)`` or ``None`` if nothing to process.
"""
from nanobot.agent.skills import BUILTIN_SKILLS_DIR
The current contents of the durable memory files (SOUL.md, USER.md,
memory/MEMORY.md) are embedded so the model edits the real files rather
than a stale mental model eliminating a class of failed/out-of-bounds
edits that previously produced hallucinated audit records.
"""
last_cursor = self.get_last_dream_cursor() last_cursor = self.get_last_dream_cursor()
entries = self.read_unprocessed_history(since_cursor=last_cursor) entries = self.read_unprocessed_history(since_cursor=last_cursor)
if not entries: if not entries:
@ -493,17 +596,50 @@ class MemoryStore:
batch = entries[:max_entries] batch = entries[:max_entries]
history_text = "\n".join( history_text = "\n".join(
f"[{e['timestamp']}] {truncate_text(e['content'], 500)}" f"[{e['timestamp']}] {truncate_text(e['content'], 1000)}"
for e in batch for e in batch
) )
skill_creator_path = str(BUILTIN_SKILLS_DIR / "skill-creator" / "SKILL.md") template = self._dream_template()
template = render_template( files_section = self._render_current_memory_files()
"agent/dream.md", strip=True, skill_creator_path=skill_creator_path, prompt = (
f"{template}\n\n{files_section}\n\n"
f"## Conversation History\n{history_text}"
) )
prompt = f"{template}\n\n## Conversation History\n{history_text}"
return (prompt, batch[-1]["cursor"]) return (prompt, batch[-1]["cursor"])
def build_dream_tools(self): def _render_current_memory_files(self) -> str:
"""Render the durable memory files' current contents for the Dream prompt.
Missing files render as ``(empty)``; oversized files are capped. The
section is the ground truth the model must edit against.
"""
files = [
("SOUL.md", self.soul_file),
("USER.md", self.user_file),
("memory/MEMORY.md", self.memory_file),
]
blocks: list[str] = []
for label, path in files:
try:
content = path.read_text(encoding="utf-8") if path.exists() else ""
except OSError:
content = ""
if len(content) > self._DREAM_FILE_EMBED_CAP:
content = truncate_text(content, self._DREAM_FILE_EMBED_CAP) + "\n...[truncated]"
blocks.append(f"### {label}\n{content}" if content.strip() else f"### {label}\n(empty)")
return "## Current Memory Files\n" + "\n\n".join(blocks)
def dream_content_diff(self) -> str:
"""Structured summary of uncommitted changes to the durable memory files.
Returns "" when git is unavailable or no content file changed. This is
the ground-truth input for diff-grounded Dream commit messages.
"""
if not self._git.is_initialized():
return ""
return self._git.summarize_working_tree(list(self._DREAM_CONTENT_PATHS))
def build_dream_tools(self) -> ToolRegistry:
"""Build the restricted tool registry used by Dream runs.""" """Build the restricted tool registry used by Dream runs."""
from nanobot.agent.skills import BUILTIN_SKILLS_DIR from nanobot.agent.skills import BUILTIN_SKILLS_DIR
from nanobot.agent.tools.apply_patch import ApplyPatchTool from nanobot.agent.tools.apply_patch import ApplyPatchTool
@ -541,40 +677,61 @@ class MemoryStore:
tools.register(WriteFileTool( tools.register(WriteFileTool(
workspace=workspace, workspace=workspace,
allowed_dir=skills_dir, allowed_dir=skills_dir,
extra_write_allowed_files=editable_files,
file_states=file_states, file_states=file_states,
)) ))
return tools return tools
@staticmethod @staticmethod
def dream_run_completed(resp: object | None) -> bool: def dream_run_completed(
"""Return True only when an ephemeral Dream agent turn completed cleanly.""" resp: object | None,
*,
had_tool_errors: bool = False,
) -> bool:
"""Return True only when a Dream turn completed without tool failures."""
metadata = getattr(resp, "metadata", None) metadata = getattr(resp, "metadata", None)
return isinstance(metadata, dict) and metadata.get("_stop_reason") == "completed" if had_tool_errors or not isinstance(metadata, dict):
return False
return cast(dict[str, Any], metadata).get("_stop_reason") == "completed"
# -- message formatting utility ------------------------------------------ # -- message formatting utility ------------------------------------------
@staticmethod @staticmethod
def _format_messages(messages: list[dict]) -> str: def _format_messages(messages: list[dict[str, Any]]) -> str:
lines = [] lines: list[str] = []
for message in messages: for message in messages:
if not message.get("content"): content = content_with_media_breadcrumbs(
continue message.get("role"),
tools = f" [tools: {', '.join(message['tools_used'])}]" if message.get("tools_used") else "" message.get("content", ""),
lines.append( message.get("media"),
f"[{message.get('timestamp', '?')[:16]}] {message['role'].upper()}{tools}: {message['content']}"
) )
if not content:
continue
tools_used = message.get("tools_used")
tools = (
f" [tools: {', '.join(cast(list[str], tools_used))}]"
if tools_used
else ""
)
raw_timestamp = message.get("timestamp")
timestamp = str(raw_timestamp) if raw_timestamp is not None else "?"
role = str(message.get("role") or "unknown")
lines.append(f"[{timestamp[:16]}] {role.upper()}{tools}: {content}")
return "\n".join(lines) return "\n".join(lines)
def raw_archive( def raw_archive(
self, self,
messages: list[dict], messages: list[dict[str, Any]],
*, *,
max_chars: int | None = None, max_chars: int | None = None,
session_key: str | None = None, session_key: str | None = None,
) -> None: ) -> None:
"""Fallback: dump raw messages to history.jsonl without LLM summarization.""" """Fallback: dump raw messages to history.jsonl without LLM summarization."""
limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS limit = max_chars if max_chars is not None else _RAW_ARCHIVE_MAX_CHARS
formatted = truncate_text(self._format_messages(messages), limit) formatted = truncate_text(
self._format_messages(public_history_messages(messages)),
limit,
)
self.append_history( self.append_history(
f"[RAW] {len(messages)} messages\n" f"[RAW] {len(messages)} messages\n"
f"{formatted}", f"{formatted}",
@ -594,23 +751,36 @@ class MemoryStore:
return f"dream:{datetime.now():%Y%m%d-%H%M%S}" return f"dream:{datetime.now():%Y%m%d-%H%M%S}"
@staticmethod @staticmethod
def build_dream_commit_message(prefix: str, resp: object | None) -> str: def build_dream_commit_message(prefix: str, diff_body: str) -> str:
"""Build a Dream auto-commit message, appending the LLM summary if present.""" """Build a Dream commit message grounded in the real working-tree diff.
msg = prefix
if resp is not None and getattr(resp, "content", None): *diff_body* is a structured, machine-derived summary of the actual file
msg = f"{msg}\n\n{resp.content.strip()}" changes (see :meth:`dream_content_diff` /
return msg :meth:`GitStore.summarize_working_tree`). The LLM narrative is
deliberately excluded so the audit record (``/dream-log``) reflects the
filesystem's truth, not the model's self-report.
An empty *diff_body* yields the bare *prefix*, which ``auto_commit``
turns into a no-op when there is nothing to stage.
"""
diff_body = (diff_body or "").strip()
if not diff_body:
return prefix
return f"{prefix}\n\n{diff_body}"
@staticmethod @staticmethod
def prune_dream_sessions(sessions_dir: Path, *, keep: int = 10) -> None: def prune_dream_sessions(sessions_dir: Path, *, keep: int = 10) -> None:
"""Remove the oldest Dream session files, keeping only the N most recent. """Remove the oldest Dream session files, keeping only the N most recent.
Only files matching ``dream_*.jsonl`` are considered. Non-dream session Only current base64url-encoded Dream session keys are considered.
files are never touched. Non-dream session files are never touched.
""" """
dream_files = sorted( dream_files: list[Path] = []
sessions_dir.glob("dream_*.jsonl"), key=lambda p: p.stat().st_mtime, for path in sessions_dir.glob("*.jsonl"):
) decoded_key = SessionManager.decode_storage_key(path.stem)
if decoded_key is not None and decoded_key.startswith("dream:"):
dream_files.append(path)
dream_files.sort(key=lambda p: p.stat().st_mtime)
if len(dream_files) <= keep: if len(dream_files) <= keep:
return return
@ -636,7 +806,7 @@ _HISTORY_ENTRY_HARD_CAP = 64_000 # emergency cap in append_history
class Consolidator: class Consolidator:
"""Lightweight consolidation: summarizes evicted messages into history.jsonl.""" """Summarize compacted messages into history.jsonl."""
_MAX_CONSOLIDATION_ROUNDS = 5 _MAX_CONSOLIDATION_ROUNDS = 5
@ -645,22 +815,14 @@ class Consolidator:
def __init__( def __init__(
self, self,
store: MemoryStore, store: MemoryStore,
provider: LLMProvider,
model: str,
sessions: SessionManager, sessions: SessionManager,
context_window_tokens: int,
build_messages: Callable[..., list[dict[str, Any]]], build_messages: Callable[..., list[dict[str, Any]]],
get_tool_definitions: Callable[[], list[dict[str, Any]]], get_tool_definitions: Callable[[], list[dict[str, Any]]],
max_completion_tokens: int = 4096,
consolidation_ratio: float = 0.5, consolidation_ratio: float = 0.5,
unified_session: bool = False, unified_session: bool = False,
): ):
self.store = store self.store = store
self.provider = provider
self.model = model
self.sessions = sessions self.sessions = sessions
self.context_window_tokens = context_window_tokens
self.max_completion_tokens = max_completion_tokens
self.consolidation_ratio = consolidation_ratio self.consolidation_ratio = consolidation_ratio
self.unified_session = unified_session self.unified_session = unified_session
self._build_messages = build_messages self._build_messages = build_messages
@ -669,17 +831,6 @@ class Consolidator:
weakref.WeakValueDictionary() weakref.WeakValueDictionary()
) )
def set_provider(
self,
provider: LLMProvider,
model: str,
context_window_tokens: int,
) -> None:
self.provider = provider
self.model = model
self.context_window_tokens = context_window_tokens
self.max_completion_tokens = provider.generation.max_tokens
def get_lock(self, session_key: str) -> asyncio.Lock: def get_lock(self, session_key: str) -> asyncio.Lock:
"""Return the shared consolidation lock for one session.""" """Return the shared consolidation lock for one session."""
return self._locks.setdefault(session_key, asyncio.Lock()) return self._locks.setdefault(session_key, asyncio.Lock())
@ -709,17 +860,12 @@ class Consolidator:
@staticmethod @staticmethod
def _full_unconsolidated_history( def _full_unconsolidated_history(
session: Session, session: Session,
*,
include_timestamps: bool = False,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Return the whole unconsolidated tail for consolidation decisions.""" """Return the whole unconsolidated tail for consolidation decisions."""
unconsolidated_count = len(session.messages) - session.last_consolidated unconsolidated_count = len(session.messages) - session.last_consolidated
if unconsolidated_count <= 0: if unconsolidated_count <= 0:
return [] return []
return session.get_history( return session.get_history(max_messages=unconsolidated_count)
max_messages=unconsolidated_count,
include_timestamps=include_timestamps,
)
@staticmethod @staticmethod
def _replay_overflow_boundary( def _replay_overflow_boundary(
@ -762,6 +908,8 @@ class Consolidator:
self, self,
session: Session, session: Session,
replay_max_messages: int | None, replay_max_messages: int | None,
*,
runtime: LLMRuntime,
) -> str | None: ) -> str | None:
"""Archive messages that would be hidden by the replay message window.""" """Archive messages that would be hidden by the replay message window."""
end_idx = self._replay_overflow_boundary(session, replay_max_messages) end_idx = self._replay_overflow_boundary(session, replay_max_messages)
@ -776,8 +924,13 @@ class Consolidator:
len(chunk), len(chunk),
replay_max_messages, replay_max_messages,
) )
summary = await self.archive(chunk, session_key=session.key) summary = await self.archive(
chunk,
runtime=runtime,
session_key=session.key,
)
session.last_consolidated = end_idx session.last_consolidated = end_idx
session.provider_state = None
self.sessions.save(session) self.sessions.save(session)
return summary return summary
@ -792,82 +945,98 @@ class Consolidator:
def estimate_session_prompt_tokens( def estimate_session_prompt_tokens(
self, self,
session: Session, session: Session,
*,
runtime: LLMRuntime,
) -> tuple[int, str]: ) -> tuple[int, str]:
"""Estimate prompt size from the full unconsolidated session tail.""" """Estimate prompt size from the full unconsolidated session tail."""
history = self._full_unconsolidated_history(session, include_timestamps=True) history = self._full_unconsolidated_history(session)
channel, chat_id = (session.key.split(":", 1) if ":" in session.key else (None, None)) channel = session.key.split(":", 1)[0] if ":" in session.key else None
# Include archived summary in estimation so the budget accounts for it. # Include archived summary in estimation so the budget accounts for it.
meta = session.metadata.get("_last_summary") meta = session.metadata.get("_last_summary")
summary = meta.get("text") if isinstance(meta, dict) else (meta if isinstance(meta, str) else None) summary = (
cast(dict[str, Any], meta).get("text")
if isinstance(meta, dict)
else meta
if isinstance(meta, str)
else None
)
probe_messages = self._build_messages( probe_messages = self._build_messages(
history=history, history=history,
current_message="[token-probe]", current_message="[token-probe]",
channel=channel, channel=channel,
chat_id=chat_id,
sender_id=None,
session_summary=summary, session_summary=summary,
session_metadata=session.metadata,
session_key=session.key, session_key=session.key,
unified_session=self.unified_session, unified_session=self.unified_session,
) )
return estimate_prompt_tokens_chain( return estimate_prompt_tokens_chain(
self.provider, runtime.provider,
self.model, runtime.model,
probe_messages, probe_messages,
self._get_tool_definitions(), self._get_tool_definitions(),
) )
@property def _input_token_budget(self, runtime: LLMRuntime) -> int:
def _input_token_budget(self) -> int:
"""Available input token budget for consolidation LLM.""" """Available input token budget for consolidation LLM."""
return self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER return (
runtime.context_window_tokens
- runtime.generation.max_tokens
- self._SAFETY_BUFFER
)
def _truncate_to_token_budget(self, text: str) -> str: def _truncate_to_token_budget(self, text: str, *, runtime: LLMRuntime) -> str:
"""Truncate text so it fits within the consolidation LLM's token budget.""" """Truncate text so it fits within the consolidation LLM's token budget."""
budget = self._input_token_budget budget = self._input_token_budget(runtime)
if budget <= 0: if budget <= 0:
return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS) return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
return truncate_text_to_tokens(text, budget) return truncate_text_to_tokens(text, budget)
async def archive( async def archive(
self, self,
messages: list[dict], messages: list[dict[str, Any]],
*, *,
runtime: LLMRuntime,
session_key: str | None = None, session_key: str | None = None,
summary_messages: list[dict] | None = None, summary_messages: list[dict[str, Any]] | None = None,
) -> str | None: ) -> str | None:
"""Summarize messages via LLM and append to history.jsonl. """Summarize messages and append the result to history.jsonl.
``messages`` are the messages being archived (removed from the live ``summary_messages`` adds context but is excluded from raw fallback.
session); they are what gets raw-dumped if the LLM call fails.
``summary_messages``, when given, lets callers include retained
messages in the summary without archiving them.
Returns the summary text on success, None if nothing to archive.
""" """
if not messages: if not messages:
return None return None
messages_to_summarize = summary_messages if summary_messages is not None else messages messages_to_summarize = public_history_messages(
try: summary_messages if summary_messages is not None else messages
)
formatted = MemoryStore._format_messages(messages_to_summarize) formatted = MemoryStore._format_messages(messages_to_summarize)
formatted = self._truncate_to_token_budget(formatted) formatted = self._truncate_to_token_budget(formatted, runtime=runtime)
response = await self.provider.chat_with_retry( system_prompt = render_template(
model=self.model, "agent/consolidator_archive.md",
strip=True,
)
try:
response = await runtime.provider.chat_with_retry(
model=runtime.model,
messages=[ messages=[
{ {
"role": "system", "role": "system",
"content": render_template( "content": system_prompt,
"agent/consolidator_archive.md",
strip=True,
),
}, },
{"role": "user", "content": formatted}, {"role": "user", "content": formatted},
], ],
tools=None, tools=None,
tool_choice=None, tool_choice=None,
temperature=runtime.generation.temperature,
max_tokens=runtime.generation.max_tokens,
reasoning_effort=runtime.generation.reasoning_effort,
) )
except Exception:
logger.warning("Consolidation provider call failed, raw-dumping to history")
self.store.raw_archive(messages, session_key=session_key)
return None
if response.finish_reason == "error": if response.finish_reason == "error":
raise RuntimeError(f"LLM returned error: {response.content}") logger.warning("Consolidation provider returned an error, raw-dumping to history")
self.store.raw_archive(messages, session_key=session_key)
return None
summary = response.content or "[no summary]" summary = response.content or "[no summary]"
self.store.append_history( self.store.append_history(
summary, summary,
@ -875,15 +1044,12 @@ class Consolidator:
session_key=session_key, session_key=session_key,
) )
return summary return summary
except Exception:
logger.warning("Consolidation LLM call failed, raw-dumping to history")
self.store.raw_archive(messages, session_key=session_key)
return None
async def maybe_consolidate_by_tokens( async def maybe_consolidate_by_tokens(
self, self,
session: Session, session: Session,
*, *,
runtime: LLMRuntime,
replay_max_messages: int | None = None, replay_max_messages: int | None = None,
) -> None: ) -> None:
"""Loop: archive old messages until prompt fits within safe budget. """Loop: archive old messages until prompt fits within safe budget.
@ -891,7 +1057,7 @@ class Consolidator:
The budget reserves space for completion tokens and a safety buffer The budget reserves space for completion tokens and a safety buffer
so the LLM request never exceeds the context window. so the LLM request never exceeds the context window.
""" """
if self.context_window_tokens <= 0: if runtime.context_window_tokens <= 0:
return return
lock = self.get_lock(session.key) lock = self.get_lock(session.key)
@ -903,19 +1069,17 @@ class Consolidator:
if not session.messages: if not session.messages:
return return
budget = self._input_token_budget budget = self._input_token_budget(runtime)
target = int(budget * self.consolidation_ratio) target = int(budget * self.consolidation_ratio)
last_summary = await self._consolidate_replay_overflow( last_summary = await self._consolidate_replay_overflow(
session, session,
replay_max_messages, replay_max_messages,
runtime=runtime,
) )
try:
estimated, source = self.estimate_session_prompt_tokens( estimated, source = self.estimate_session_prompt_tokens(
session, session,
runtime=runtime,
) )
except Exception:
logger.exception("Token estimation failed for {}", session.key)
estimated, source = 0, "error"
if estimated <= 0: if estimated <= 0:
self._persist_last_summary(session, last_summary) self._persist_last_summary(session, last_summary)
return return
@ -925,7 +1089,7 @@ class Consolidator:
"Token consolidation idle {}: {}/{} via {}, msgs={}", "Token consolidation idle {}: {}/{} via {}, msgs={}",
session.key, session.key,
estimated, estimated,
self.context_window_tokens, runtime.context_window_tokens,
source, source,
unconsolidated_count, unconsolidated_count,
) )
@ -956,11 +1120,15 @@ class Consolidator:
round_num, round_num,
session.key, session.key,
estimated, estimated,
self.context_window_tokens, runtime.context_window_tokens,
source, source,
len(chunk), len(chunk),
) )
summary = await self.archive(chunk, session_key=session.key) summary = await self.archive(
chunk,
runtime=runtime,
session_key=session.key,
)
# Advance the cursor either way: on success the chunk was # Advance the cursor either way: on success the chunk was
# summarized; on failure archive() already raw-archived it as # summarized; on failure archive() already raw-archived it as
# a breadcrumb. Re-archiving the same chunk on the next call # a breadcrumb. Re-archiving the same chunk on the next call
@ -968,19 +1136,17 @@ class Consolidator:
if summary: if summary:
last_summary = summary last_summary = summary
session.last_consolidated = end_idx session.last_consolidated = end_idx
session.provider_state = None
self.sessions.save(session) self.sessions.save(session)
if not summary: if not summary:
# LLM is degraded — stop hammering it this call; # LLM is degraded — stop hammering it this call;
# the next invocation can retry a fresh chunk. # the next invocation can retry a fresh chunk.
break break
try:
estimated, source = self.estimate_session_prompt_tokens( estimated, source = self.estimate_session_prompt_tokens(
session, session,
runtime=runtime,
) )
except Exception:
logger.exception("Token estimation failed for {}", session.key)
estimated, source = 0, "error"
if estimated <= 0: if estimated <= 0:
break break
@ -992,15 +1158,11 @@ class Consolidator:
async def compact_idle_session( async def compact_idle_session(
self, self,
session_key: str, session_key: str,
*,
runtime: LLMRuntime,
max_suffix: int = 8, max_suffix: int = 8,
) -> str | None: ) -> str | None:
"""Hard-truncate an idle session under the consolidation lock. """Archive an idle prefix and hide it from replay without deleting it."""
Used by AutoCompact so all session mutation goes through a single
lock-protected path. Returns the summary text on success, ``None``
if the LLM failed (raw_archive fallback), or ``""`` if there was
nothing to archive.
"""
lock = self.get_lock(session_key) lock = self.get_lock(session_key)
async with lock: async with lock:
self.sessions.invalidate(session_key) self.sessions.invalidate(session_key)
@ -1008,7 +1170,6 @@ class Consolidator:
messages_to_summarize = list(session.messages[session.last_consolidated:]) messages_to_summarize = list(session.messages[session.last_consolidated:])
if not messages_to_summarize: if not messages_to_summarize:
session.updated_at = datetime.now()
self.sessions.save(session) self.sessions.save(session)
return "" return ""
@ -1020,22 +1181,19 @@ class Consolidator:
metadata={}, metadata={},
last_consolidated=0, last_consolidated=0,
) )
dropped, already_consolidated = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True) result = probe.retain_recent_legal_suffix(max_suffix, extend_to_user=True)
messages_to_keep = probe.messages visible_suffix = probe.messages
messages_to_remove = dropped[already_consolidated:] messages_to_remove = result.dropped
if not messages_to_remove and not messages_to_keep: if not messages_to_remove:
session.updated_at = datetime.now()
self.sessions.save(session) self.sessions.save(session)
return "" return ""
last_active = session.updated_at last_active = session.updated_at
summary: str | None = "" # The visible suffix informs the summary but stays out of raw fallback.
if messages_to_remove:
# Summarize the retained suffix too, but only remove/raw-dump
# the messages that are no longer kept in the live session.
summary = await self.archive( summary = await self.archive(
messages_to_remove, messages_to_remove,
runtime=runtime,
session_key=session_key, session_key=session_key,
summary_messages=messages_to_summarize, summary_messages=messages_to_summarize,
) )
@ -1046,17 +1204,17 @@ class Consolidator:
"last_active": last_active.isoformat(), "last_active": last_active.isoformat(),
} }
session.messages = messages_to_keep # Preserve history and advance only the replay boundary.
session.last_consolidated = 0 session.last_consolidated = len(session.messages) - len(visible_suffix)
session.updated_at = datetime.now() session.provider_state = None
self.sessions.save(session) self.sessions.save(session)
if messages_to_remove:
logger.info( logger.info(
"Idle-session compact for {}: archived={}, kept={}, summary={}", "Idle-session compact for {}: archived={}, visible={}, retained={}, summary={}",
session_key, session_key,
len(messages_to_remove), len(messages_to_remove),
len(messages_to_keep), len(visible_suffix),
len(session.messages),
bool(summary), bool(summary),
) )

View File

@ -2,26 +2,45 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable, Mapping
from typing import Any from dataclasses import replace
from pathlib import Path
from nanobot.config.schema import ModelPresetConfig from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.base import LLMProvider from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot
PresetSnapshotLoader = Callable[[str], ProviderSnapshot] PresetSnapshotLoader = Callable[[str], ProviderSnapshot]
PresetCatalogLoader = Callable[[], Mapping[str, ModelPresetConfig]]
def default_selection_signature(signature: tuple[object, ...] | None) -> tuple[object, ...] | None: def default_selection_signature(
return signature[:2] if signature else None signature: tuple[object, ...] | None,
model_preset: str | None = None,
) -> tuple[object, ...] | None:
return (model_preset, *signature[:2]) if signature else None
def configured_model_presets(config: Any) -> dict[str, ModelPresetConfig]: def configured_model_presets(config: Config) -> dict[str, ModelPresetConfig]:
return {**config.model_presets, "default": config.resolve_default_preset()} return {**config.model_presets, "default": config.resolve_default_preset()}
def load_model_preset_catalog(
config_path: Path | None = None,
) -> dict[str, ModelPresetConfig]:
"""Load the current preset catalog from the configured file."""
from nanobot.config.loader import load_config, resolve_config_env_vars
return configured_model_presets(
resolve_config_env_vars(
load_config(config_path),
config_path=config_path,
),
)
def make_preset_snapshot_loader( def make_preset_snapshot_loader(
config: Any, config: Config,
provider_snapshot_loader: Callable[..., ProviderSnapshot] | None, provider_snapshot_loader: Callable[..., ProviderSnapshot] | None,
) -> PresetSnapshotLoader: ) -> PresetSnapshotLoader:
if provider_snapshot_loader is not None: if provider_snapshot_loader is not None:
@ -34,12 +53,13 @@ def build_static_preset_snapshot(
name: str, name: str,
preset: ModelPresetConfig, preset: ModelPresetConfig,
) -> ProviderSnapshot: ) -> ProviderSnapshot:
provider.generation = preset.to_generation_settings()
return ProviderSnapshot( return ProviderSnapshot(
provider=provider, provider=provider,
model=preset.model, model=preset.model,
context_window_tokens=preset.context_window_tokens, context_window_tokens=preset.context_window_tokens,
signature=("model_preset", name, preset.model_dump_json()), signature=("model_preset", name, preset.model_dump_json()),
generation=preset.to_generation_settings(),
model_preset=name,
) )
@ -51,7 +71,7 @@ def build_runtime_preset_snapshot(
loader: PresetSnapshotLoader | None, loader: PresetSnapshotLoader | None,
) -> ProviderSnapshot: ) -> ProviderSnapshot:
if loader is not None: if loader is not None:
return loader(name) return replace(loader(name), model_preset=name)
return build_static_preset_snapshot(provider, name, presets[name]) return build_static_preset_snapshot(provider, name, presets[name])

View File

@ -0,0 +1,245 @@
"""Public resolution boundary for default and overridden LLM runtimes."""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import replace
from types import MappingProxyType
from typing import cast
from nanobot.agent import model_presets as preset_helpers
from nanobot.config.schema import Config, ModelPresetConfig
from nanobot.providers.factory import ProviderSnapshot, build_provider_snapshot
from nanobot.utils.llm_runtime import LLMRuntime, runtime_from_provider_snapshot
class ModelRuntimeResolver:
"""Own model selection and resolve it to immutable execution values.
The resolver is deliberately independent of ``AgentLoop``. Command, SDK,
and tool admission layers can depend on this public service without reading
or mutating private loop state.
"""
def __init__(
self,
initial_runtime: LLMRuntime,
*,
model_presets: Mapping[str, ModelPresetConfig] | None = None,
preset_catalog_loader: preset_helpers.PresetCatalogLoader | None = None,
configured_default_preset: str | None = None,
provider_snapshot_loader: Callable[[], ProviderSnapshot] | None = None,
preset_snapshot_loader: preset_helpers.PresetSnapshotLoader | None = None,
) -> None:
self._runtime = initial_runtime
self._model_presets = dict(model_presets or {})
self._preset_catalog_loader = preset_catalog_loader
self._preset_catalog_refresh_required = False
self._provider_snapshot_loader = provider_snapshot_loader
self._preset_snapshot_loader = preset_snapshot_loader
self._refresh_required = False
self._resolved_presets: dict[str, LLMRuntime] = {}
self._tracks_provider_generation = initial_runtime.model_preset is None
self._default_selection_signature = preset_helpers.default_selection_signature(
initial_runtime.snapshot_signature,
configured_default_preset,
)
@property
def runtime(self) -> LLMRuntime:
"""Return the current immutable default without refreshing configuration."""
return self._runtime
@property
def model_presets(self) -> Mapping[str, ModelPresetConfig]:
self._refresh_preset_catalog()
return MappingProxyType({
name: preset.model_copy(deep=True)
for name, preset in self._model_presets.items()
})
@property
def model_preset(self) -> str | None:
return self._runtime.model_preset
@property
def provider_signature(self) -> tuple[object, ...] | None:
return self._runtime.snapshot_signature
def current(self, *, refresh: bool = False) -> LLMRuntime:
"""Return the selected runtime, optionally refreshing the default source."""
if refresh:
self.refresh()
self._refresh_provider_generation()
return self._runtime
def admit(self) -> LLMRuntime:
"""Resolve the immutable runtime for the next turn admission."""
if self._refresh_required:
self.refresh()
self._refresh_provider_generation()
return self._runtime
def invalidate(self) -> None:
"""Refresh configured runtime state on the next admission."""
self._refresh_required = True
self._preset_catalog_refresh_required = True
self._resolved_presets.clear()
def _refresh_preset_catalog(self) -> None:
if not self._preset_catalog_refresh_required:
return
if self._preset_catalog_loader is not None:
self._model_presets = dict(self._preset_catalog_loader())
self._preset_catalog_refresh_required = False
def resolve_snapshot(
self,
snapshot: ProviderSnapshot,
) -> LLMRuntime:
"""Resolve a factory snapshot without changing the selected default."""
return runtime_from_provider_snapshot(snapshot)
def adopt_snapshot(
self,
snapshot: ProviderSnapshot,
) -> LLMRuntime:
"""Select a snapshot as the default for future turns."""
runtime = self.resolve_snapshot(snapshot)
self._runtime = runtime
self._tracks_provider_generation = runtime.model_preset is None
self._default_selection_signature = preset_helpers.default_selection_signature(
runtime.snapshot_signature,
runtime.model_preset,
)
return runtime
def resolve_preset(self, name: str | None) -> LLMRuntime:
"""Resolve a named preset without changing the selected default."""
self._refresh_preset_catalog()
normalized = preset_helpers.normalize_preset_name(name, self._model_presets)
cached = self._resolved_presets.get(normalized)
if cached is not None:
return cached
snapshot = preset_helpers.build_runtime_preset_snapshot(
name=normalized,
presets=self._model_presets,
provider=self._runtime.provider,
loader=self._preset_snapshot_loader,
)
runtime = self.resolve_snapshot(snapshot)
self._resolved_presets[normalized] = runtime
return runtime
def select_preset(self, name: str | None) -> LLMRuntime:
"""Select a named preset as the default for future turns."""
runtime = self.resolve_preset(name)
self._runtime = runtime
self._tracks_provider_generation = False
return runtime
def select_model(self, model: str) -> LLMRuntime:
"""Change the default model without reconstructing downstream consumers."""
if not isinstance(cast(object, model), str) or not model.strip():
raise ValueError("model must be a non-empty string")
self._runtime = replace(
self._runtime,
model=model.strip(),
model_preset=None,
)
return self._runtime
def select_context_window(self, context_window_tokens: int) -> LLMRuntime:
"""Change the default context limit for future admissions."""
raw_context_window = cast(object, context_window_tokens)
if not isinstance(raw_context_window, int) or isinstance(
raw_context_window,
bool,
):
raise TypeError("context_window_tokens must be an integer")
self._runtime = replace(
self._runtime,
context_window_tokens=context_window_tokens,
)
return self._runtime
def _refresh_provider_generation(self) -> LLMRuntime | None:
"""Adopt direct provider-default changes only for provider-backed defaults."""
if not self._tracks_provider_generation:
return None
runtime = self._runtime
captured = LLMRuntime.capture(
runtime.provider,
runtime.model,
context_window_tokens=runtime.context_window_tokens,
model_preset=runtime.model_preset,
snapshot_signature=runtime.snapshot_signature,
)
if captured.generation == runtime.generation:
return None
self._runtime = replace(runtime, generation=captured.generation)
return self._runtime
def refresh(self) -> LLMRuntime | None:
"""Refresh configured defaults and return the replacement when changed."""
if self._provider_snapshot_loader is None:
self._refresh_required = False
return None
self._resolved_presets.clear()
snapshot = self._provider_snapshot_loader()
default_selection = preset_helpers.default_selection_signature(
snapshot.signature,
snapshot.model_preset,
)
active_preset = self._runtime.model_preset
if active_preset and self._default_selection_signature in (None, default_selection):
runtime = self.resolve_preset(active_preset)
else:
runtime = self.resolve_snapshot(snapshot)
unchanged = (
runtime.snapshot_signature == self._runtime.snapshot_signature
and runtime.model_preset == self._runtime.model_preset
)
self._refresh_required = False
if unchanged:
self._default_selection_signature = default_selection
return None
(
self._runtime,
self._tracks_provider_generation,
self._default_selection_signature,
) = (
runtime,
runtime.model_preset is None,
default_selection,
)
return runtime
def resolve_override(
self,
*,
model: str | None,
model_preset: str | None,
config: Config | None = None,
) -> LLMRuntime | None:
"""Resolve an SDK-style per-run override without mutating the default."""
if model is not None and model_preset is not None:
raise ValueError("model and model_preset are mutually exclusive")
if model_preset is not None:
return self.resolve_preset(model_preset)
if model is None:
return None
if config is None:
return LLMRuntime(
provider=self._runtime.provider,
model=model,
generation=self._runtime.generation,
context_window_tokens=self._runtime.context_window_tokens,
snapshot_signature=("model_override", model),
)
base = config.resolve_preset(self.model_preset)
preset = base.model_copy(update={"model": model, "provider": "auto"})
return self.resolve_snapshot(build_provider_snapshot(config, preset=preset))

Some files were not shown because too many files have changed in this diff Show More