* feat(core): add deferred tool call bridge * fix(core): update read-file image guidance regex to the two-step bridge wording (#10410) * fix: resolve remaining merge conflicts from main merge (#10410) * fix(core): address round-1 review for the deferred tool-call bridge (#10410) Security/correctness: - Re-check the execution allowlist against the resolved tool_call target in the scheduler (fork allowlist previously only saw the wrapper name). - Skip the pending bridge TOOL_CALL start on abort so a cancelled call never emits a start event after its synthetic TOOL_RESULT. - Route INVALID_TOOL_PARAMS bridge resolution errors through the validation-retry loop so repeated bad envelopes get the stop directive. - Reject a hidden deferred target when tool_search is unregistered, and make the TOOL_NOT_REGISTERED remedy conditional on tool_search existing. - Seed announcedMcpToolNames for eagerly revealed MCP tools so a later disconnect is still announced when the bridge is incomplete. - buildDeferredToolsReminder returns null when the bridge is incomplete. Docs (bridge-registered caveats, two-half reachability, DeepSeek removal, schema regeneration, table cells, wording): settings.md, settingsSchema.ts + regenerated settings.schema.json, sdk types.ts/README/sdk-typescript.md, design doc, skills/types.ts, web-shell i18n. Tests: allowlist re-check, abort ordering, retry directive, leader-only and tool_search-absent rejections, revealed-tool exclusion, bridge availability both directions, PM bridge gate, cancel-after-resolution rename, incomplete- bridge reminder, MCP disconnect announcement, zoom hint wording. * test(cli): model the complete bridge in the ACP tool_call routing test (#10410) * test(integration): adapt sdk-mcp-server E2E to the deferred-tool bridge contract (#10410) Under the ToolSearch + ToolCall bridge, tools fetched via tool_search select: stay hidden from the model-facing declaration list so the prompt-cache prefix remains stable (they are invoked through tool_call; direct invocation by name still executes, as the resume test already exercises). Flip the three fresh-session advertisement assertions from 'tool is advertised after select:' to 'tool remains hidden', matching the contract documented in tool-search.ts. The resume-restoration test is unchanged and already passes at the merge commit. * fix(core): normalize the legacy deny gate for tool_call like the loop fallback (#10410) The no-PermissionManager bridge deny gate matched deny entries exactly (canonicalToolName is alias-resolution only — no lowercase/trim), but the _schedule legacy-deny fallback it delegates to compares excludedTool.toLowerCase().trim() === normalizedToolName. Config stores permissions.deny entries verbatim, so an entry like 'Tool_Call' or ' tool_call ' slipped past the gate's exact compare: the envelope was unwrapped and the fallback then checked the deny list only against the resolved TARGET name — executing a call the same config denies when it arrives as a direct tool_call (pre-diff behavior). Mirror the loop's normalization on the gate so both paths deny identically (R2-1). Adds three parameterized cases ('Tool_Call', ' tool_call ', 'TOOL_CALL') extending the legacy-deny pin; removing the normalization turns all three red while the exact-entry case stays green (verified). Full coreToolScheduler.test.ts 395/395. * fix(core): complete the bridge remedy list, eager-reveal disconnect tracking, and prune pinning (#10410) Three fix-induced findings from the bridge rounds: R1-15: the unreachable-tools warning's remedy clause now names tools.disabled alongside deny rules and --exclude-tools, matching the cause enumeration the PR documents in settingsSchema.ts; the warning test pins the mention. R1-28: an eager-reveal of an MCP tool (incomplete bridge) now also records the name in announcedMcpToolNames. The seed alone only reaches that set via rememberAnnouncedDeferredTools, which runs exclusively in startChat — so a server registering after the initial startChat used to disconnect silently and the model kept calling a dead server's tools. New test covers the mid-session reveal, the disconnect announcement, and a reconnect/disconnect flap announcing twice; removing the add turns it red (verified). R1-18: new interleaved test pinning the batch-start prune as the only mechanism that clears a tool_call-keyed retry counter across a successful bridged execution (recording keys on the model-facing name, prune keys on post-resolution names); disabling the prune fires RETRY LOOP DETECTED prematurely and turns the test red (verified). client.test.ts + coreToolScheduler.test.ts: 770/770 green. * docs: qualify the bridge reachability claims to match the code (#10410) Four fix-induced doc findings, applied together across all copies: R1-12 (9 locations): 'the demoted tools are out of reach for that session' is now qualified with the two escape hatches the code has — tools also listed in tools.visible are declared upfront, and resumed sessions re-declare demoted tools referenced by direct calls in the transcript (consistent with client.ts's own 'would be false for this session' comment). R1-25 (settings.md tools.core/discoveryCommand/migration rows): 'stay reachable through tool_search + tool_call' now carries the 'while both bridge tools are registered' condition. R1-9 (threshold, 3 copies): 'Set 0 to always keep deferred tools behind the bridge' overstated — two reveal paths ignore the threshold; replaced with the qualified wording naming resumed-history replay, tools.visible, and the incomplete-bridge fallback. R2-2 (4 locations): the unaffected/bypass lists now name tool_call next to tool_search, matching isExemptFromEagerAllowList which exempts both identically. * test(integration): harden the bridge-contract E2E assertions (#10410) R2-9: the flipped not.toContain declaration assertions now carry positive controls on the same snapshot — the post-select list must contain both alwaysLoad bridge tools (tool_search, tool_call) — so an emptied or index-shifted declaration list can no longer pass them vacuously. R2-15: the first test now scripts the fake model to invoke the selected tools through tool_call envelopes (the documented post-select path), exercising resolveDeferredToolCall end-to-end through query(); results are paired by the model-facing name ('tool_call') with content checks, since the scheduler keeps modelFacingName on the wire. Reverting the script to direct-by-name calls would remove the suite's only end-to-end signal for a broken invocation half. typecheck -p integration-tests clean. * chore(core): address wenshao verification notes on the bridge (#10410) 1. tool-search.ts:326 — Prettier-format the schemaBlocks map (slipped through CI because the format step runs --write, not --check). 2. tool-call.test.ts — pin the recursive-bridge guard by its message ('cannot invoke bridge tool'), not just errorType: the downstream isDeferredAndHidden rejection returns the same INVALID_TOOL_PARAMS, so the old errorType-only assertion stayed green with the guard deleted. Mutation check confirms the strengthened test now kills that mutant (2 failed with the guard removed, 12/12 intact). * fix(core): enforce the subagent/teammate tool-exclusion set in the tool_call bridge (#10410) R4-1 (review round 4): the tool_call bridge bypassed the subagent/teammate tool-exclusion set. EXCLUDED_TOOLS_FOR_SUBAGENTS / EXCLUDED_TOOLS_FOR_TEAMMATES were enforced only at declaration level (prepareTools isExcluded), but the bridge makes invocation independent of declaration without carrying the exclusion set over — resolveDeferredToolCall enforced only the plan-lifecycle and leader-only policies, tool_search had no exclusion filter, and the scheduler's post-unwrap re-checks never consulted the set. A wildcard/ general-purpose subagent (or teammate) could therefore discover (tool_search) and execute (tool_call) control-plane tools it must not reach (team_delete, cron_*, workflow, send_message, ...); the probe executed team_delete end-to-end through the bridge from a subagent context. The merge base rejected the same trigger (undeclared names fail the declaredToolNames gate), so the bridge introduced the path. Fix: enforce the same context-aware exclusion set in resolveDeferredToolCall, after the plan-lifecycle/leader-only checks (so those keep their specific messages). To avoid a circular import (agent-core imports coreToolScheduler, which imports tool-call), the exclusion sets and getExcludedToolsForCurrentContext moved from agent-core.ts to subagent-plan-tool-policy.ts (already imported by tool-call.ts); agent-core.ts re-exports them for existing consumers. The check is guarded by isSubagentLikeExecutionContext so the leader context is not gated. Adds a mutation-checked test (rejects an exclusion-set target team_delete / workflow bridged from a subagent context): removing the exclusion check turns both red. tool-call.test.ts 14/14; tool-search + coreToolScheduler 447; subagent-plan-tool-policy 6; agent-core + background-agent-resume 99; core tsc --noEmit clean. * fix: remove unused SUBAGENT_PLAN_LIFECYCLE_TOOLS import from agent-core (TS6133) The exclusion sets that spread SUBAGENT_PLAN_LIFECYCLE_TOOLS moved to subagent-plan-tool-policy.ts so the tool_call bridge can enforce them without a circular import; the import in agent-core.ts was left behind and tsc --build flags it as unused (TS6133). * fix(core): address round-5 review on the tool_call bridge exclusion (#10410) R4-1 follow-up (Critical): the flat exclusion denied AgentTool through the bridge even while maxSubagentDepth permitted the spawn — prepareTools depth-gates AgentTool (re-admits it while spawnBlockReason === null) but the bridge consumed the raw set. Add the shared predicate isToolExcludedForCurrentContext (subagent-plan-tool-policy.ts) that carries the depth-gated re-admission, consumed by BOTH prepareTools and resolveDeferredToolCall so the layers cannot drift; the scheduler threads the real configured maxSubagentDepth through. Without the configured depth the bridge fails closed on AgentTool (the raw-set floor). The predicate is deliberately ungated so prepareTools keeps its frameless fail-closed contract; the bridge and tool_search apply their own isSubagentLikeExecution context gate, leaving the leader session untouched. R5-1: mirror the exclusion on the discovery side — collectCandidates and the select: blocked predicate now drop context-excluded tools, so subagents are no longer advertised the schemas of tools they are forbidden to invoke (schema leak + wasted turns). Leader re-inspection stays unrestricted. R5-2: move the plan-lifecycle / leader-only / exclusion checks ahead of the isDeferredAndHidden gate (relative order kept). Real excluded tools are frequently not deferred (workflow/team_delete/todo_write shouldDefer=false, enter_plan_mode constructed shouldDefer=false), so the old gate order misrouted them into the factually-wrong 'already visible — call it directly' INVALID_TOOL_PARAMS denial and the scheduler's malformed-envelope retry counting. R5-3..R5-6 test pins: SEND_MESSAGE subagent denial + teammate resolve/deny cases discriminate the context-aware selector; the plan-tool denial pins the dedicated message; allow-side cases pin the context gate (leader and in-frame non-excluded resolution); the legacy alias case pins canonical-name membership keying. Fixtures now use the real non-deferred shape. Mutation-verified: nine mutants (check removed, selector swapped both ways, plan check removed, envelope-name keying, flat AgentTool, bridge gate removed, blanket in-frame denial, tool-search filter removed) all turn the new tests red; suites green (tool-call 22, tool-search 56, policy 6, agent-core 49, coreToolScheduler 396). * fix(core): close the two round-5 deferred bridge items (#10410) Case-insensitive resolution: tool_search's select: resolves requested names case-insensitively, but the tool_call half resolved them exactly, so a schema reviewed as e.g. Read_File was not callable through the bridge. The invocation half now falls back to a case-insensitive match against the registered names (exact-case names keep the fast path), and the fallback feeds the recursive bridge guard, so casing cannot dodge it either. Remedy pin: 'rejects an unknown deferred target' now pins the present-side remedy ('Run tool_search again...'); the absent-side constant was already pinned by the sibling case, so both remedy branches are mutation-checked. Mutation-verified: removing the fallback turns 2 tests red; dropping the remedy suffix turns 1 red; suite green (24). * fix(core): address round-6 review — disallowedTools re-check and stale DeepSeek text (#10410) R6-8 (Critical): the bridge resolved around the declaration list, so a per-agent disallowedTools blocklist — enforced only at declaration level in prepareTools() — did not stop a bridged call: a subagent configured with disallowedTools: ['mcp__slack'] could tool_call mcp__slack__post_message to execution, where the merge base synthesized 'Tool not found'. Fold the blocklist into AgentCore.isToolExecutionAllowed with prepareTools' exact match semantics (MCP server-level patterns via matchesMcpPattern, exact match otherwise); the predicate already feeds both the direct-call gate and the scheduler's bridge target re-check, so one fold closes both. The scheduler's bridge denial message now covers allowlist and blocklist. Mutation check: removing the fold turns the new agent-core test red. Discovery-side mirror (tool_search candidates) is deliberately deferred: it needs the blocklist threaded into the agent context frame across all launch sites; invocation is fail-closed either way (ruled in the thread). R6-1..R6-7: drop the stale 'automatic opt-out for DeepSeek models' references from every string this PR writes (settingsSchema + generated vscode schema, SDK types/README, user/dev docs) — this PR's config change removes that opt-out, so the text must not keep citing it; also drop the DeepSeek disable advice from the tools.toolSearch.enabled doc row (round-6 deferred list item). Triage follow-up (wenshao): the ACP frontend now threads the configured maxSubagentDepth through resolveDeferredToolCall, matching the terminal scheduler and tool_search so the depth-gated AgentTool re-admission is consistent across all three frontends (omitting it failed closed, but the corner case now agrees everywhere). Verified: core typecheck clean; agent-core 50, tool-call 24, tool-search 56, coreToolScheduler 396 — all green; blocklist mutant red. * test(cli): give the Session test config mock getMaxSubagentDepth (#10410) The ACP bridge now threads the configured depth through resolveDeferredToolCall; the shared partial Config mock in Session.test.ts cast 'as unknown as Config' lacked the method, so 'routes tool_call through a hidden deferred tool in ACP' crashed with a TypeError. Mocks claiming the Config interface should carry the methods the code under test calls — verified the target test green locally. * fix(docs/core): address round-7 review — deny-vs-remove semantics, tie-break parity, precedence pin (#10410) R7-1 (Critical): the rewritten tools.core row claimed permissions.deny 'removes a tool outright', but deny never removes MCP tools from the registry — ToolRegistry.registerTool gates only on disabledTools and mcp-client registration never consults the PermissionManager. Restore the split semantic (deny blocks calls; tools.disabled / per-server excludeTools remove from the registry), matching the SDK README and the permissions.deny row's own wording. The pasted migration-table row at :492 gets the same wording fix AND its structure repaired (it was a 5-cell settings-table row inside the 3-column migration table; now a proper Legacy/Equivalent/Notes row). R7-14: the invocation half's case-insensitive fallback now uses last-match like the discovery half's lowerIndex (Map keyed by lowercase name, last registered variant wins), so both bridge halves resolve the same tool for a case-variant request. Mutation check: first-match turns the new tie-break test red. R7-13: new test composing executionAllowedTools with disallowedTools — the blocklist wins for an allowlisted-and-blocklisted bridged target. Mutation check: removing the blocklist fold turns both blocklist tests red. Verified: tool-call 25 + agent-core 51 green; core typecheck clean. * fix: align deferred tool case resolution * fix(core): preserve bridged tool identities * chore(docs): minimize formatting churn * fix(core): handle deferred tool edge cases * fix(core): close deferred bridge gaps * fix(core): fail closed on malformed bridged classifier entries, correct bridge docs (#10410) - toAutoClassifierInput coerces a missing/non-string envelope name and fails closed on the name alone, so a malformed bridged history entry can no longer throw into the caller's raw-args fallback and leak unredacted arguments into the AUTO classifier prompt (R16-3). - The prefix-stability guarantee no longer scopes mid-session declaration refreshes to resumed sessions: any tool-set refresh re-declares a still-hidden deferred tool that live history calls directly. Swept through settings.md, settingsSchema.ts (+ regenerated schema), i18n zh, SDK README/JSDoc, and the tool_search description (R16-2). - SDK docs now state that the incomplete-bridge warning goes to the CLI child's stderr, which the SDK discards unless debug is enabled (R16-4). - Correct the collectCandidates mutation comment: the keyword assertion pins the layered collectCandidates + returnSchemas filter pair, not the single filter (verify-report F2). * fix(core): redact orphaned tool call envelopes * docs(sub-agents): clarify deferred-bridge exception to the tools allowlist (#10410) Co-authored-by: Qwen-Coder <[email protected]> * fix(core): preserve Goal provenance for bridged duplicates * fix(core): guard canonical tool-name lookup * fix(core): keep bridged envelope payload in loop-detection repeat key - getLoopDetectionToolCall substituted {} when a bridged envelope's `arguments` was not a plain object (e.g. a JSON string awaiting SchemaValidator repair), collapsing every such call to the same target onto one repeat key; the always-on consecutive-identical guard then halted turns of distinct calls. Keep the envelope itself as the key material so distinct payloads stay distinct. - Correct the deferred-tool refresh enumeration in all doc copies: subagent completion never triggers a declaration refresh; the real triggers are resume, MCP discovery, the first plan-mode entry in a session, and subagent definition changes. Regenerated settings.schema.json from the settings source. * fix(core): bound nested tool_call classifier projection, correct toolSearch cache text (#10410) * fix: address deferred bridge review findings * feat(core): default tools.toolSearch.threshold to 0 (always-defer) (#10410) The preload gate shipped on by default for one reason: revealing a deferred tool rewrote the function-declaration list at the front of the prompt prefix, so a single tool_search load busted the whole KV cache. With a small deferred set the token savings were negligible, which made deferral a net loss. The tool_search + tool_call bridge removes that premise — a bridge reveal never touches the declaration list — so always-defer is now affordable and matches Claude Code's ENABLE_TOOL_SEARCH default. What preloading still buys is the one tool_search round trip before a deferred tool's first use: a per-tool one-off, not the whole-session compounding cost a cache bust was. The gate itself is unchanged — still session-start only, still one budget over bundled and MCP deferred tools alike, still all-or-nothing — so raising the threshold opts back in exactly as before. Safe mode and bare mode already forced 0, so that path is untouched. Regenerated settings.schema.json from the settings source and corrected both design docs, whose cache-bust rationale the bridge supersedes. * fix(core): preserve deferred fork tools * fix(core): harden bridge history consumers against malformed envelopes - microcompact: guard non-string tool names from unvalidated history, and record an unparseable-call sentinel so a reused call id fails the identity unanimity check instead of compacting on partial evidence - goals: classify Goal bookkeeping provenance case-insensitively so a case-variant bridged get_goal/update_goal result cannot enter the evidence catalog as an external fact - client: seed recentCompletedToolNames from the resolved bridge target on resume, matching what the live path records - docs: record the missing presentation-mark/schema-fingerprint gate as a known limitation pending the #11321 scope decision * test(core): raise code-mode waitFor deadlines past host boot time The verification gate rejected the previous round on three failures that never reached an assertion: the code-mode host spawns a tsx child whose boot alone measures ~4.5s on the oversubscribed runners, while exec-context-tools waited the vi.waitFor default (1s) for the first nested dispatch and two scheduler tests waited 4s for nested dispatch / awaiting_approval. Raise the three inner deadlines to 10s and the two containing test timeouts to 20s. No assertion changes. * fix(core): align bridge wording and gates with the two-half tool bridge (#10410) - Reword the hidden-deferred-tool sentence (shared by the Workflow tool description, its failure hint, and the workflow-keyword reminder) to the bridge flow: review the schema with tool_search, then invoke with tool_call; the old 'reveal with ToolSearch first' wording instructed a mechanism tool_search no longer performs (R27-1). - Gate the workflow-authoring route and the keyword reminder on BOTH bridge halves, so a tool_search-only session inlines the reference and drops the steering instead of pointing at a route that cannot invoke (R27-2). - Enforce the registry's isToolDeclared capability gate in resolveDeferredToolCall, matching tool_search's select: refusal, so tool_call cannot execute a registered-but-undeclared target such as propose_goal outside a goal-proposal turn (R27-3). - Carry the parent agent's per-agent disallowedTools blocklist through the agent ALS frame into a fork's execution allowlist and toolConfig, so the union with the live registry cannot re-admit a tool the parent was configured never to reach (R24-1). Co-authored-by: Qwen-Coder <[email protected]> * fix(core): enforce configured tool allowlists across bridge * fix(core): persist fork disallowedTools blocklist across background resume (#10410) * fix(core): admit exec under CodeModeOnly when configured tools omit it (#10410) In code mode the registry declares exec unconditionally, so folding a finite toolConfig.tools list that omits exec into the execution allowlist refused the only declared tool and degraded the agent to text-only. Carve exec out in the executionAllowedTools === undefined branch, mirroring the existing carve-out below; the blocklist check still runs first, so disallowedTools: ['exec'] keeps winning. Co-authored-by: Qwen-Coder <[email protected]> * fix(core): align deferred bridge policy and redaction * fix(core): preserve deferred fork tool access * fix(core): restore deferred bridge in containers * fix(core): preserve deferred bridge policy and accounting * fix(core): distinguish deferred bridge outcomes on resume --------- Co-authored-by: Shaojin Wen <[email protected]> Co-authored-by: qwen-code-dev-bot <[email protected]> Co-authored-by: Qwen-Coder <[email protected]> Co-authored-by: qwen-code-ci-bot <[email protected]> Co-authored-by: Qwen Code Autofix <qwen-code-autofix[bot]@users.noreply.github.com> Co-authored-by: yiliang114 <[email protected]> Co-authored-by: 易良 <[email protected]>
73 KiB
@qwen-code/sdk
A minimum experimental TypeScript SDK for programmatic access to Qwen Code.
Feel free to submit a feature request/issue/PR.
Installation
npm install @qwen-code/sdk
Requirements
- Node.js >= 22.0.0
From v0.1.1, the CLI is bundled with the SDK. So no standalone CLI installation is needed.
Quick Start
import { query } from '@qwen-code/sdk';
// Single-turn query
const result = query({
prompt: 'What files are in the current directory?',
options: {
cwd: '/path/to/project',
},
});
// Iterate over messages
for await (const message of result) {
if (message.type === 'assistant') {
console.log('Assistant:', message.message.content);
} else if (message.type === 'result') {
console.log('Result:', message.result);
}
}
API Reference
query(config)
Creates a new query session with the Qwen Code.
Parameters
prompt:string | AsyncIterable<SDKUserMessage>- The prompt to send. Use a string for single-turn queries or an async iterable for multi-turn conversations.options:QueryOptions- Configuration options for the query session.
QueryOptions
| Option | Type | Default | Description |
|---|---|---|---|
cwd |
string |
process.cwd() |
The working directory for the query session. Determines the context in which file operations and commands are executed. |
model |
string |
- | The AI model to use (e.g., 'qwen-max', 'qwen-plus', 'qwen-turbo'). Takes precedence over OPENAI_MODEL and QWEN_MODEL environment variables. |
pathToQwenExecutable |
string |
Bundled CLI | Path to the Qwen Code executable. Supports multiple formats: 'qwen' (native binary from PATH), '/path/to/qwen' (explicit path), '/path/to/cli.js' (Node.js bundle), 'node:/path/to/cli.js' (force Node.js runtime), 'bun:/path/to/cli.js' (force Bun runtime). If not provided, the SDK uses the bundled CLI included with the package. |
permissionMode |
'default' | 'plan' | 'auto-edit' | 'auto' | 'yolo' |
'default' |
Permission mode controlling tool execution approval. See Permission Modes for details. |
canUseTool |
CanUseTool |
- | Custom permission handler for tool execution approval. Invoked when a tool requires confirmation. Must respond within 60 seconds or the request will be auto-denied. See Custom Permission Handler. |
env |
Record<string, string> |
- | Environment variables to pass to the Qwen Code process. Merged with the current process environment. |
systemPrompt |
string | QuerySystemPromptPreset |
- | System prompt configuration for the main session. Use a string to fully override the built-in Qwen Code system prompt, or a preset object to keep the built-in prompt and append extra instructions. |
mcpServers |
Record<string, McpServerConfig> |
- | MCP (Model Context Protocol) servers to connect. Supports external servers (stdio/SSE/HTTP) and SDK-embedded servers. External servers are configured with transport options like command, args, url, httpUrl, etc. SDK servers use { type: 'sdk', name: string, instance: Server }. |
abortController |
AbortController |
- | Controller to cancel the query session. Call abortController.abort() to terminate the session and cleanup resources. |
debug |
boolean |
false |
Enable debug mode for verbose logging from the CLI process. |
maxSessionTurns |
number |
-1 (unlimited) |
Maximum number of conversation turns before the session automatically terminates. Must be an integer. A turn consists of a user message and an assistant response. |
coreTools |
string[] |
- | Uses the legacy coreTools / CLI --core-tools allowlist semantics. If specified, only matching core tools are registered for the session. This is the only allowlist-style option that restricts built-in tool registration; a whole-tool permissions.deny / excludeTools rule (and tools.disabled in settings.json) also removes a tool from the registry. permissions.allow in settings.json is pure auto-approval and never removes, demotes, or hides a tool (#10075). To keep a tool's schema out of the initial model request, use tools.eager in settings.json (requires restart, #9827) — tool_search, tool_call, structured_output, plan-mode lifecycle tools, task_stop, mcp__* and computer_use__* tools are exempt from that allowlist and keep their normal loading; tools demoted this way stay registered and reachable through tool_search + tool_call while both bridge tools are registered — when either is unregistered (tools.toolSearch.enabled: false denies both; a tool_search or tool_call deny rule, or a tools.disabled entry removes one) the demoted tools that remain hidden are not offered to the model and cannot be reached through the bridge for that session, and a warning is written to the CLI process's stderr (SDK forwards it only with piped stderr and effective debug logging; an explicit logLevel always wins). These bridge and warning rules apply to direct tool mode. CodeModeOnly hides both bridge tools, keeps full nested schemas for callable deferred tools in exec, and skips deferred reminders and this warning; tools.eager does not make them unreachable or save their schema tokens. In direct mode they stay registered, so a direct call by their own name is still evaluated and approved normally — except tools also listed in tools.visible, which are declared upfront, and sessions whose live history contains a direct call to a still-hidden demoted tool, which any tool-set refresh (resume, MCP discovery, the first plan-mode entry in a session, a subagent definition change) re-declares.; to remove a tool entirely, use a whole-tool excludeTools / permissions.deny rule — a rule with a specifier (such as 'Bash(rm *)') only denies matching invocations at runtime. MCP tools are exempt from deny-based removal: hide them with the per-server excludeTools / tools.disabled filters instead (deny still blocks their calls at runtime). Example: ['read_file', 'edit', 'run_shell_command']. |
excludeTools |
string[] |
- | Equivalent to permissions.deny in settings.json. Excluded tools return a permission error immediately. Takes highest priority over all other permission settings. Supports tool name aliases and pattern matching: tool name ('write_file'), shell command prefix ('Bash(rm *)'), or path patterns ('Read(.env)', 'Edit(/src/**)'). |
allowedTools |
string[] |
- | Equivalent to permissions.allow in settings.json for auto-approval. Matching tools bypass canUseTool callback and execute automatically. Only applies when tool requires confirmation. Like permissions.allow, this is pure auto-approval and never affects which tools are registered or which schemas are sent (#10075). Supports same pattern matching as excludeTools. Example: ['Bash(git status)', 'Bash(npm test)']. |
authType |
'openai' | 'anthropic' | 'qwen-oauth' | 'gemini' | 'vertex-ai' |
- | Authentication type for the AI service. When provided, the SDK forwards it to the CLI as --auth-type. |
agents |
SubagentConfig[] |
- | Configuration for subagents that can be invoked during the session. Subagents are specialized AI agents for specific tasks or domains. |
includePartialMessages |
boolean |
false |
When true, the SDK emits incomplete messages as they are being generated, allowing real-time streaming of the AI's response. |
resume |
string |
- | Resume a previous session by providing its session ID. Equivalent to CLI's --resume flag. |
sessionId |
string |
- | Specify a session ID for the new session. Ensures SDK and CLI use the same ID without resuming history. Equivalent to CLI's --session-id flag. |
Tip
If you need to configure
coreTools,excludeTools, orallowedTools, it is strongly recommended to read the permissions configuration documentation first, especially the Tool name aliases and Rule syntax examples sections. Rule patterns such asBash(git *),Read(.env), andEdit(/src/**)apply toexcludeToolsandallowedTools;coreToolsaccepts aliases but strips invocation specifiers.
Timeouts
The SDK enforces the following default timeouts:
| Timeout | Default | Description |
|---|---|---|
canUseTool |
1 minute | Maximum time for canUseTool callback to respond. If exceeded, the tool request is auto-denied. |
mcpRequest |
1 minute | Maximum time for SDK MCP tool calls to complete. |
controlRequest |
1 minute | Maximum time for control operations like initialize(), setModel(), setPermissionMode(), getContextUsage(), and interrupt() to complete. |
streamClose |
1 minute | Maximum time to wait for initialization to complete before closing CLI stdin in multi-turn mode with SDK MCP servers. |
You can customize these timeouts via the timeout option:
import { query } from '@qwen-code/sdk';
const q = query({
prompt: 'Your prompt',
options: {
timeout: {
canUseTool: 60000, // 60 seconds for permission callback
mcpRequest: 600000, // 10 minutes for MCP tool calls
controlRequest: 60000, // 60 seconds for control requests
streamClose: 15000, // 15 seconds for stream close wait
},
},
});
Experimental Daemon Session Client
DaemonSessionClient is an experimental wrapper for clients that talk to a
running qwen serve daemon over HTTP + SSE. It binds one daemon session so TUI,
channel, IDE, or web backend adapters do not need to pass sessionId into every
call.
import { DaemonClient, DaemonSessionClient } from '@qwen-code/sdk';
const daemon = new DaemonClient({
baseUrl: 'http://127.0.0.1:4170',
token: process.env['QWEN_SERVER_TOKEN'],
});
const caps = await daemon.capabilities();
const session = await DaemonSessionClient.createOrAttach(daemon, {
workspaceCwd: caps.workspaceCwd,
});
const eventController = new AbortController();
const eventTask = (async () => {
for await (const event of session.events({
signal: eventController.signal,
})) {
console.log(event.type, event.data);
}
})();
const result = await session.prompt({
prompt: [{ type: 'text', text: 'Summarize this workspace.' }],
});
eventController.abort();
await eventTask;
console.log(result.stopReason);
session.events() tracks the last seen SSE event id and reuses it on the next
subscription by default. Pass { resume: false } to start a fresh subscription
without sending Last-Event-ID.
When createOrAttach() is called with modelServiceId, the returned session
client seeds its first event subscription with Last-Event-ID: 0. This replays
the daemon ring from the oldest available event so adapters can observe
attach-time model_switch_failed or model_switched events that are not
reported on the create/attach HTTP response. Raw DaemonClient callers should
pass { lastEventId: 0 } on their first subscribeEvents() call when they use
modelServiceId.
The raw event envelope remains available as DaemonEvent with data: unknown.
Adapters that want a v1 typed view can layer the schema helpers on top without
changing the wire stream:
import {
asKnownDaemonEvent,
createDaemonSessionViewState,
reduceDaemonSessionEvent,
} from '@qwen-code/sdk';
let view = createDaemonSessionViewState();
for await (const event of session.events()) {
view = reduceDaemonSessionEvent(view, event);
const known = asKnownDaemonEvent(event);
if (known?.type === 'permission_request') {
console.log(known.data.requestId);
}
}
Offline daemon transcript projection
The opt-in browser-safe transcript entry converts already-parsed append-only ChatRecord values into the same block model used by daemon clients. It does not read files, parse JSONL text, start a daemon, or access network or browser storage.
import { projectChatRecordsToDaemonTranscript } from '@qwen-code/sdk/daemon/transcript';
const projection = projectChatRecordsToDaemonTranscript(records);
if (!projection.complete) {
console.warn(projection.diagnostics);
}
renderTranscript(projection.blocks);
Projection is synchronous and scans all records and message parts. maxBlocks
limits retained output blocks, not computation. As a conservative browser
guideline, project up to roughly 1,000 ordinary records on the main thread;
move larger or unusually text-heavy inputs to a Web Worker and call the same
entry there.
Talking to running sessions
@qwen-code/sdk/peer lets a program that is not a Qwen Code session join the
sessions running as the same user on the same machine — a voice front-end, a
relay, a build watcher. The program shows up in qwen sessions ps, and in the
list_agents of every session that has agents.crossSessionMessaging turned
on — which is also what lets those sessions message it by name with
send_message. It can message them back. It runs on Node only and needs
nothing beyond Node itself.
import { PeerEndpoint } from '@qwen-code/sdk/peer';
const endpoint = await PeerEndpoint.start({
name: 'voice-bridge',
onMessage: (message) =>
console.log(`${message.fromName}: ${message.content}`),
});
const [session] = await endpoint.list();
if (session) {
const sent = await endpoint.send({
to: session.address,
content: 'What are you working on?',
});
if (sent.kind === 'sent') {
const receipt = await endpoint.awaitReceipt(sent.msgId, { final: true });
console.log(receipt?.status); // delivered, denied, refused, ...
}
}
await endpoint.close();
A message like that is held for the session's user to review. To direct a
session without that review, mint a controller token with
qwen sessions controllers add --label voice-bridge, give it to the endpoint,
and mark the sends that should present it:
const endpoint = await PeerEndpoint.start({
name: 'voice-bridge',
controllerToken: process.env['QWEN_CONTROLLER_TOKEN'],
});
await endpoint.send({
to: 'my-app-3f',
content: 'run the tests',
controller: true,
});
Things to know:
- A session has an inbox only while its
agents.crossSessionMessagingsetting is on, which it is by default. A session that turned it off does not appear inlist(), and its ownlist_agentsandsend_messagecannot see or reach the program either.qwen sessions pslists the program regardless. - A message is delivered without review in exactly two cases: the send
presents a controller token (
controller: true), or itsfromModenames the receiving session's own review class.fromModeis a claim nothing authenticates, so a program that is not a coding session should leave it out. Nothing in the record — notkind, notname— buys delivery. The receiving session'sagents.crossSessionInboundsetting outranks both:holdorrefusethere wins over a controller token. - Mark only the sends meant to direct a session. Addresses are resolved from records any program running as you can write, so a controller send presents the token to whichever process's record answers to that address. A controller send to another peer endpoint is dropped unread, because an endpoint's inbox accepts only its own token.
- The endpoint's inbox applies none of the protections a Qwen Code session
applies to its own: no rate limit, no holds, and no duplicate window beyond
the last 200 messages it answered. Every message is answered
deliveredand handed toonMessageas it arrives, so apply your own limits there if you need them. WithoutonMessage, every message is answeredrefused. - Call
close()before exiting, including from your own signal handlers. A process killed without closing leaves its record behind until a Qwen Code session lists the directory and sees the process is gone. - UNIX domain sockets only: Windows is not supported yet.
The record schema, wire format and receipt states are documented in Cross-Session Protocol.
Message Types
The SDK provides type guards to identify different message types:
import {
isSDKUserMessage,
isSDKAssistantMessage,
isSDKSystemMessage,
isSDKResultMessage,
isSDKPartialAssistantMessage,
} from '@qwen-code/sdk';
for await (const message of result) {
if (isSDKAssistantMessage(message)) {
// Handle assistant message
} else if (isSDKResultMessage(message)) {
// Handle result message
}
}
Query Instance Methods
The Query instance returned by query() provides several methods:
const q = query({ prompt: 'Hello', options: {} });
// Get session ID
const sessionId = q.getSessionId();
// Check if closed
const closed = q.isClosed();
// Interrupt the current operation
await q.interrupt();
// Change permission mode mid-session
await q.setPermissionMode('yolo');
// Change model mid-session
await q.setModel('qwen-max');
// Get context window usage breakdown (token counts per category)
const usage = await q.getContextUsage();
// Pass true to hint that per-item details should be displayed
const detail = await q.getContextUsage(true);
// Close the session
await q.close();
Permission Modes
The SDK supports different permission modes for controlling tool execution:
default: Write tools are denied unless approved viacanUseToolcallback or inallowedTools. Read-only tools execute without confirmation.plan: Blocks all write tools, instructing AI to present a plan first.auto-edit: Auto-approve edit tools (edit,write_file,notebook_edit) while other tools require confirmation.auto: Uses the built-in classifier to auto-approve safe tool calls and block risky ones, with manual-approval fallback after repeated policy blocks or classifier outages.yolo: All tools execute automatically without confirmation.
Permission Priority Chain
Decision priority (highest first): deny > ask > allow > (default/interactive mode)
The first matching rule wins.
excludeTools/permissions.deny- Blocks tools completely (returns permission error)permissions.ask- Always requires user confirmationpermissionMode: 'plan'- Blocks all non-read-only toolspermissionMode: 'yolo'- Auto-approves all toolsallowedTools/permissions.allow- Auto-approves matching toolspermissionMode: 'auto'- Classifier-mediated approval for remaining toolscanUseToolcallback - Custom approval logic (if provided, not called for allowed tools)- Default behavior - Auto-deny in SDK mode (write tools require explicit approval)
Examples
Multi-turn Conversation
import { query, type SDKUserMessage } from '@qwen-code/sdk';
async function* generateMessages(): AsyncIterable<SDKUserMessage> {
yield {
type: 'user',
session_id: 'my-session',
message: { role: 'user', content: 'Create a hello.txt file' },
parent_tool_use_id: null,
};
// Wait for some condition or user input
yield {
type: 'user',
session_id: 'my-session',
message: { role: 'user', content: 'Now read the file back' },
parent_tool_use_id: null,
};
}
const result = query({
prompt: generateMessages(),
options: {
permissionMode: 'auto-edit',
},
});
for await (const message of result) {
console.log(message);
}
Custom Permission Handler
import { query, type CanUseTool } from '@qwen-code/sdk';
const canUseTool: CanUseTool = async (toolName, input, { signal }) => {
// Allow all read operations
if (toolName.startsWith('read_')) {
return { behavior: 'allow', updatedInput: input };
}
// Prompt user for write operations (in a real app)
const userApproved = await promptUser(`Allow ${toolName}?`);
if (userApproved) {
return { behavior: 'allow', updatedInput: input };
}
return { behavior: 'deny', message: 'User denied the operation' };
};
const result = query({
prompt: 'Create a new file',
options: {
canUseTool,
},
});
Handling ask_user_question
When the model needs a decision from the user it calls the built-in
ask_user_question tool. The SDK surfaces this through the same
canUseTool callback: the tool input contains a questions array, and you
return the collected answers via updatedInput.answers. answers is an
object keyed by the question's index (as a string), where each value is the
label of the chosen option (or free-form text when the user picks "Other").
import { query, type CanUseTool } from '@qwen-code/sdk';
const canUseTool: CanUseTool = async (toolName, input, { signal }) => {
if (toolName === 'ask_user_question') {
const questions = input.questions as Array<{
question: string;
header: string;
options: Array<{ label: string; description: string }>;
}>;
// Present the questions to the user however your app sees fit, then
// build an index-keyed map of their answers.
const answers: Record<string, string> = {};
for (let i = 0; i < questions.length; i++) {
answers[String(i)] = await promptUserToChoose(questions[i]);
}
// Return the answers through `updatedInput.answers` — the CLI forwards
// them to the tool so the model receives the user's decisions.
return { behavior: 'allow', updatedInput: { ...input, answers } };
}
return { behavior: 'allow', updatedInput: input };
};
If you return
allowwithout anyanswers, the tool reports that no answer was provided; returndenyto signal the user declined.
With External MCP Servers
import { query } from '@qwen-code/sdk';
const result = query({
prompt: 'Use the custom tool from my MCP server',
options: {
mcpServers: {
'my-server': {
command: 'node',
args: ['path/to/mcp-server.js'],
env: { PORT: '3000' },
},
},
},
});
Override the System Prompt
import { query } from '@qwen-code/sdk';
const result = query({
prompt: 'Say hello in one sentence.',
options: {
systemPrompt: 'You are a terse assistant. Answer in exactly one sentence.',
},
});
Append to the Built-in System Prompt
import { query } from '@qwen-code/sdk';
const result = query({
prompt: 'Review the current directory.',
options: {
systemPrompt: {
type: 'preset',
preset: 'qwen_code',
append: 'Be terse and focus on concrete findings.',
},
},
});
With SDK-Embedded MCP Servers
The SDK provides tool and createSdkMcpServer to create MCP servers that run in the same process as your SDK application. This is useful when you want to expose custom tools to the AI without running a separate server process.
tool(name, description, inputSchema, handler)
Creates a tool definition with Zod schema type inference.
| Parameter | Type | Description |
|---|---|---|
name |
string |
Tool name (1-64 chars, starts with letter, alphanumeric and underscores) |
description |
string |
Human-readable description of what the tool does |
inputSchema |
ZodRawShape |
Zod schema object defining the tool's input parameters |
handler |
(args, extra) => Promise<Result> |
Async function that executes the tool and returns MCP content blocks |
The handler must return a CallToolResult object with the following structure:
{
content: Array<
| { type: 'text'; text: string }
| { type: 'image'; data: string; mimeType: string }
| { type: 'resource'; uri: string; mimeType?: string; text?: string }
>;
isError?: boolean;
}
createSdkMcpServer(options)
Creates an SDK-embedded MCP server instance.
| Option | Type | Default | Description |
|---|---|---|---|
name |
string |
Required | Unique name for the MCP server |
version |
string |
'1.0.0' |
Server version |
tools |
SdkMcpToolDefinition[] |
- | Array of tools created with tool() |
Returns a McpSdkServerConfigWithInstance object that can be passed directly to the mcpServers option.
Example
import { z } from 'zod';
import { query, tool, createSdkMcpServer } from '@qwen-code/sdk';
// Define a tool with Zod schema
const calculatorTool = tool(
'calculate_sum',
'Add two numbers',
{ a: z.number(), b: z.number() },
async (args) => ({
content: [{ type: 'text', text: String(args.a + args.b) }],
}),
);
// Create the MCP server
const server = createSdkMcpServer({
name: 'calculator',
tools: [calculatorTool],
});
// Use the server in a query
const result = query({
prompt: 'What is 42 + 17?',
options: {
permissionMode: 'yolo',
mcpServers: {
calculator: server,
},
},
});
for await (const message of result) {
console.log(message);
}
Abort a Query
import { query, isAbortError } from '@qwen-code/sdk';
const abortController = new AbortController();
const result = query({
prompt: 'Long running task...',
options: {
abortController,
},
});
// Abort after 5 seconds
setTimeout(() => abortController.abort(), 5000);
try {
for await (const message of result) {
console.log(message);
}
} catch (error) {
if (isAbortError(error)) {
console.log('Query was aborted');
} else {
throw error;
}
}
Error Handling
The SDK provides an AbortError class for handling aborted queries:
import { AbortError, isAbortError } from '@qwen-code/sdk';
try {
// ... query operations
} catch (error) {
if (isAbortError(error)) {
// Handle abort
} else {
// Handle other errors
}
}
FAQ / Troubleshooting
Version 0.1.0 Requirements
If you're using SDK version 0.1.0, please note the following requirements:
Qwen Code Installation Required
Version 0.1.0 requires Qwen Code >= 0.4.0 to be installed separately and accessible in your PATH.
# Install Qwen Code globally
npm install -g @qwen-code/qwen-code@latest
Note: From version 0.1.1 onwards, the CLI is bundled with the SDK, so no separate Qwen Code installation is needed.
License
Apache-2.0 - see LICENSE for details.