AI capabilities overview
Out of the box Zhin only runs commands; it doesn't "chat". With AI enabled, messages that match no command go to an LLM, which can also call tools you write.
AI is optional: without it, the bot depends on no model SDK and needs no API key.
Enable it in three steps
1. Run the wizard
npx zhin setup --ai
pnpm installThe wizard asks for a model service (OpenAI, Anthropic, DeepSeek, Google, any OpenAI-compatible API, or a local Ollama), installs the SDK and the Agent runtime, and declares the tool and Prompt Section capabilities in package.json.
2. Add your key
Cloud model keys go in .env (the wizard tells you the variable name, e.g. AI_API_KEY). Local Ollama needs no key.
3. Try it
pnpm devSend a sentence in a Sandbox DM, or ai: hello in a group, and the bot replies with the model.
The wizard writes roughly this to zhin.config.yml:
ai:
enabled: true
providers:
openai-main: # a name you choose
sdk: openai
apiKey: ${AI_API_KEY}
agents:
zhin: # the main Agent must be named zhin
provider: openai-main
model: gpt-4o-miniproviders say which model service to connect to; agents say which Agent uses which model.
Installing by hand instead of the wizard
pnpm add @zhin.js/agent zod ai
pnpm add @ai-sdk/openai # the SDK for your vendorInstalling @zhin.js/agent alone doesn't make tools/ and prompt-sections/ work — you must also declare @zhin.js/tool and @zhin.js/prompt-section in package.json#zhin.features. In code, import ZhinAgent, AIService and friends from zhin.js/agent (or @zhin.js/agent). Model SDKs are optional dependencies; if one is missing, startup reports Missing peer dependency with the install command.
When the AI replies
Messages that match no command are checked in this order:
| Order | Situation | Default |
|---|---|---|
| 1 | Starts with /, ! or ! | Skip the AI, to avoid clashing with commands |
| 2 | Starts with #, AI: or ai: | Sent to the AI (DMs and groups), with the prefix removed |
| 3 | The bot is mentioned in a group or channel | Sent to the AI |
| 4 | Direct message | The whole message goes to the AI |
| 5 | A DM containing a configured keyword | Sent to the AI |
So in groups the bot doesn't butt in by default — only when mentioned or prefixed. All of this is configurable; see ai.trigger.
Let the AI do things
By default the AI can only talk. To let it check the weather, search music or query a database, put a tool in tools/:
// tools/weather/index.ts
import { defineAgentTool } from '@zhin.js/tool';
import { z } from 'zod';
export default defineAgentTool<{ city: string }>({
description: 'Get the current weather for a city',
inputSchema: z.object({ city: z.string().min(1) }),
async execute({ city }) {
const response = await fetch(`https://wttr.in/${encodeURIComponent(city)}?format=3`);
return response.text();
},
});A tool's parameter schema reaches the model as written: enum, anyOf, nullable types, integer, format and value ranges are kept, for MCP tools too. Arguments the model sends back are checked against the same constraints, and a violation is reported to the model so it can call again. Gemini rejects fields without a type, so those go to Gemini as strings.
Restricting tools, requiring confirmation, packaging procedures as Skills and adding business context are covered in Agent tools and Skills.
Common adjustments
| I want to… | Change |
|---|---|
| Switch model or vendor, or use a local model | ai.providers |
| Trigger in groups without a mention, or change prefixes | ai.trigger |
| Open the AI to only some users or groups | ai.access |
| Use different Agents or models in different groups | ai.agents |
| Let the AI use tools from external MCP servers | ai.mcpServers |
| Control which shell commands the AI may run and whether they need approval | ai.agent |
| Understand and reply with voice | Speech |
Configuration reference
Everything lives under ai: in zhin.config.yml; ${VAR} expands from environment variables.
ai.providers
Each provider is keyed by a name you choose; sdk picks the model SDK:
ai:
providers:
openrouter:
sdk: openai-compatible
baseUrl: ${OPENROUTER_BASE_URL}
apiKey: ${OPENROUTER_API_KEY}| sdk | Install | Key settings |
|---|---|---|
openai | @ai-sdk/openai | apiKey, optional baseUrl (a proxy root gets /v1, same rule as openai-compatible) |
openai-compatible | @ai-sdk/openai-compatible | baseUrl + apiKey (/v1 appended when the path has no version, see compat below); Cloudflare Workers AI can use accountId instead of baseUrl |
anthropic | @ai-sdk/anthropic | apiKey, optional baseUrl (/v1 appended) |
deepseek | @ai-sdk/deepseek | apiKey, optional baseUrl |
minimax | @ai-sdk/minimax | apiKey, optional baseUrl (default https://api.minimax.io/anthropic/v1; for the China site use https://api.minimaxi.com/anthropic, /v1 is appended) |
google | @ai-sdk/google | apiKey, optional baseUrl (/v1beta appended) |
ollama | @ai-sdk/openai-compatible (via the OpenAI-compatible API) | host (default http://127.0.0.1:11434, /v1 appended); no apiKey; set contextWindow (see below) |
Optional fields:
models: the models allowed. When omitted, they are fetched from/v1/modelsafter startup (last cache first, then refreshed in the background).input: content types the model accepts,text | image | audio | video | file. When omitted, the default depends on the SDK and model name:- Gemini chat models on
sdk: googleare natively multimodal and default totext, image, audio, video, file. TTS, embedding, image-generation and Live models are text-only. - On
sdk: openai, GPT-4o, GPT-4.1, GPT-4.5, GPT-5.x, o1, o3 and o4-mini default totext, image, and so does every Claude 3+ model onsdk: anthropic. The audio, realtime, TTS, search, embedding and image-generation variants, plus o1-mini and o3-mini, are text-only. PDFs and other files are off by default; setinput: [text, image, file]to send them. - Other SDKs (DeepSeek, MiniMax, Ollama, gateways) are text-only, because one vendor's models differ in what they accept. Groq is the exception: Llama 4 Scout / Maverick default to
text, image, while GPT-OSS and the other models stay text-only.
An explicit
inputalways wins.- Gemini chat models on
contextWindow: context window size. When omitted, most providers assume 128k.sdk: ollamais the exception: zhin readsOLLAMA_CONTEXT_LENGTH(when zhin and Ollama share a machine) and otherwise assumes 4096. Ollama sizes the context by VRAM (4k below 24 GiB, 32k for 24–48 GiB, 256k above), its OpenAI-compatible API cannot raise it per request, and it silently drops the start of a longer prompt, system prompt included. So zhin errs low. SetcontextWindowto the CONTEXT column ofollama ps. Startup prints a hint when it is missing.thinkingLevel: reasoning depth,off | low | medium | high. When omitted, the provider default applies. zhin passes it to each SDK as the AI SDKreasoningoption, and the SDK maps it to its own setting: Gemini 3thinkingLevel(offbecomesminimal), the Gemini 2.5 thinking budget, the Claude thinking budget, or OpenAI and DeepSeek reasoning effort. A thinking setting already inproviderOptions(thinkingConfig,thinking,reasoningEffortoreffort) wins. MiniMax always uses adaptive thinking and ignores it.maxOutputTokens: output token cap per request, default8192. Gemini and Claude count thinking tokens against it, so raise it withthinkingLevel: highto keep replies from being cut off.compat(openai-compatible/ollama):includeUsage: request streamed usage (stream_options.include_usage). Defaulttrue. Without it most compatible servers stream no token counts. Setfalsefor a server that rejects the field.appendV1: whether/v1is appended tobaseUrl. Unset: a path that already names a version (/api/v3,/api/paas/v4,/v1beta/openai) or ends in/openaiis used as written; anything else gets/v1, so existinghttps://hostandhttps://host/apiconfigs behave as before.truealways appends (the old behaviour),falsenever does.promptCacheKey: send OpenAI'sprompt_cache_key. Defaultfalse, because strict gateways reject unknown fields.supportsDeveloperRole(sdk: openai):falsesends the system prompt of reasoning models (o-series, gpt-5) with thesystemrole, for OpenAI-protocol endpoints that rejectdeveloper.supportsReasoningContentandtoolCallsInAssistantare deprecated and have no effect.
authScheme: theAuthorizationscheme. Unset uses the SDK default (Bearerfor OpenAI-style SDKs,x-api-keyfor Anthropic,x-goog-api-keyfor Google).''sendsAuthorization: <key>;Bearer,Tokenand so on sendAuthorization: <scheme> <key>. Chat and model-list requests use the same rule.timeout,maxRetries: this provider's per-request timeout (ms, 0 disables) and retries per model. They overriderequestTimeoutMs/maxRetriesinai.providerResilience; a per-modelmodelHarness.requestTimeoutMsstill wins.firstChunkTimeoutMs,chunkTimeoutMs: streaming stall timeouts (ms), off by default. The first bounds the wait for response headers and the first chunk; the second bounds silence between chunks. A stall counts as a timeout: it is retried, then fails over to a fallback model. This catches a connected-but-silent request well beforetimeout; a few tens of seconds is a good value.imageGeneration: defaults for text-to-image.providerOptions: AI SDKproviderOptionskeyed by SDK name, merged into every request. For Gemini, this is where safety settings and thinking depth go:yamlai: providers: gemini: sdk: google apiKey: ${GEMINI_API_KEY} defaultModel: gemini-3.8-flash providerOptions: google: safetySettings: - { category: HARM_CATEGORY_HARASSMENT, threshold: BLOCK_ONLY_HIGH } thinkingConfig: { thinkingLevel: low }If Gemini refuses on safety grounds (SAFETY, PROHIBITED_CONTENT, RECITATION, a blocked prompt and so on), the user sees a polite refusal that names the reason instead of an empty reply. Such a refusal is neither retried nor sent to a fallback model: the same content gets the same verdict, and switching models to get past it would bypass your
safetySettings. Gemini uses implicit caching: the system prompt and tool definitions stay unchanged, and cache hits are reported ascacheReadin usage and ascache_read_tokensin the turn trace. For a steadier discount, turn on explicit caching withcontextCache: { mode: explicit, ttlSeconds: 600 }. zhin then creates acachedContentsentry for the system prompt and tool definitions and reuses it within the TTL. A changed prefix gets a new cache and the old one is deleted. If the server has dropped the cache, zhin resends the full request. If a cache can't be created (for example, the prefix is too short), it falls back to implicit caching. Cache storage is billed by the hour. Explicit caching works on Gemini 3 and 2.5 only; Gemini 4 Argon and later don't support it.Explicit caching also requires the proxy to implement
cachedContents. Creation failures are reported by theai-context-cachelogger withgemini_cache_create, the HTTP status when available, and the fallback reason. The existing failure backoff is retained for the same prefix, including across standalone text completions. Logs exclude prompts, credentials, and upstream response bodies. A reportedcacheReadcan come from implicit caching and does not prove that an explicit cache was created.Gemini requires tool names to match
^[A-Za-z_][A-Za-z0-9_.:-]{0,127}$. Plugin instance keys such as60sbecome the tool-name prefix, so a name like60s__skill__60s-life__weatherstarts with a digit and Gemini rejects it with 400Invalid function name. zhin rewrites such names before sending them to Gemini (for example_60s__skill__60s-life__weather, with a short hash added on a collision or when a name is too long) and maps the model's tool calls back. Execution, permissions and session history keep the original names, so you don't need to rename plugin keys. Valid names are sent unchanged, and other providers are not affected.Gemini through your own proxy: if the proxy simply forwards requests (same requests, same
x-goog-api-keyauth, same responses; only the host differs), pointbaseUrlat it. Both the root and a URL ending in/v1betawork; a trailing/is dropped and/v1betais never doubled. The default ishttps://generativelanguage.googleapis.com.generateContent, streamingstreamGenerateContent, explicit-cachecachedContents(create and delete) and the model list all use this URL. If the proxy needs extra request headers, add the optionalheaders; they are sent on every request:yamlai: providers: gemini: sdk: google apiKey: ${GEMINI_API_KEY} baseUrl: https://gemini-proxy.example.com # same as https://gemini-proxy.example.com/v1beta headers: # optional x-proxy-tag: zhinGroq (GroqCloud): Groq uses
openai-compatible, and zhin ships a Groq preset. With the aliasgroq(orgroqcloud) you can omitsdkandbaseUrl. AbaseUrlonapi.groq.comis treated as Groq under any alias, andsdk: openaiis switched toopenai-compatible:yamlai: providers: groq: sdk: openai-compatible baseUrl: https://api.groq.com/openai/v1 apiKey: ${GROQ_API_KEY} models: - openai/gpt-oss-120b - openai/gpt-oss-20b # thinkingLevel: medium # GPT-OSS defaults to low; medium / high also workCaveats:
- Earlier reasoning is sent back as
reasoning, the field Groq accepts. Groq rejects the SDK's defaultreasoning_content, which made the second step of every tool loop fail with a 400. - Groq rejects
logprobs,top_logprobs,logit_biasand annabove 1, so zhin never sends them. reasoning_effortgoes only to GPT-OSS and Qwen 3. GPT-OSS defaults tolow, which keeps replies fast and helps you stay under the free tier's 8K tokens per minute. Other models (Llama, for example) never get the field, even withthinkingLevelset.- GPT-OSS is text-only. Images are handled the way zhin handles any model without image support, so they never reach Groq. Llama 4 Scout and Maverick default to
text, image. An explicitinputalways wins. - The context window comes from the model id: 131072 for GPT-OSS and 128k for other models. Override it with
contextWindow. - Free-tier limits are low: GPT-OSS gets about 8K tokens per minute and 1,000 requests per day (see Groq rate limits). A 429 on a per-minute limit (RPM / TPM) is retried after
retry-after. A per-day limit (RPD / TPD) can't recover until the next day, so zhin skips retries and moves to the next candidate model (limits are per model). If no candidate is left, the user is told today's quota is used up.
- Earlier reasoning is sent back as
ai.agents
Bind Agent names to provider + model. zhin is the main Agent and must exist:
ai:
agents:
zhin:
provider: openrouter
model: openrouter/free
mcpServers: [icqq] # MCP servers this Agent uses
nickname: Zhi # how the AI refers to itself, and its display name
planner:
provider: openrouter
model: openrouter/free
permission: # which sub-agents it may dispatch
task: { "researcher": allow, "*": deny }| Field | Description |
|---|---|
provider / model | A name from ai.providers and a model id (required) |
mcpServers | Names from ai.mcpServers |
nickname | The AI's self-reference and display name |
permission.task | glob → allow / deny, limiting which sub-agents spawn_task may dispatch |
IM messages always go to zhin; other Agents are only called by name (spawn_task, collaboration roles).
Deprecated: priority / match
ai.agents.<name>.priority and match (including hasMedia, scene, adapter and every other condition) never took effect. They are still accepted, but startup logs a warning, zhin config check --fix deletes them, and they will be removed in the next minor release. To let a text-only model understand images, use ai.multimodal.perception.
ai.mcpServers
Connect the AI to external MCP servers and use their tools. Requires @modelcontextprotocol/sdk.
ai:
mcpServers:
- name: icqq
transport: streamable-http # stdio | streamable-http | sse
url: ${ICQQ_MCP_URL}
headers:
Authorization: Bearer ${ICQQ_MCP_TOKEN}
- name: filesystem
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/zhin-mcp-test"]- Once connected, the server's tools join the tool pool under names prefixed with the server name.
- If any server fails to connect, the new config doesn't take effect and the old version keeps serving.
ai.agents.<name>.mcpServersmay only reference names declared here, or config validation fails.
The other direction: make your bot an MCP server
To let external AI clients connect to your bot, use the top-level mcp: config and run pnpm add @zhin.js/mcp @zhin.js/agent zod ai. This is separate from ai.mcpServers; setting one doesn't enable the other. See MCP Server configuration.
ai.trigger
ai:
trigger:
prefixes: ["ai:"] # default ['#', 'AI:', 'ai:']
ignorePrefixes: ["/"] # default ['/', '!', '!']
respondToAt: true # default true: trigger when mentioned in a group
respondToPrivate: true # default true: trigger on every DM
keywords: [] # trigger when a DM contains one of these
timeout: 60000 # per-turn timeout (ms)
thinkingMessage: Thinking… # optional placeholder reply before processing
# errorTemplate: "Error: {error}" # optional; unset, the bot says it lagged in a chat voice and the owner also sees the cause
masters: ["10001"] # users with approval rights
trusted: [] # trusted users (below master)Mention triggering relies on the adapter flagging the message as mentioning the bot (CQ codes, Slack app_mention, etc.).
When a turn fails, the bot says so in a chat voice ("刚卡了一下,你再说一遍?") without mentioning models, keys or retries; when the sender is the owner (master), a short cause is appended in brackets, such as "(模型额度用完了)". The full error goes to the logs only. HTTP, Console and other non-chat entry points keep the precise messages. A configured errorTemplate still formats entry-level errors.
Mentions, quotes and DMs always reach the Agent. The Agent may still decide no reply is needed and return an empty reply ({"messages":[]}), in which case nothing is sent.
Ambient group engagement
When nobody addresses the bot in a group, it can decide by itself whether to chime in:
ai:
trigger:
ambient:
conversations: ["icqq:1048877509"] # platform:groupId; "*" for every group
judge: # judge model, defaults to the main Agent model; prefer a cheap, fast one
- groq/openai/gpt-oss-120b # a list is tried in order when one fails or is rate limited
- groq/openai/gpt-oss-20b
quietMs: 4000 # wait until the group is quiet, then judge the burst once
cooldownMs: 120000 # minimum gap between two unprompted interjections
maxPerHour: 3 # unprompted interjections per group per hour
conversationWindowMs: 180000 # after the bot speaks, the group is in a conversation for this long
judgeIntervalMs: 30000 # outside a conversation, judge a group at most this often
minConfidence: 0.7 # judge confidence required to engage
mode: active # shadow: log the judgment without replyingHow it works:
- After a new message, wait
quietMsfor the group to go quiet; messages in that window are judged together. - The judge reads the last 20 messages (the bot's own lines are labeled
You (this bot)) and returns whether to engage and how confident it is.- In a conversation (the bot spoke within
conversationWindowMs, addressed or not): replies to it depend only on the judge, not on the cooldown or hourly cap, with a safety ceiling of 30 per hour. - Otherwise: unprompted interjections are limited by
cooldownMsandmaxPerHour.
- In a conversation (the bot spoke within
- Above the threshold, the main Agent handles the message as usual. Its context says nobody called it, and it can still stay quiet.
Each judgment logs an ambient_engagement line (decision, confidence, reason). Run in shadow first to see whether the judgments are sound, then switch to active.
Let the structured-decision model judge
With structured decisions (Chinese) set up (for example TypeSafe Jev), the decision model can replace the chat-model judge for "should the bot speak up?", which is usually faster and more consistent:
ai:
decisions:
provider: root/typesafe
ambient:
mode: shadow # start in shadow: the judge list still decides; Jev's call is logged as ambient_decision_shadow
timeoutMs: 3000 # budget per judgment (default 3000); a timeout counts as a failure
# minConfidence: 0.8 # optional: treat "engage" below this probability as "stay quiet"
trigger:
ambient:
conversations: ["icqq:1048877509"]
judge: # fallback in active mode when Jev fails, times out or answers invalidly
- groq/openai/gpt-oss-120boffor unset: unchanged, only thejudgelist.shadow: thejudgelist decides; Jev judges the same messages and its answer is logged asambient_decision_shadowfor comparison.active: Jev decides; on failure thejudgelist takes over (a failed judge is passed over for 60 seconds). Theambient_engagementlog line namesdecision:<model>in itsjudgefield.
quietMs, cooldownMs, maxPerHour, conversationWindowMs, judgeIntervalMs, ambient.minConfidence and ambient.mode: shadow still apply; addressed, quoted, prefix-matched and private messages still skip the ambient judge. Both minConfidence values apply (the stricter wins).
ai.access
Control which conversations may use the AI (e.g. for platform AIGC compliance):
ai:
access:
mode: whitelist # open (default) | closed | whitelist
users: ["10001"] # allowed user ids
groups: ["123456"] # allowed group / channel ids
denyMessage: AI is not enabled for this conversation.Denied DMs get denyMessage; denied groups and channels get no reply.
ai.multimodal.perception
When the main model cannot see images (for example Groq gpt-oss takes text only), an image normally becomes a [Media unsupported:provider_input: image] placeholder and the AI does not know what is in it. With perception configured, an image-capable model (for example Gemini Flash-Lite) first describes the image in text, and that description goes to the main model:
ai:
providers:
groq:
sdk: openai-compatible
baseUrl: https://api.groq.com/openai/v1
apiKey: ${GROQ_API_KEY}
gemini:
sdk: google
apiKey: ${GEMINI_API_KEY}
agents:
zhin:
provider: groq
model: openai/gpt-oss-120b
multimodal:
perception:
provider: gemini # required, a name from ai.providers
model: gemini-2.5-flash-lite # required, must declare input for the kinds below
kinds: [image] # optional: image | video | file, default [image]; file means PDF only
maxChars: 800 # optional: description length cap, default 800
timeoutMs: 30000 # optional: per-request timeout, default 30000
maxItemsPerTurn: 4 # optional: media described per turn, default 4| Field | Meaning |
|---|---|
provider / model | The model that describes media. provider must be a name from ai.providers; a wrong value fails startup and zhin config check reports it |
kinds | Kinds to describe. Audio is not here; it keeps using speech-to-text from ai.multimodal.audio |
maxChars | Longer descriptions are cut off |
timeoutMs | Per-request timeout. A failure is retried at most once, with the same retry, timeout and error classification as the main chat |
maxItemsPerTurn | Media beyond this count in one turn keep the placeholder |
How it behaves:
- Only media the main model cannot take is described. If the main model can see images, the image goes to it directly and
perceptionis not called. - A description starts with
[图片描述]/[视频描述]/[文件描述]and is marked as untrusted external content: text inside an image can carry instructions, so the model treats it as data only. A turn that read a description counts as having seen external content, so running commands, messaging other conversations, changing config or creating scheduled tasks afterwards needs approval, and the approval card lists "图片/媒体的自动描述" (automatic media description) in its source chain. - The description is saved with the user message in the session history, so later turns can still ask about "that image". The image itself is not saved.
- Images with the same content (sha256 of the bytes) are described once, so common stickers and memes do not cost another call.
- If a description fails, times out or exceeds
maxItemsPerTurn, the old placeholder is used and the turn carries on. Each media item's result is recorded as amedia_resolutionevent in thezhin tracetimeline; a successful description hascode: media_perceptionand names the model used. - Without
perception, behaviour is exactly as before.
ai.agent
ai.agent controls how the AI runs — most importantly, the security policy for execution tools like bash:
ai:
agent:
execSecurity: allowlist # deny | allowlist (default) | full
execPreset: readonly # readonly | network | development | custom
execAllowlist: [make] # allowlist when execPreset=custom
execApprovalMode: ask # ask (default) | bypass | auto
gitStatus: true # default true: one-line git status in the prompt (skipped outside git repos)
contextPaths: [] # extra context files for the system prompt (~ and relative paths ok)
systemPromptMaxChars: 100000 # total system prompt character limit
inboundQueue:
groupMode: supersede # default: a sender's new message replaces their own unanswered turn; fifo queues everything
maxQueuedTurns: 5 # queued turns allowed per conversation
ttlMs: 180000 # queue timeout
merge: # group merge window (ADR 0038), group chats only
enabled: true
quietMs: 1500 # flush after this much quiet
maxWaitMs: 6000 # longest a window waits
maxSenders: 5 # senders per merged reply
live: # danmaku mode (ADR 0038), liveDanmaku adapters only
mode: pick-one # pick-one answers one danmaku / summary answers the window once
windowMs: 10000 # danmaku window
minIntervalMs: 15000 # minimum gap between two danmaku repliesCommand allowlists: execPreset widens step by step — readonly (ls, cat, grep, find…) → network (adds curl, wget, ping…) → development (adds npm, node, git, python…). In every mode, dangerous commands such as sudo, eval, dd and export are refused, and operations like rm -rf node_modules are hard-blocked.
Approval modes:
execApprovalMode | Behavior |
|---|---|
ask (default) | Ask a master for approval |
auto | A tool-less reviewer Agent decides; if it can't, it asks a master; model errors, timeouts or invalid output are denied |
bypass | Skip approval — but never the dangerous-command blocklist, tool permissions, file / network boundaries or the sandbox |
In groups and channels an approval offers "deny / just this once / this conversation / always allow"; in DMs, "deny / just this once / always allow". "This conversation" applies to everyone in it; "always allow" applies only to the current sender; every lasting decision is bound to the current conversation. /approve always bash and /approve rule <regex> manage this account's persistent allow rules.
More details
- Global context files: besides
contextPaths,~/.config/zhin/AGENTS.mdand~/.config/zhin/ZHIN.mdare loaded automatically (skipped if absent) as a# User Contextsection before project context; 8KB per file, 16KB total. - Prompt budget: over
systemPromptMaxChars,opportunisticcontent is removed first; ifrequiredcontent doesn't fit, the turn fails explicitly. - Command check chain: dangerous blocklist → strip env prefixes (
FOO=bar cmdmatches ascmd) → strip wrappers (timeout 10 cmd) → split compound commands (&&,|checked per segment) → outsidefull, reject newlines,$(...)and backticks → read-only commands pass automatically. Approval goes through the current turn'sApprovalPort. - Legacy approval files: V1 approval files aren't migrated online; rebuild rules explicitly before upgrading.
- Message queueing:
inboundQueue.groupModeis also the default turn-intent policy.supersedelets a sender's new message replace their own unanswered turn (other members' messages always queue and never interrupt someone else);fifoqueues in arrival order. See Agent: messages that arrive while a turn is running.steer,follow_upandobserveare set explicitly by adapters, commands or other policies; cross-participantsteer/follow_upauthorization can only come from the Host's trustedresolveTurnIntentpolicy, never from message metadata. Tools stay bound to the active turn's authority, and the execution log records who triggered the control and the turn ID.
How it works
At startup Zhin tidies the ai: config: providers whose credentials expand to empty are dropped (debug log only), and Agents bound to them are skipped. If no usable zhin Agent remains, AI stays off — without affecting the rest of the bot.
Next steps
- Agent tools and Skills: let the AI call your functions.
- Agent deep dive: turns, on-demand tools, sub-tasks, sessions and context compaction.
- Speech: speech recognition and voice replies.
- Console: watch AI conversations and runs in the browser.