qwen-code/docs/developers/rest-api-integration.md
callmeYe be1a0a1fb0
feat(serve): support model and reasoning selection at session creation (#12308)
* feat(serve): support model and reasoning selection at session creation

* fix(serve): allow model-only session startup configuration

* test(serve): include startup capability in registry expectations

* fix(serve): roll back rejected standalone startup

* fix(serve): make definite startup rejections retryable and honest

- Map the ACP internalError wire shape ({code: -32603, data.details})
  produced when switchModel refuses an unregistered model to
  startup_config_rejected on the startup-config call path, so the
  ordinary route answers 422 instead of 500 and the standalone path
  takes its definite rollback branch.
- Roll back the persisted recording of a caller-supplied sessionId on
  POST /session after a definite startup rejection, so a retry of the
  same id is no longer answered with 409 session_id_conflict; uncertain
  failures keep the recording.
- Publish auto in the OpenAPI approvalMode enum and pin it against
  APPROVAL_MODES; narrow the 422/protocol/design cleanup wording to
  match the implemented best-effort semantics; restore the sessionId in
  the new rollback warning.

* fix(serve): keep uncertain startup faults uncertain and roll back ACP recordings (#12308)

- session-startup-config: drop the -32603 arm so child-side internal
  errors (auth, credential, timeout, transport) stay uncertain instead
  of a false definite startup_config_rejected; the ACP child now maps
  only its two caller-caused switchModel refusals (unknown model for the
  auth type, media-only primary) to invalid params, so definite
  rejections ride -32602 alone.
- dispatch: roll back the persisted recording of a caller-supplied
  sessionId on the ACP session/new transports after a definite startup
  rejection, mirroring the REST route; uncertain outcomes keep it.
- routes/session: warn once when the recording rollback is refused or
  fails instead of silently discarding the boolean outcome.
- standalone-session-service: extract the shared rollback sequence into
  one helper used by both the startup-config and model_selection_failed
  branches.
- openapi: qualify the 422 close/rollback as best-effort and replace the
  unconfirmable status-view pointer with the self-certifying retry check.

* fix(serve): name the rejected session in startup rollback and align ACP error codes (#12308)

- SessionStartupConfigError carries an optional sessionId so both
  rollback gates (REST route, ACP dispatcher) roll back the rejected
  session's recording even when the caller never supplied an id or the
  child did not honor it; a daemon-generated id no longer leaves a
  listed, resumable phantom after a definite 422.
- A refused ACP recording rollback now logs the same inconclusive
  warning the REST route emits instead of dropping the cleanup result.
- startup_config_rejected maps to JSON-RPC -32602 (caller input), with
  data.httpStatus keeping the REST-equivalent 422; SDK transports key
  on httpStatus, so client behavior is unchanged.
- SDK throws DaemonStartupConfigError (TypeError with code
  'invalid_startup_config') for pre-transport startup rejections.
- Docs: modelApplied is documented as spawn-scoped again (an attach
  omits it or reports the spawn owner's outcome).
- Tests: daemon-generated-id rollback pins on both transports, a
  refused-rollback log pin, a service-boundary invalid-config guard
  pin, full reasoning-selection acceptance coverage, whitespace trim
  pins, and a real-ModelsConfig classifier coupling test.

* test(sdk): keep the message assertion beside the stable code on startup rejections (#12308)

* fix(serve): fail closed on control-character startup model ids (#12308)

A modelServiceId carrying a newline slipped past parseSessionStartupConfig
and produced a core refusal message the single-line classifier regexes
could not match, so a definite caller-caused rejection degraded to an
internal error and skipped the recording rollback. Reject control
characters at the parse boundary and make isCallerCausedModelRefusal
newline-tolerant; align the SDK and integration-guide modelApplied
presence rule with the spawn-scoped wording the bridge and OpenAPI
already publish.

* docs(sdk): name both transport error-code locations in DaemonStartupConfigError JSDoc (#12308)

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev-bot@users.noreply.github.com>
Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
Co-authored-by: qwen-code-ci-bot <qwen-code-ci-bot@users.noreply.github.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
2026-09-25 16:45:37 +00:00

41 KiB

REST API integration guide

For teams putting Qwen Code inside their own product over HTTP: run qwen serve as a backend and drive it from your own front end.

This page is the entry point. The curated Daemon REST API reference covers the stable integration surface and links to its OpenAPI 3.1 contract. The full protocol is qwen-serve-protocol.md; the internals are the daemon deep dive; a runnable TypeScript walkthrough is examples/daemon-client-quickstart.md.

Which paths exist

Seven ways to build on the daemon, separated by one question — how much of the front end do you own?

Path You own Status
daemon + bundled Web Shell nothing — use it as shipped ships today (user guide)
daemon --no-web + your own UI the entire front end ships today — this page
daemon + branded Web Shell branding, not code not built (#11357)
daemon + self-hosted Web Shell build the front-end build not built (#11358)
daemon via SDK DaemonClient client code, never raw HTTP ships today (TS, Java) — the Python SDK is process-transport-only and has no daemon client, so a Python integration drives path 2 over raw HTTP
daemon via MCP bridge nothing — another agent drives it ships as qwen-serve-mcp in @qwen-code/sdk — see Drive the daemon through MCP
embeddable Web Shell components your app and server; QC supplies the chat UI workspace-internal (not yet on npm) — build from the monorepo, see Embed the Web Shell

Headless qwen -p and ACP over stdio for editors are separate integration paths. Channels and extensions can also run through the daemon; see the channel guide and Extension reference.

Drive the daemon through MCP

@qwen-code/sdk publishes qwen-serve-mcp, a stdio MCP server that maps its tools to a running daemon. Start qwen serve, then add this server to any MCP client on Node.js 22 or newer:

{
  "mcpServers": {
    "qwen-serve-bridge": {
      "type": "stdio",
      "command": "npx",
      "args": ["-y", "-p", "@qwen-code/sdk", "qwen-serve-mcp"],
      "env": {
        "QWEN_DAEMON_URL": "http://127.0.0.1:4170",
        "QWEN_DAEMON_TOKEN": "<your-token>",
        "QWEN_WORKSPACE_CWD": "/path/to/your/project"
      }
    }
  }
}

QWEN_DAEMON_URL defaults to http://127.0.0.1:4170; QWEN_DAEMON_TOKEN carries the daemon bearer token when authentication is enabled; and QWEN_WORKSPACE_CWD optionally supplies the default workspace for session creation — the path must already be registered with the daemon (start it with --workspace <path>), because the value is forwarded verbatim as POST /session's cwd and an unregistered path makes every session-establishing tool fail with 400 workspace_mismatch; leave it unset to use the primary workspace. These names are read by the MCP bridge. Daemon channel and TUI adapters instead use QWEN_DAEMON_WORKSPACE for their workspace override; they also read QWEN_DAEMON_URL and QWEN_DAEMON_TOKEN. Leave QWEN_BRIDGE_ALLOW_GLOBAL_SCOPE unset unless the MCP client is trusted to perform restricted writes. Set it to true to enable exactly: the auto-edit, auto and yolo approval modes plus any persist: true approval-mode write; workspace tool toggling; MCP server restart; and global-scope memory and agent writes. Switching between plan and default and workspace-scope memory writes work without it. The package's bridge README lists every tool and the programmatic API.

Embed the Web Shell

Use @qwen-code/web-shell when your React application should own the page and server while Qwen Code supplies the chat UI. The package is workspace-internal for now — no release step publishes it to npm yet — so build it from the monorepo and depend on it by path, the same way packages/web-templates consumes it:

cd qwen-code
npm ci
npm run build --workspace=@qwen-code/web-shell

Then in your application:

npm install @qwen-code/sdk react react-dom
npm install file:../qwen-code/packages/web-shell
import { WebShellWithProviders } from '@qwen-code/web-shell';

<WebShellWithProviders
  baseUrl="https://daemon.example.com"
  token={daemonToken}
  sessionId={sessionId}
  sessionContext={{ kind: 'standalone' }}
/>;

This direct browser connection is suitable only for the trusted, single-operator model described below: the token grants the browser full daemon authority. When the application and daemon use different origins, start the daemon with --allow-origin <application-origin>; a non-loopback origin also requires a bearer token, and the daemon refuses to start without one — see Start the daemon. The package also exports providers for sharing one daemon session across several views and a separate @qwen-code/web-shell/transcript entry point for read-only transcript rendering. See the package README for those variants and the prop contract.

Two things to know before designing

The daemon does not run inference in-process. It spawns qwen --acp child processes and brokers between them and HTTP. It runs the CLI entry script under the same Node binary, using QWEN_CLI_ENTRY or otherwise process.argv[1]. An embedding Node backend must point QWEN_CLI_ENTRY at the installed Qwen CLI entry script; there is no qwen lookup on PATH. A missing entry point surfaces as MissingCliEntryError.

In steady state there is one child per active workspace runtime, not one per session. Every session in a workspace multiplexes onto that child and shares its process, OAuth state, file cache and hierarchy-memory parse. So the fault domain is the workspace: if the child exits, every session multiplexed onto it is torn down together. Size the container for the daemon plus one child per registered workspace, with headroom for one extra child per runtime during a channel swap. When sessions must fail independently, run separate daemons — --max-sessions caps concurrency, not blast radius.

Authentication is single-operator. The runtime bearer token grants the whole bearer-gated API, and a trusted loopback caller gets full authority including code execution as the daemon user. There is no per-end-user principal model. If you are putting this behind a multi-user product, your backend owns user identity and must not hand the daemon token to browsers. Containerised and multi-tenant deployment are explicitly deferred — see "v0.16-alpha known limits" in the user guide.

Configured channel webhook ingress (POST /channels/:channelName/webhooks/:source) uses its own x-qwen-webhook-secret authentication before bearer authentication; it is inert until a channel webhook source is configured.

Start the daemon

Generate the token once in terminal 1. The shell builtin prints it so you can paste the same value into the hidden prompt shown below in every other terminal:

export QWEN_SERVER_TOKEN="$(openssl rand -hex 32)"
printf 'Copy this token to the other terminals: %s\n' "$QWEN_SERVER_TOKEN"
export DAEMON_URL=http://127.0.0.1:4170

Terminal 1 — this command blocks, so leave it running:

qwen serve --no-web --require-auth \
  --hostname 0.0.0.0 --port 4170 \
  --workspace /srv/project

In every other terminal, paste the token printed by terminal 1 when read prompts for it. This keeps the token out of shell history and child-process arguments:

read -rsp 'QWEN_SERVER_TOKEN: ' QWEN_SERVER_TOKEN; printf '\n'
export QWEN_SERVER_TOKEN
export DAEMON_URL=http://127.0.0.1:4170

DAEMON_URL is the loopback base URL every client command below uses — export it, with the same value, in each terminal you run them in — and it matches servers[0].url in the OpenAPI artifact. The daemon still binds 0.0.0.0 so a remote host can reach it, but do not point DAEMON_URL at that host in plaintext: a bearer token that can drive a shell is readable by anyone on the path. Reach a non-loopback host over TLS (see below).

--no-web preserves the routes listed below, but disables Web Shell assets and dependent surfaces: on macOS the /live/* routes and /live/host socket, and on every platform GET /mcp-app-sandbox. Pass the token by environment rather than --token, which is readable by any local user through /proc/<pid>/cmdline.

The Bash examples below pass the Authorization header through a file descriptor using the shell's printf builtin, keeping the token out of curl's arguments. They require Bash, curl, and jq. For cross-device access, terminate TLS as described in HTTPS / TLS for mobile and cross-device access; the daemon then serves https:// on the same port, so re-export DAEMON_URL with the https:// scheme before running the commands below.

The routes an integration actually uses

Most of what the daemon registers exists to drive the Web Shell — git operations, extension install, workspace trust, voice, scheduled tasks — and changes with that UI. The subset below is an order of magnitude smaller.

These are the ones a REST integration needs. Treat the rest as internal.

Discovery

Route Purpose
GET /health Liveness probe
GET /capabilities Preflight — read workspaceCwd and policy.permission before anything else

Session lifecycle

Route Purpose
POST /session Create. Send sessionScope: "thread" for an independent conversation
DELETE /session/:id Close. The persisted session survives and can be reloaded
POST /session/:id/load · POST /session/:id/resume Restore a persisted session
POST /session/:id/heartbeat Defer the idle reaper
PATCH /session/:id/metadata Session metadata
POST /session/:id/model Switch model within the bound service
GET /session/:id/status Runtime status

Prompting and streaming

Route Purpose
POST /session/:id/prompt Submit. Returns 202 on admission, not completion
POST /session/:id/cancel Cancel the active prompt only
GET /session/:id/events SSE stream. Subscribe before prompting
GET /session/:id/transcript Conversation history
GET /session/:id/context Top-level model, mode, and config-option state; virtual subagents return an empty state
GET /session/:id/export · GET /session/:id/pending-prompts Export the persisted transcript · list queued prompts

Token usage is not part of this surface: for a top-level session, GET /session/:id/context returns the live model, mode, and configuration-option state. A subagent.-prefixed virtual session id resolves against its parent runtime and returns an empty state object. Usage counters sit on GET /session/:id/context-usage, a route this contract does not specify — it carries the session_context_usage capability tag and is described only in the internal session lifecycle notes.

Permissions

Route Purpose
POST /session/:id/permission/:requestId Answer a permission_request. Routed to the runtime that owns the session and never falls back to the primary bridge; an untrusted non-primary owner is rejected, while an untrusted primary owner proceeds to the active permission policy
POST /permission/:requestId Process-global form, wired to the primary workspace's bridge only: it 404s for a session owned by another registered runtime, with the same body as a lost vote under the default first-responder policy — so a 404 here does not by itself mean the request was already answered

Read-only workspace context

Route Purpose
GET /file · GET /file/bytes Read a file, or a byte range
GET /stat · GET /list · GET /glob Path metadata, directory listing, glob
GET /workspace/tools Tools reported by the live ACP child; without one, the response has acpChannelLive: false, tools: [], and a not_started error

Minimal flow

1. Preflight. Read workspaceCwd (so you can omit cwd on create) and policy.permission (so you know who may answer permission requests).

curl -sH @<(printf 'Authorization: Bearer %s\n' "$QWEN_SERVER_TOKEN") "$DAEMON_URL/capabilities"

2. Create a session. Use sessionScope: "thread" unless callers are meant to share one conversation — the default "single" makes a second same-workspace create reuse the existing session, serialising unrelated callers through one queue.

SESSION_JSON="$(curl -sX POST "$DAEMON_URL/session" \
  -H @<(printf 'Authorization: Bearer %s\n' "$QWEN_SERVER_TOKEN") -H 'Content-Type: application/json' \
  -d '{"sessionScope":"thread"}')" || echo "create failed (curl exit $?)" >&2
printf '%s\n' "$SESSION_JSON"
SID="$(printf '%s' "$SESSION_JSON" | jq -er '.sessionId // empty')"
export SID
: "${SID:?no sessionId in the create response}"
# → {"sessionId":"…","workspaceCwd":"/srv/project","attached":false}

3. Subscribe before prompting. Run this in a second terminal with the same QWEN_SERVER_TOKEN and DAEMON_URL, and with SID set to the sessionId step 2 printed: exports do not cross terminals, so re-export the token and DAEMON_URL there and set SID to that sessionId yourself. Do not paste the block back into terminal 1 — its SID= assignment would overwrite the value steps 4-6 use. Last-Event-ID: 0 replays from the oldest retained event, which is how you catch events fired between create and subscribe — notably model_switch_failed. On an attach (the default sessionScope: "single" reusing an existing session) that event is the only signal that a bad modelServiceId was rejected, because the failure is deliberately not propagated as an HTTP error. On a fresh create that carries modelServiceId — which step 2's body does not — the 200 body also carries modelApplied, false when the switch was rejected, and that is the deterministic one to act on rather than an event on a bounded ring. A create carrying neither modelServiceId nor startupConfig has no modelApplied key of its own — one that coalesced with an in-flight spawn reports the spawn owner's modelApplied, which is why the attach signal to act on stays model_switch_failed.

To confirm a startup selection instead of using legacy best-effort switching, preflight session_startup_config and send startupConfig: { modelServiceId, reasoningEffort? }. Model-only creation works without a reasoning control; an explicit unsupported effort fails with 422 startup_config_rejected rather than returning modelApplied: false. Startup does not write shared defaults.

# terminal 2 — re-export what you need; shell variables do not cross terminals
# export QWEN_SERVER_TOKEN='<the token from step 1>'
# export DAEMON_URL=http://127.0.0.1:4170
SID='<sessionId from step 2>'
curl -N "$DAEMON_URL/session/$SID/events" \
  -H @<(printf 'Authorization: Bearer %s\n' "$QWEN_SERVER_TOKEN") \
  -H 'Accept: text/event-stream' -H 'Last-Event-ID: 0'

Each data: line is a full envelope on one line; the envelope's type matches the event: line.

Replay is limited by --event-ring-size and a fixed 8 MiB per-subscription byte budget. If the stream emits state_resync_required with reason: "replay_budget_exceeded", recover through POST /session/:id/load instead of treating the replay as complete.

4. Prompt. 202 means admitted, not finished. Correlate turn_complete / turn_error on the stream by promptId. Read stopReason on turn_complete; on turn_error, read message and any optional code / errorKind — see POST /session/:id/prompt.

curl -sX POST "$DAEMON_URL/session/$SID/prompt" \
  -H @<(printf 'Authorization: Bearer %s\n' "$QWEN_SERVER_TOKEN") -H 'Content-Type: application/json' \
  -d '{"prompt":[{"type":"text","text":"What does src/main.ts do?"}]}'
# → 202 {"promptId":"…","lastEventId":42}

5. Answer permission requests. When the agent wants to run a tool and its approval mode asks for confirmation, it emits permission_request and the turn blocks until someone answers or you cancel — by default there is no timeout (--permission-response-timeout-ms defaults to 0 = wait indefinitely), so an unanswered request keeps holding a slot in the session's prompt queue until you cancel or close the session. Arm your own deadline if the flow needs one.

The mode is the child's own Qwen setting tools.approvalMode, resolved from the daemon host's and the --workspace directory's settings; the daemon pins nothing at spawn. Its default is auto, which approves one class of tool calls without asking — those publish no permission_request at all — and still asks for the rest. An untrusted workspace folder is forced down to default (ask), which is why one deployment sees these events and another sees none, and GET /capabilities reports the vote-mediation policy rather than the approval mode, so preflight will not tell you which posture you are in. If your integration depends on approval gating, pin tools.approvalMode explicitly and decide up front how it answers: auto-approval can already be in effect without anyone having chosen it.

Answer on the session-scoped route: it reaches the owning workspace whenever exactly one live runtime owns the session, and never falls back to the primary bridge. An untrusted non-primary owner returns 403 untrusted_workspace; the primary runtime is exempt from this trust check, so an untrusted primary owner may accept the vote. An unresolved owner fails closed instead of voting on the wrong runtime — 404 session_not_found, 500 ambiguous_session_owner, or 503 workspace_runtime_unavailable with Retry-After: 1 (retry; the vote was not recorded). Copy data.requestId from the permission_request event and set it before voting:

export REQUEST_ID='<data.requestId>'
curl -sX POST "$DAEMON_URL/session/$SID/permission/$REQUEST_ID" \
  -H @<(printf 'Authorization: Bearer %s\n' "$QWEN_SERVER_TOKEN") -H 'Content-Type: application/json' \
  -d '{"outcome":{"outcome":"selected","optionId":"proceed_once"}}'

6. Close. DELETE /session/$SID → 204. The on-disk session is retained.

Operations

Concern Where
Concurrency caps --max-sessions, --max-total-sessions; over-cap creates return 503 with Retry-After
Rate limiting --rate-limit plus the per-class --rate-limit-* flags
Idle cleanup --session-idle-timeout-ms; keep alive with POST /session/:id/heartbeat
Memory --child-heap-mode defaults to observe; opt-in admit and enforce refuse new ACP children at the modeled count limit with HTTP 503 and code: "acp_child_capacity_exhausted". Before rejecting, a fully managed daemon may reclaim one least-recently-used empty ACP from a different, trusted, active non-internal workspace runtime owned by the daemon, with no live sessions or pending work and retry admission once. If no other workspace qualifies (including the single-workspace case), the rejection is unchanged. Loaded sessions remain protected; workspace registration and saved history survive reclamation. --memory-budget-mb controls the adaptive live-journal growth pool for POST /session/:id/load, not SSE replay; pinning either --max-journal-bytes or --max-journal-events disables growth. Experimental opt-in enforce also sets each managed child's fixed old-space ceiling from the modeled budget; other modes keep legacy host-derived arguments. limits.memory.enforced reports that wiring, not an RSS guarantee. Calibrate the workload before enabling; restart with admit to roll back. See Configuration for budget calculation. SSE replay is separately bounded by --event-ring-size and a fixed 8 MiB per-subscription budget; an omitted tail produces state_resync_required with reason: "replay_budget_exceeded"
Prompt deadlines --prompt-deadline-ms; expiry emits turn_error
Errors Error taxonomy
Observability Observability
Full flag list Configuration

Choosing a workspace when ACP capacity is exhausted

When an App first-submit session creation, an existing-session load/resume (including embedded panes), or explicit Skills runtime preparation safely returns acp_child_capacity_exhausted, Web Shell offers a workspace chooser if the daemon advertises workspace_runtime_stop. Cancel preserves the initiating draft and existing work. Selecting a workspace and confirming interrupts all previewed sessions and their tools, while retaining registration, files and saved history. Workspaces with independent services or enabled scheduled tasks are disabled with a reason. New-session creation by an embedding host remains the host's responsibility; an empty ChatPane does not lazily create a session through sendPrompt. No additional environment variables are needed, and automatic reclamation still only selects empty runtimes.

API clients can read GET /workspaces/runtime-stop-options, obtain user confirmation, and submit the exact channel/epoch/token/session set to POST /workspaces/:workspace/runtime/stop. Treat unknown or partial outcomes as such: refresh the selected receipt, do not assume a lost response means nothing stopped, and do not blindly retry the original prompt. Continue the original operation once only after stopped and released are true and its daemon, workspace and draft remain current. A stop whose budget expires while a session close is in flight returns a failed receipt while cleanup continues; unconfirmed release then keeps the workspace isolated and its capacity counted, so refresh the matching receipt to observe later cleanup without another stop POST. A refreshed receipt reporting stopped/released may still carry the earlier teardown warning in error — treat released: true as the release signal. A stop whose budget is exhausted between session closes instead returns 409 workspace_runtime_stop_incomplete with cleanup already ended — the workspace is no longer isolated, that receipt will not change, and stopping the remaining sessions requires a fresh preview and confirmation. See the runtime-stop protocol for request fields, error codes and receipt semantics.