Skip to Content
Developer GuideREST API integration guide

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?

PathYou ownStatus
daemon + bundled Web Shellnothing — use it as shippedships today (user guide)
daemon --no-web + your own UIthe entire front endships today — this page
daemon + branded Web Shellbranding, not codenot built (#11357 )
daemon + self-hosted Web Shell buildthe front-end buildnot built (#11358 )
daemon via SDK DaemonClientclient code, never raw HTTPships 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 bridgenothing — another agent drives itships as qwen-serve-mcp in @qwen-code/sdk — see Drive the daemon through MCP
embeddable Web Shell componentsyour app and server; QC supplies the chat UIworkspace-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

RoutePurpose
GET /healthLiveness probe
GET /capabilitiesPreflight — read workspaceCwd and policy.permission before anything else

Session lifecycle

RoutePurpose
POST /sessionCreate. Send sessionScope: "thread" for an independent conversation
DELETE /session/:idClose. The persisted session survives and can be reloaded
POST /session/:id/load · POST /session/:id/resumeRestore a persisted session
POST /session/:id/heartbeatDefer the idle reaper
PATCH /session/:id/metadataSession metadata
POST /session/:id/modelSwitch model within the bound service
GET /session/:id/statusRuntime status

Prompting and streaming

RoutePurpose
POST /session/:id/promptSubmit. Returns 202 on admission, not completion
POST /session/:id/cancelCancel the active prompt only
GET /session/:id/eventsSSE stream. Subscribe before prompting
GET /session/:id/transcriptConversation history
GET /session/:id/contextTop-level model, mode, and config-option state; virtual subagents return an empty state
GET /session/:id/export · GET /session/:id/pending-promptsExport 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

RoutePurpose
POST /session/:id/permission/:requestIdAnswer 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/:requestIdProcess-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

RoutePurpose
GET /file · GET /file/bytesRead a file, or a byte range
GET /stat · GET /list · GET /globPath metadata, directory listing, glob
GET /workspace/toolsTools 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 without modelServiceId has no modelApplied key at all.

# 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

ConcernWhere
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
ErrorsError taxonomy
ObservabilityObservability
Full flag listConfiguration

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.

Last updated on