NullClaw is compatible with OpenClaw config structure and uses snake_case keys.
Who this page is for
- Users creating or editing the main
config.json - Operators tuning channels, gateway behavior, and autonomy limits
- Migrators mapping existing OpenClaw-style settings into NullClaw
Read this next
- Open Usage and Operations after config edits to validate runtime behavior
- Open Security before widening permissions, public exposure, or tool scope
- Open Gateway API if your config changes affect pairing, webhooks, or external integrations
- Open External Channel Plugins if you are wiring a non-core channel
If you came from ...
- Installation: this page takes over once
nullclawis installed and ready for first-run setup - README: this is the detailed config path after choosing the operator/user docs route
- Gateway API: come back here when the API workflow depends on concrete
gatewayor channel settings
- macOS/Linux:
~/.nullclaw/config.json - Windows:
%USERPROFILE%\\.nullclaw\\config.json
Recommended first step:
nullclaw onboard --interactiveThis generates your initial config file.
The example below is enough to run local CLI mode (replace API key):
{
"models": {
"providers": {
"openrouter": {
"api_key": "YOUR_OPENROUTER_API_KEY"
}
}
},
"agents": {
"defaults": {
"model": {
"primary": "openrouter/anthropic/claude-sonnet-4"
}
}
},
"channels": {
"cli": true
},
"memory": {
"backend": "sqlite",
"auto_save": true
},
"gateway": {
"host": "127.0.0.1",
"port": 3000,
"require_pairing": true
},
"autonomy": {
"level": "supervised",
"workspace_only": true,
"max_actions_per_hour": 20
},
"security": {
"sandbox": {
"backend": "auto"
},
"audit": {
"enabled": true
}
}
}- Controls runtime diagnostics and observability output.
- For OpenTelemetry, use the nested
diagnostics.otelobject. - OTEL spans are flushed at natural runtime boundaries such as turn completion and agent shutdown, with batch flushing still used as a fallback for longer-running flows.
- OTEL endpoints should use HTTPS. Plain HTTP is appropriate only for localhost/private collectors or container-local targets such as
host.docker.internal,host.containers.internal, or single-label service names likeotel.
Example:
{
"diagnostics": {
"backend": "otel",
"log_tool_calls": true,
"log_message_receipts": true,
"log_message_payloads": true,
"log_llm_io": true,
"otel": {
"endpoint": "https://otel:4318",
"service_name": "nullclaw",
"headers": {
"Authorization": "Bearer example-token"
}
}
}
}- Defines LLM provider connection parameters and API keys.
- Common providers:
openrouter,openai,anthropic,groq.
Example:
{
"models": {
"providers": {
"openrouter": { "api_key": "sk-or-..." },
"anthropic": { "api_key": "sk-ant-..." },
"openai": { "api_key": "sk-..." }
}
}
}Common per-provider fields:
api_key: credential for that provider entry.base_url: override for custom or self-hosted OpenAI-compatible endpoints.api_mode: selectchat_completionsorresponsesfor compatible providers.user_agent: optionalUser-Agentheader override.max_streaming_prompt_bytes: skip streaming above this estimated prompt size.chat_template_enable_thinking_param: for custom OpenAI-compatible vLLM/Qwen endpoints, mapreasoning_efforttochat_template_kwargs.enable_thinking.
- Sets default model route, typically
provider/vendor/model. - Example:
openrouter/anthropic/claude-sonnet-4
- Optional top-level routing table for automatic per-turn model selection in
nullclaw agent. - Each entry maps a route
hintto a concreteproviderandmodel. - Recognized routing hints in the current daemon are
fast,balanced,deep,reasoning, andvision. balancedis the normal fallback when configured.fastis preferred for short status/list/check prompts and other short structured tasks such as extraction, counting, classification, or narrow return-only transforms.deepandreasoningare preferred for investigation, planning, tradeoff analysis, and longer contexts.visionis used for image turns.api_keyis optional. If omitted, NullClaw uses the normal credential frommodels.providers.<provider>.cost_classis optional metadata with valuesfree,cheap,standard, orpremium.quota_classis optional metadata with valuesunlimited,normal, orconstrained.
Example:
{
"model_routes": [
{ "hint": "fast", "provider": "groq", "model": "llama-3.3-70b", "cost_class": "free", "quota_class": "unlimited" },
{ "hint": "balanced", "provider": "openrouter", "model": "anthropic/claude-sonnet-4", "cost_class": "standard", "quota_class": "normal" },
{ "hint": "deep", "provider": "openrouter", "model": "anthropic/claude-opus-4", "cost_class": "premium", "quota_class": "constrained" },
{ "hint": "vision", "provider": "openrouter", "model": "openai/gpt-4.1", "cost_class": "standard", "quota_class": "normal" }
]
}Notes:
model_routesare used only when the session is not pinned to an explicit model.- If both
deepandreasoningare configured, deep-analysis prompts preferdeep. /modelshows the last auto-route decision so operators can see which route was picked and why.- Auto-routed sessions temporarily degrade a route after quota or rate-limit failures and skip it until the cooldown expires.
- Route metadata only nudges scoring. Ambiguous prompts still stay on
balanced;fastis reserved for high-confidence cheap tasks, and strong deep-analysis signals still win over cheaper routes.
- Defines named agent profiles used by the
delegatetool,/subagents spawn --agent, andbindings. - Each entry may set
provider+model, or a fullprovider/modelref inmodel.primary. - Example:
{
"agents": {
"list": [
{
"id": "coder",
"model": { "primary": "ollama/qwen3.5:cloud" },
"system_prompt": "You're an experienced coder"
}
]
}
}Use this pattern when you want one "orchestrator" agent to delegate specialized tasks:
- Define reusable specialists under
agents.list. - Keep a general default under
agents.defaults. - Use
bindingsto route specific chats/topics to a specialist. - Use
/subagents spawn --agent <agent-id> <task>when you want explicit one-off delegation from the operator side.
Example:
{
"agents": {
"defaults": {
"model": { "primary": "openrouter/anthropic/claude-sonnet-4" }
},
"list": [
{
"id": "orchestrator",
"model": { "primary": "openrouter/anthropic/claude-sonnet-4" },
"system_prompt": "Coordinate tasks and delegate to specialists."
},
{
"id": "coder",
"model": { "primary": "openrouter/qwen/qwen3-coder" },
"system_prompt": "You are focused on implementation and tests."
},
{
"id": "researcher",
"model": { "primary": "openrouter/openai/gpt-4.1" },
"system_prompt": "You are focused on investigation and synthesis."
}
]
}
}Notes:
agents.list[].idis the value used by/subagents spawn --agent <name>, thedelegatetool'sagentargument, andbindings[].agent_id.- Prefer short stable ids (
coder,researcher) so chat commands stay simple. - Keep specialist prompts narrow; broad prompts overlap and reduce routing clarity.
Use workspace_path when a named agent should run from its own workspace instead of the global one.
Example:
{
"agents": {
"list": [
{
"id": "coder",
"model": { "primary": "ollama/qwen2.5-coder:14b" },
"system_prompt": "Focus on implementation and tests.",
"workspace_path": "agents/coder"
}
]
}
}Behavior:
- Relative paths are resolved relative to the directory that contains
config.json. - Absolute paths are used as-is.
- Both
/and\are accepted in config; the runtime normalizes separators for the current OS. workspace_pathdoes not disablesystem_prompt. If both are set, nullclaw keeps the named profile prompt and also loads bootstrap context from that dedicated workspace.- On first use, nullclaw scaffolds the workspace if missing and creates:
AGENTS.mdSOUL.mdIDENTITY.mdMEMORY.md
Isolation model:
- The agent's file operations, markdown memory files, and workspace-scoped context use that workspace.
- When
workspace_pathis set, the agent also gets a durable memory namespace of the formagent:<agent-id>. - That namespace is used by:
nullclaw agent --agent <id>/subagents spawn --agent <id> ...- routed sessions that resolve to that named agent through
bindings
Practical effect:
- Two named agents can share the same provider/model family but keep separate durable notes and separate workspaces.
workspace_pathdoes not route chats by itself. Routing still comes frombindings,/bind, or explicit--agent//subagents spawn --agent.
debounce_msdelays handling rapid-fire plain-text inbound messages so several short fragments can collapse into one turn.- Default:
3000. - Applies to daemon-routed inbound text and the Agent CLI REPL.
- Set
0to disable it. - Slash commands and media-bearing inbound messages bypass debounce.
- Telegram keeps its channel-specific split-message merge path; this setting becomes the base debounce window for that path.
Example:
{
"messages": {
"inbound": {
"debounce_ms": 1500
}
}
}- Configures global retry and failover behavior for LLM providers.
provider_retries: Number of times to retry a failed LLM request (default: 2).provider_backoff_ms: Initial exponential backoff delay between retries (default: 500).fallback_providers: List of provider names to try if an unqualified model should fan out beyond the primary provider.model_fallbacks: Mapping of a model to an ordered list of fallback models. Each fallback may be either another bare model name or an explicitprovider/modelref.
Example:
{
"reliability": {
"provider_retries": 2,
"provider_backoff_ms": 500,
"fallback_providers": ["groq", "openai"],
"model_fallbacks": [
{
"model": "anthropic/claude-sonnet-4",
"fallbacks": ["openai/gpt-4o", "groq/llama-3.3-70b"]
}
]
}
}Notes:
- Failover order for bare model refs: primary provider first, then each listed
fallback_provider. - Provider-qualified fallback refs such as
openai/gpt-4oroute directly to that provider and skip the generic provider fanout. api_keys: (Optional) List of extra API keys for rotation on rate-limit (429) errors.
Use this section when you want the runtime identity to come from an AIEOS document.
When configured, nullclaw injects the parsed AIEOS content into the system prompt alongside workspace identity files such as AGENTS.md and IDENTITY.md:
{
"identity": {
"format": "aieos",
"aieos_path": "./identity/aieos.identity.json"
}
}You can also inline the same document directly in config:
{
"identity": {
"format": "aieos",
"aieos_inline": "{\"identity\":{\"names\":{\"first\":\"nullclaw-assistant\"},\"bio\":\"General-purpose autonomous assistant\"},\"linguistics\":{\"style\":\"concise\"},\"motivations\":{\"core_drive\":\"Help the operator finish tasks safely\"}}"
}
}Minimal AIEOS v1.1 example file (identity/aieos.identity.json):
{
"identity": {
"names": {
"first": "nullclaw-assistant"
},
"bio": "General-purpose autonomous assistant"
},
"linguistics": {
"style": "concise"
},
"motivations": {
"core_drive": "Help the operator finish tasks safely"
}
}Notes:
- AIEOS payloads use top-level sections such as
identity,psychology,linguistics,motivations, andcapabilities. - Prefer
aieos_pathfor maintainability and version control readability. - Use
aieos_inlineonly when you need a fully self-contained single config file. - Keep
identity.formataligned with the payload source (aieos). - Relative
aieos_pathvalues are resolved against the active workspace first, then against the current working directory.
- Channel config lives under
channels.<name>. - Multi-account channels typically use an
accountswrapper.
External channel plugin example:
{
"channels": {
"external": {
"accounts": {
"wa-web": {
"runtime_name": "whatsapp_web",
"transport": {
"command": "nullclaw-plugin-whatsapp-web",
"args": ["--stdio"],
"timeout_ms": 10000,
"env": {
"PLUGIN_TOKEN": "secret"
}
},
"config": {
"bridge_url": "http://127.0.0.1:3301",
"allow_from": ["*"]
}
}
}
}
}
}External channel notes:
For the full protocol, lifecycle, metadata conventions, and plugin author contract, see External Channel Plugins.
runtime_nameis the nullclaw runtime channel id used by routing, bindings, session keys, and outbound dispatch. It must not reuse a built-in channel name or any runtime name already claimed by another configured channel.transport.commandplus optionaltransport.argsstarts the plugin as a child process over line-delimited JSON-RPC on stdio.transport.timeout_msbounds host->plugin RPC waits; nullclaw still caps control-plane waits internally so one broken plugin cannot stall supervision for minutes.transport.envis forwarded only to the plugin process.configmust be a JSON object; it is forwarded to the pluginstartrequest asparams.config.- Plugins must answer
get_manifest, handlestart/send/stop;healthis recommended so supervision can detect disconnected sidecars instead of only live processes. get_manifest.resultmust containprotocol_version: 2;capabilities.health,capabilities.streaming,capabilities.send_rich,capabilities.typing,capabilities.edit,capabilities.delete,capabilities.reactions, andcapabilities.read_receiptsare optional capability bits.health.resultmust report an explicit boolean (healthy) or explicit health signals (ok,connected,logged_in); an empty object is treated as invalid. For QR/device-link style channels, this is the place to reportconnected: falseorlogged_in: falsewhile background auth is still in progress.start.paramsnow has a nestedruntimeobject withname,account_id, and host-ownedstate_dir.start.resultmust containstarted: true;startshould return promptly after local initialization instead of blocking on QR scans or human login.send,send_rich,edit_message,delete_message, and typing/message-action RPCs must returnresult.accepted: truewhen the plugin actually accepts the action. A JSON-RPC success envelope by itself is not enough.send.paramsnow has nestedruntimeandmessageobjects; text payloads usemessage.text.- If a plugin declares both
capabilities.edit=trueandcapabilities.delete=true,send.resultmay also includemessage_idormessage { target?, message_id }; that lets nullclaw keep a tracked draft updated on channels that do not support native.chunkstreaming. - If
capabilities.streaming=true, nullclaw may emit.chunkstagedsendevents during model streaming. If it is absent orfalse, nullclaw will only emit final responses. - If
capabilities.send_rich=true, the host may callsend_richwith nestedruntimeandmessage { target, text, attachments, choices }. - If
capabilities.typing=true, the host may callstart_typing/stop_typingwith nestedruntimeplusrecipient. - If
capabilities.edit=true/capabilities.delete=true, the host may calledit_message/delete_message. - If
capabilities.reactions=trueorcapabilities.read_receipts=true, the host may callset_reactionandmark_read. inbound_message.params.messagemust includesender_id,chat_id, andtext; ifmetadatais present it must be a JSON object, and ifmediais present it must be an array of non-empty strings.- Include
metadata.peer_kindplusmetadata.peer_idwhen you want routing/bindings to distinguish direct vs group peers for unknown channels. - Unknown/external channels can also set
metadata.is_group,metadata.is_dm, ormetadata.typing_recipient; nullclaw promotes that metadata into prompt conversation context and processing-indicator routing. - A reference bridge adapter for the PR #265 WhatsApp Web sidecar lives in
examples/whatsapp-web/nullclaw-plugin-whatsapp-web. - Production companion repositories now live out of tree: nullclaw/nullclaw-channel-baileys and nullclaw/nullclaw-channel-whatsmeow-bridge.
nullclaw channel start externalstarts the first configured external account;nullclaw channel start <runtime_name>targets a specific configured runtime name such aswhatsapp_web.
Telegram example:
{
"channels": {
"telegram": {
"accounts": {
"main": {
"bot_token": "123456:ABCDEF",
"allow_from": ["YOUR_TELEGRAM_USER_ID"]
}
}
}
}
}WeChat example:
{
"channels": {
"wechat": [
{
"account_id": "main",
"callback_token": "wechat-callback-token",
"encoding_aes_key": "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG",
"app_id": "wx1234567890abcdef",
"app_secret": "wechat-app-secret",
"allow_from": ["openid_123"]
}
]
}
}WeChat notes:
- NullClaw already supports WeChat as a built-in webhook channel; you do not need an external plugin to receive Official Account callbacks.
- The gateway webhook path is
/wechat. Use?account_id=<id>when you have multiple configured WeChat accounts. callback_tokenis required for signature verification.encoding_aes_keyis optional but required when your WeChat callback is configured forencrypt_type=aes.app_idandapp_secretare optional unless you want outbound active-message delivery through the WeChat custom message API.allow_fromshould list trusted OpenIDs. Keep it explicit; do not rely on an empty allowlist for privacy.- Build with
-Dchannels=wechat(or-Dchannels=all) if your binary was compiled without the WeChat channel.
Telegram forum topics:
- Topic session isolation is automatic; there is no separate
topic_idfield underchannels.telegram. - The practical operator flow is:
- configure named agent profiles under
agents.list - open the target Telegram chat or forum topic
- run
/bind <agent>
- configure named agent profiles under
- If you want a specific forum topic to use a specific agent, configure it in
bindingswithmatch.peer.id = "<chat_id>:thread:<topic_id>". - If you also want a fallback agent for the rest of the same Telegram group, add another binding for the plain group id
"<chat_id>". /bind statusshows the current effective route and the available agent ids./bind clearremoves only the exact binding for the current account/chat/topic and lets routing fall back to the broader match./bindwrites an exactbindings[]entry for the current Telegram account and peer./bind statusdistinguishes an exact local override from an inherited broader fallback.- Topic-specific bindings win over group fallback by route priority; the order in
bindings[]does not matter. - Telegram menu visibility for
/bindis controlled bychannels.telegram.accounts.<id>.binding_commands_enabled.
Example:
{
"bindings": [
{
"agent_id": "coder",
"match": {
"channel": "telegram",
"account_id": "main",
"peer": { "kind": "group", "id": "-1001234567890:thread:42" }
}
},
{
"agent_id": "orchestrator",
"match": {
"channel": "telegram",
"account_id": "main",
"peer": { "kind": "group", "id": "-1001234567890" }
}
}
]
}In that setup, topic 42 routes to coder, while the rest of the forum falls back to orchestrator.
Peer ID format note: Topic peer IDs in
bindingsmust use the canonical:thread:Nformat (e.g."-1001234567890:thread:42"). The legacy#topic:Nformat (e.g."-1001234567890#topic:42") is auto-converted at load time but is deprecated — a warning will appear in the logs. If you see#topic:in nullclaw's log output, convert it to:thread:when copying into your config. The/bindcommand always saves in the correct format automatically.
Named agent profiles and bindings are separate concerns: agents.list defines reusable profiles, while bindings decides which profile is used for a given chat/topic.
Minimal end-to-end example:
{
"agents": {
"list": [
{
"id": "orchestrator",
"provider": "openrouter",
"model": "anthropic/claude-sonnet-4"
},
{
"id": "coder",
"provider": "ollama",
"model": "qwen2.5-coder:14b",
"system_prompt": "You are the coding agent for this topic."
}
]
},
"channels": {
"telegram": {
"accounts": {
"main": {
"bot_token": "123456:ABCDEF",
"allow_from": ["YOUR_TELEGRAM_USER_ID"],
"draft_previews": false,
"binding_commands_enabled": true,
"topic_commands_enabled": true,
"topic_map_command_enabled": true,
"commands_menu_mode": "scoped"
}
}
}
},
"bindings": [
{
"agent_id": "orchestrator",
"match": {
"channel": "telegram",
"account_id": "main",
"peer": { "kind": "group", "id": "-1001234567890" }
}
}
]
}Operator flow:
- Send
/bind coderinside the target forum topic. nullclawwrites a new exactbindings[]entry to~/.nullclaw/config.jsonfor that topic and Telegram account.- The next message in that topic uses the new routed agent profile.
nullclawmust have write access to~/.nullclaw/config.jsonfor/bindto persist changes.
About draft_previews:
draft_previews=falseis the default and recommended setting.- When disabled, Telegram still shows activity/typing while the reply is being generated, but it does not use ephemeral
sendMessageDraftpreviews. - Set
draft_previews=trueonly if you explicitly want live partial previews and accept that Telegram may hide and replace those drafts before the final message lands.
About account_id:
account_ididentifies the configured Telegram account entry, not a topic and not an agent.- In the standard
channels.telegram.accountslayout, the object key becomes the account id. For example,accounts.mainmeansaccount_id = "main". - In
bindings,match.account_idrestricts a binding to one specific Telegram account. - If
match.account_idis omitted, the binding can match any Telegram account for that channel. - Different account ids are only useful when the same nullclaw instance runs multiple Telegram bot accounts/tokens.
Use channels.web for browser UI / extension traffic over WebSocket (/ws by default).
Example:
{
"channels": {
"web": {
"accounts": {
"default": {
"transport": "local",
"listen": "127.0.0.1",
"port": 32123,
"path": "/ws",
"auth_token": "replace-with-long-random-token",
"message_auth_mode": "pairing",
"allowed_origins": ["http://localhost:5173"]
}
}
}
}
}Practical rules:
- Keep
listen = "127.0.0.1"for the pairing-first local UX. - In local transport, unauthenticated WebSocket upgrade is allowed only on loopback. This is what lets a UI connect first and then send
pairing_request. - If you change
listento0.0.0.0or another non-loopback address, the WebSocket upgrade must already include the channel token:ws://host:32123/ws?token=<auth_token>- or
Authorization: Bearer <auth_token>
- For non-loopback bind, do not expect local
pairing_requestto work before the socket is authenticated. The pairing-first flow is loopback-only by design. message_auth_mode = "pairing"means eachuser_messagemust carry the UIaccess_tokenreturned by the pairing flow.message_auth_mode = "token"is local-transport only and requires a stable token from config or env. In this mode, the UI sendsauth_tokenon eachuser_messageinstead of a pairing JWT.auth_tokenhardens the WebSocket upgrade and becomes mandatory for non-loopback bind.- Use
/wsfor the WebSocket endpoint./pairbelongs to the HTTP gateway API, not the web channel WebSocket flow. - For headless/LAN access, the safest operator path is still SSH tunnel or reverse proxy in front of a loopback-bound web channel.
Remote/headless example:
{
"channels": {
"web": {
"accounts": {
"default": {
"transport": "local",
"listen": "0.0.0.0",
"port": 32123,
"path": "/ws",
"auth_token": "replace-with-long-random-token",
"message_auth_mode": "token",
"allowed_origins": ["https://chat-ui.example.com"]
}
}
}
}
}Effect on delivery:
- Incoming Telegram updates are handled by the account that received them.
- Routing uses that same
account_id, somatch.account_id = "main"matches only messages received throughchannels.telegram.accounts.main. - Replies go back out through the same Telegram account/runtime that handled the message.
- If one binding uses
account_id = "main"and another usesaccount_id = "sub", they apply to different configured Telegram accounts; this does not split a single Telegram account's traffic by itself.
Rules:
- Empty
allow_frombehavior is channel-specific. Some channels, including WeChat and Discord, treat an omitted or empty list as "no filtering" rather than "deny all", so set explicit IDs/OpenIDs for a private bot. allow_from: ["*"]allows all sources on allowlist-based channels; use it only when you intentionally want an open bot.
Max example:
{
"channels": {
"max": [
{
"account_id": "main",
"bot_token": "MAX_BOT_TOKEN",
"allow_from": ["YOUR_MAX_USER_ID"],
"group_allow_from": ["YOUR_MAX_USER_ID"],
"group_policy": "allowlist",
"mode": "webhook",
"webhook_url": "https://bot.example.com/max?account_id=main",
"webhook_secret": "replace-with-random-secret",
"require_mention": true,
"streaming": true,
"interactive": {
"enabled": true,
"ttl_secs": 900,
"owner_only": true
}
}
]
}
}Max notes:
channels.maxis an array of account entries;account_iddistinguishes multiple Max bots.- Prefer
mode = "webhook"for production. Max documents long polling as suitable for development/testing, while webhooks are the recommended production path. webhook_urlmust be HTTPS.- For multi-account webhook setups, give each account either a unique
webhook_secretor a uniqueaccount_idquery in the webhook URL, for example/max?account_id=main. allow_fromandgroup_allow_fromaccept either Maxuser_idvalues or usernames.user_idis the stable choice for bindings and routing.require_mention = trueonly affects group chats. Direct messages andbot_starteddeep links still work normally.- Max inline buttons are one-shot in nullclaw: after a valid click, the original keyboard is cleared to avoid stale buttons.
Discord example:
{
"channels": {
"discord": {
"accounts": {
"default": {
"token": "YOUR_DISCORD_BOT_TOKEN",
"intents": 37377,
"allow_from": ["YOUR_DISCORD_USER_ID"]
}
}
}
}
}Set allow_from explicitly unless you intentionally want an open bot. In the current Discord runtime, an omitted or empty allow_from list disables filtering instead of denying all inbound messages.
Enable MESSAGE CONTENT INTENT in the Discord Developer Portal if you want the bot to process ordinary guild messages. Without it, Discord omits message content for most guild traffic; direct messages and messages that mention the bot still include content.
Gateway intents (intents) is a bitmask. Default 37377 = GUILDS (1) + GUILD_MESSAGES (512) + MESSAGE_CONTENT (32768) + DIRECT_MESSAGES (4096). Calculate custom intents from https://discord.com/developers/docs/topics/gateway#gateway-intents.
Discord setup flow:
- Create application at https://discord.com/developers/applications
- Bot section → Add Bot → Reset Token (copy immediately)
- Privileged Gateway Intents → Enable MESSAGE CONTENT INTENT → Save
- OAuth2 → URL Generator → Scopes:
bot - Bot Permissions: Send Messages, Read Message History, Read Messages/View Channels
- Copy URL, open in browser, select server, authorize
The current Discord integration does not require extra OAuth scopes or elevated permissions such as Administrator.
Multi-bot setup uses accounts wrapper. Each account_id creates an independent Discord bot connection with separate session state and routing:
{
"channels": {
"discord": {
"accounts": {
"production": {
"token": "PRODUCTION_BOT_TOKEN",
"intents": 37377,
"allow_from": ["ADMIN_USER_ID"]
},
"testing": {
"token": "TESTING_BOT_TOKEN",
"intents": 37377,
"allow_from": ["DEV_USER_ID"]
}
}
}
}
}Channel-specific bindings use peer.kind = "channel" with Discord channel IDs (enable Developer Mode → right-click channel → Copy ID):
{
"bindings": [
{
"agent_id": "coder",
"match": {
"channel": "discord",
"account_id": "default",
"peer": {"kind": "channel", "id": "CHANNEL_ID_HERE"}
}
}
]
}Direct message bindings use peer.kind = "direct" with user IDs:
{
"bindings": [
{
"agent_id": "personal",
"match": {
"channel": "discord",
"account_id": "default",
"peer": {"kind": "direct", "id": "USER_ID_HERE"}
}
}
]
}Parameters:
token(required) - Bot token from Discord Developer Portalintents(default: 37377) - Gateway intents bitmaskallow_bots(default: false) - Allow messages from other botsallow_from(default: []) - Optional allowlist of user IDs; for Discord, an omitted or empty list disables filtering, so set explicit IDs for a private bot.["*"]also matches all usersrequire_mention(default: false) - Require bot mention in guilds to respondguild_id(optional) - Reserved for Discord server scoping; current runtime does not enforce it
NullClaw splits messages >2000 characters (Discord API limit).
Verification:
nullclaw channel start discord
nullclaw channel statusnullclaw channel start discord starts only the first configured Discord account. For multi-account validation, run nullclaw gateway and send a test message to each configured bot.
Common issues:
- Bot only responds in DMs or explicit mentions: enable MESSAGE CONTENT INTENT, then re-invite the bot if needed
- "Privileged Intents" error: enable MESSAGE CONTENT INTENT in Discord Developer Portal; verified apps may also need Discord approval
- Bot offline: Check
nullclaw service status, verify token hasn't been reset - No response in guilds: Check
require_mentionsetting, verify Read Messages permission
backend: start withsqlite. Available engines:sqlite,markdown,clickhouse,postgres,redis,lancedb,lucid,memory(LRU),api,none.auto_save: persists conversation memory automatically.- For hybrid retrieval and embedding settings, see root
config.example.json.
Note: The markdown_only memory profile automatically enables hybrid retrieval with temporal decay (half-life 30 days) for optimal relevance scoring. This ensures temporal awareness even with plain markdown files.
Recommended defaults:
host = "127.0.0.1"require_pairing = true
Avoid direct public exposure. Use tunnel when external access is required.
| Field | Default | Description |
|---|---|---|
host |
"127.0.0.1" |
Listen address |
port |
3000 |
Listen port |
require_pairing |
true |
Require bearer token on all API requests |
allow_public_bind |
false |
Allow binding to non-loopback addresses |
pair_rate_limit_per_minute |
10 |
Max /pair requests per minute per IP |
webhook_rate_limit_per_minute |
60 |
Max webhook requests per minute per IP |
idempotency_ttl_secs |
300 |
Duration to cache idempotent request results |
max_body_size_bytes |
65536 |
Maximum HTTP request body size in bytes (64 KB). Raise this when accepting image or file payloads (e.g. 20971520 for 20 MB). |
request_timeout_secs |
30 |
Socket read timeout for incoming HTTP requests in seconds. Raise this when accepting large payloads over slow connections. |
Tunnel providers for exposing the gateway to the public internet. Required for webhook-based channels when running without a public IP.
Providers:
| Provider | Description |
|---|---|
none |
No tunnel (default) |
cloudflare |
Cloudflare Tunnel |
ngrok |
ngrok tunnel |
tailscale |
Tailscale Funnel |
custom |
Custom tunnel command |
Example: ngrok
{
"tunnel": {
"provider": "ngrok",
"ngrok": {
"auth_token": "YOUR_NGROK_AUTH_TOKEN",
"domain": "your-domain.ngrok-free.app"
}
}
}Example: Cloudflare
{
"tunnel": {
"provider": "cloudflare",
"cloudflare": {
"token": "YOUR_CLOUDFLARE_TUNNEL_TOKEN"
}
}
}Notes:
- Tunnel starts before gateway.
- Public URL is printed on startup and written to
daemon_state.json.
level: start withsupervised.level = "yolo": bypasses command policy checks; use only for trusted local debugging.workspace_only: keeptrueto limit file access scope.max_actions_per_hour: keep conservative limits first.
sandbox.backend = "auto": auto-selects an available sandbox backend.audit.enabled = true: recommended for traceability.
Use only in controlled environments:
{
"http_request": {
"enabled": true,
"allowed_domains": ["192.168.1.10", "*.internal.example.com"],
"search_base_url": "https://searx.example.com",
"search_provider": "auto",
"search_fallback_providers": ["jina", "duckduckgo"]
},
"autonomy": {
"level": "full",
"allowed_commands": ["*"],
"allowed_paths": ["*"],
"require_approval_for_medium_risk": false,
"block_high_risk_commands": false
}
}Notes:
search_base_url(for web_search tool): Must behttps://host[/search]or a local/privatehttp://host[:port][/search]URL. HTTP is allowed only for localhost/private hosts (e.g.,http://localhost:8888,http://192.168.1.10:8888). This URL is used by theweb_searchtool to query SearXNG instances.allowed_commands: ["*"]andallowed_paths: ["*"]significantly widen execution scope.http_request.allowed_domains: Domains that bypass SSRF protection for thehttp_requestandweb_fetchtools.[](empty): All domains go through SSRF check (default, safest).["example.com"]: Only specified domains skip SSRF protection.["*.example.com"]: Matches all subdomains (e.g.,api.example.com,www.example.com).["192.168.1.10"]: IP addresses can also be allowlisted (exact match only, CIDR ranges not supported).["*"]: DANGEROUS - All domains skip SSRF protection and DNS pinning. Use only in trusted network environments where you control DNS and need to allow access to any IP address. This effectively disables SSRF protection.- Example: If your SearXNG runs on
192.168.1.10, add"192.168.1.10"to access it viahttp_requesttool. - Security trade-off: Allowlisted domains skip DNS pinning, allowing access to private IPs. This trades DNS rebinding protection for operational flexibility.
- HTTPS-only policy: The
http_requestandweb_fetchtools requirehttps://URLs. Plain HTTP is rejected for security. Note: This does not affectweb_searchtool'ssearch_base_urlwhich allows HTTP for local hosts. - Check order: Allowlist is checked BEFORE DNS resolution to prevent DNS exfiltration attacks.
After each config change:
nullclaw doctor
nullclaw status
nullclaw channel statusIf gateway/channel changed, also run:
nullclaw gateway- Run
nullclaw doctorandnullclaw statusafter each edit to confirm the config still loads cleanly - Use Usage and Operations for operational checks, service mode, and troubleshooting flow
- Review Security before enabling broader autonomy, public bind, or wildcard allowlists