Files
qwen-code/packages/sdk-typescript/README.md
T
70edbf475e feat(core): preserve prompt cache for deferred tools (#10410)
* 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]>
2026-09-20 13:17:49 +00:00

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, or allowedTools, it is strongly recommended to read the permissions configuration documentation first, especially the Tool name aliases and Rule syntax examples sections. Rule patterns such as Bash(git *), Read(.env), and Edit(/src/**) apply to excludeTools and allowedTools; coreTools accepts 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.crossSessionMessaging setting is on, which it is by default. A session that turned it off does not appear in list(), and its own list_agents and send_message cannot see or reach the program either. qwen sessions ps lists the program regardless.
  • A message is delivered without review in exactly two cases: the send presents a controller token (controller: true), or its fromMode names the receiving session's own review class. fromMode is a claim nothing authenticates, so a program that is not a coding session should leave it out. Nothing in the record — not kind, not name — buys delivery. The receiving session's agents.crossSessionInbound setting outranks both: hold or refuse there 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 delivered and handed to onMessage as it arrives, so apply your own limits there if you need them. Without onMessage, every message is answered refused.
  • 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 via canUseTool callback or in allowedTools. 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.

  1. excludeTools / permissions.deny - Blocks tools completely (returns permission error)
  2. permissions.ask - Always requires user confirmation
  3. permissionMode: 'plan' - Blocks all non-read-only tools
  4. permissionMode: 'yolo' - Auto-approves all tools
  5. allowedTools / permissions.allow - Auto-approves matching tools
  6. permissionMode: 'auto' - Classifier-mediated approval for remaining tools
  7. canUseTool callback - Custom approval logic (if provided, not called for allowed tools)
  8. 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 allow without any answers, the tool reports that no answer was provided; return deny to 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.