Files
qwen-code/docs/developers/sdk-typescript.md
T
qqqys f2f10e7b39 feat(settings): turn cross-session messaging on by default (#11840)
* feat(settings): turn cross-session messaging on by default

The switch has been opt-in since the feature landed, which was the right
posture while the pieces that make a reachable session safe were still
arriving one pull request at a time: authenticated inboxes, receipts and
held-message expiry, review-class parity with tighten-only repository
settings, trusted controller tokens, admission control, the registry
contract, daemon-managed sessions and the SDK peer endpoint. With all of
that in place, off by default no longer protects anything. It only means
a voice front-end joining through the SDK cannot see a session whose
user never found the setting, and two sessions in one repository cannot
tell each other a build finished.

Flip the schema default to true. Merged settings carry only what some
scope actually wrote, so the schema default never reached the readers of
this key: every one of them saw `undefined` and compared it to `true`.
The four readers now ask one helper, which answers on for `true` and for
an unset key, and off for `false` and for anything it does not recognize
— a value the reader cannot interpret must not open a socket. The
workspace tighten-only ranking follows: unset now ranks with `true`, so a
repository's `false` is a tightening and is kept, and a workspace `true`
repeats the default and is dropped without a warning.

The words change with the default. `/peers` on a session with no inbox
distinguishes "turned off in settings" from "the inbox could not start"
instead of telling the user to enable a setting that is already on. On a
platform without an inbox transport, the startup failure line is shown
only to a user who turned the switch on by hand; a bind that failed where
it should have worked is still reported to everyone, because that session
is unreachable and this line is the only place its user learns it. The
controller-token hint, the send_message error, the settings reference, the
SDK guides and the messaging section of the commands page now say what
turning the feature off looks like.

Suites that stage a session through partial mocks turn the switch off in
their settings, or complete the config mock where registration now runs;
the daemon-session and interactive-startup suites are where unset-means-on
is exercised.

Part of #10925

* test(cli): turn messaging off in suites that stage startup or ACP sessions through partial mocks

Two suites the default flip reaches that the first commit missed. The
entry-point startup test counts exit-cleanup registrations, and with
messaging on the inbox arms a third. The ACP worktree-restore suite loads
sessions through an exhaustive core mock, and with messaging on each
loaded session registers itself through calls that mock does not stage.

Neither suite is about messaging, so both say off in their settings; the
on-by-default path is exercised in startInteractiveUI.test and the
daemon-session suite of acpAgent.test.

* refactor(cli): name the opted-in-by-hand check next to the enabled helper

The startup failure notice asks two questions three lines apart: whether
messaging is on, which the shared helper answers and an unset key answers
yes, and whether the user wrote `true` themselves, which decides whether
an unsupported platform is worth mentioning. The second read the raw
setting inline, next to a helper documented as the one place the first
question is answered, and looked like a missed migration. Bind it to a
name so the two questions read as two.

* fix(cli): address review on the default-on switch: scope-aware notices, no futile rebinds, honest docs

Review round one on the default flip. Thirteen suggestions, all taken.

Two readers now ask a question merged settings cannot answer, so both
move next to `isCrossSessionMessagingEnabled` in peerMessaging/enabled.ts:

- Whether a person opted in. The startup notice for a platform with no
  inbox transport is said only to someone who wrote `true`; it read the
  merged value, which also carries an operator's system-defaults file, so
  a fleet default counted as a hand opt-in. It now reads the user scope,
  and the workspace scope when it is trusted and active.
- Which scope turned messaging off. `/peers` told everyone their settings
  said `false` and to set it to true. Anything but `true` or unset reads
  as off, so it no longer states the value, and it names the scope with a
  remedy that works there: a workspace `false` outranks a user `true` (a
  workspace may only tighten), System overrides everything, and a user
  `true` does override system defaults.

`/peers` on a platform with no inbox transport now says so as
information. It was error-styled and repeated the failure's advice to
disable a setting most of those users never enabled.

An ACP process no longer retries the bind for every hosted session after
`unsupported_platform`. That refusal holds for every candidate path, so
the settled null is kept; every other cause still retries.

The design doc, in both languages, names the two senders delivered
without review that the default now reaches: a process the session
starts, which inherits the child token, and a same-user process that
claims the receiving session's review class, which nothing
authenticates. It records why they are documented rather than gated.
The setting's description, the settings reference and the VS Code schema
carry the same clause, and the Windows paragraph says what changes there.
`commands.md` no longer promises a warning for a workspace `true` that
merely repeats the default.

Tests pin what was unpinned: a daemon-hosted session with the key unset
registers; one bind attempt under `unsupported_platform` and a retry
under any other cause; the warning when a workspace `true` would reopen a
switch the user turned off; the operator-default startup case; the scoped
off messages. Negative assertions that stopped being able to fail are
repointed, and stale "off by default" test names and comments are gone.
2026-09-15 11:46:41 +00:00

43 KiB

Typescript SDK

@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
  • Qwen Code >= 0.4.0 (stable). The SDK uses its bundled CLI by default; set pathToQwenExecutable only when you need to run a custom qwen binary or CLI bundle.

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, structured_output, plan-mode lifecycle tools, task_stop, mcp__* and computer_use__* tools are exempt from that allowlist and keep their normal loading; 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.

Note

For coreTools, aliases like Read, Edit, and Bash also work, but invocation specifiers such as Bash(git *) are stripped. coreTools restricts tool registration, not invocation patterns.

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
    },
  },
});

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();

interrupt() cancels only the active turn. For a multi-turn query created with an async iterable prompt, the query and its input stream remain open, so later messages from the iterable are processed normally. Use close() or abort the configured AbortController when you want to end the entire session.

Daemon caller-supplied session IDs

DaemonClient.createOrAttachSession accepts an optional sessionId for callers that must persist an identity before session creation:

import { DaemonClient } from '@qwen-code/sdk';

const daemon = new DaemonClient({ baseUrl: 'http://127.0.0.1:4170' });
const session = await daemon.createOrAttachSession({
  workspaceCwd: '/path/to/project',
  sessionId: '550E8400-E29B-41D4-A716-446655440000',
});

console.log(session.sessionId); // 550e8400-e29b-41d4-a716-446655440000

The SDK requires the daemon's session_id_override capability before sending the mutation. REST mode serializes sessionId directly; an active ACP adapter maps it to session/new._meta["qwen-code/sessionId"]. The SDK verifies the success response and throws DaemonSessionIdProtocolError if the daemon returns a different ID.

This option always creates a new thread session and is not an idempotent attach. If the create outcome is ambiguous, use the known ID with load or resume. Omitting the option preserves the existing create-or-attach behavior.

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.

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,
  },
});

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
  }
}