Skip to Content
Developer Guide

DaemonClient quickstart (TypeScript)

A minimal end-to-end example: start a qwen serve daemon in another terminal, then drive it from a Node script with the SDK’s DaemonClient. See also: Daemon mode user guide and HTTP protocol reference.

Setup

In one terminal:

qwen serve --port 4170 \ --workspace /path/to/project-a \ --workspace /path/to/project-b # → qwen serve listening on http://127.0.0.1:4170 (mode=http-bridge, workspace=/path/to/project-a)

Each --workspace value must be an absolute directory. The first startup workspace is primary and remains the compatibility default for requests that omit cwd; /capabilities.workspaces[] is the catalog clients should use when selecting any runtime explicitly.

In another:

npm install @qwen-code/sdk

Hello daemon

import { DaemonClient, type DaemonEvent } from '@qwen-code/sdk'; const client = new DaemonClient({ baseUrl: 'http://127.0.0.1:4170', // PR 27 (v0.16-alpha): when `token` is omitted, DaemonClient falls // back to `process.env.QWEN_SERVER_TOKEN` automatically — same env // var the daemon's `--token` CLI flag falls back to. So either: // export QWEN_SERVER_TOKEN="$(openssl rand -hex 32)" # one-shot // export QWEN_SERVER_TOKEN="$(cat ./my-token-file)" # user-managed file // const client = new DaemonClient({ baseUrl: '...' }); // OR pass it explicitly when you have a different env-var name: // token: process.env.MY_TOKEN, }); // 1. Confirm we can reach the daemon, gate UI on its features, and // select a trusted workspace from the advertised catalog. const caps = await client.capabilities(); console.log('Daemon features:', caps.features); const selectedWorkspace = caps.workspaces?.find( (workspace) => workspace.trusted && !workspace.primary, ) ?? caps.workspaces?.find((workspace) => workspace.trusted); if (!selectedWorkspace) throw new Error('No trusted workspace is available'); console.log('Selected workspace:', selectedWorkspace.id, selectedWorkspace.cwd); // 2. Spawn-or-attach inside that runtime. The SDK maps `workspaceCwd` // to the wire-level POST /session `cwd` field. Omitting it is allowed // only when the caller intentionally wants the legacy primary default. const session = await client.createOrAttachSession({ workspaceCwd: selectedWorkspace.cwd, }); console.log(`session=${session.sessionId} attached=${session.attached}`); // 3. Subscribe to the event stream. Pass `lastEventId: 0` so the daemon // replays everything from the session's start — without it, there's // a TOCTOU window between `subscribeEvents()` returning the iterator // and the underlying SSE connection actually opening (one fetch // round-trip), during which a fast-starting agent can emit events // that go into the per-session ring but won't be streamed to a fresh // no-cursor subscriber. `lastEventId: 0` makes the replay buffer // cover that gap (and any reconnect later — see below). const abort = new AbortController(); const subscription = (async () => { for await (const event of client.subscribeEvents(session.sessionId, { signal: abort.signal, lastEventId: 0, })) { handleEvent(event); } })(); // 4. Send a prompt and wait for it to settle. (Order-of-operations // note: even if `prompt()` fires before the SSE handshake // completes, step 3's `lastEventId: 0` guarantees every event // lands in the iterator.) const result = await client.prompt(session.sessionId, { prompt: [{ type: 'text', text: 'Summarize src/main.ts in one sentence.' }], }); console.log('stop reason:', result.stopReason); // 5. Tear down the subscription so the script can exit. abort.abort(); await subscription; function handleEvent(event: DaemonEvent): void { switch (event.type) { case 'session_update': { const data = event.data as { sessionUpdate: string; content?: { text?: string }; }; if (data.sessionUpdate === 'agent_message_chunk' && data.content?.text) { process.stdout.write(data.content.text); } break; } case 'permission_request': // See "Voting on permissions" below for first-responder semantics. console.log('\n[needs permission]', event.data); break; case 'permission_resolved': console.log('\n[permission resolved]', event.data); break; case 'session_died': console.error('\n[agent crashed]', event.data); break; default: console.log(`\n[${event.type}]`, event.data); } }

Workspace file helpers

File routes are workspace-scoped, not session-scoped. Bind a qualified helper to the selected workspace id so every request remains inside that runtime:

const selected = client.workspaceById(selectedWorkspace.id); const file = await selected.readWorkspaceFile('src/main.ts'); const updated = await selected.editWorkspaceFile({ path: 'src/main.ts', oldText: 'timeout: 30000', newText: 'timeout: 60000', expectedHash: file.hash!, }); console.log(updated.hash);

expectedHash is SHA-256 over the raw on-disk bytes. mode: "replace" and editWorkspaceFile() require it so stale clients do not overwrite a file they did not just read. Write/edit require bearer-token configuration even on loopback; start the daemon with --token or QWEN_SERVER_TOKEN before using them.

Reconnect with Last-Event-ID

If your client process restarts mid-session, replay events you missed:

let cursor: number | undefined; for await (const event of client.subscribeEvents(session.sessionId, { signal: abort.signal, lastEventId: cursor, // resume from after this id; undefined = live only })) { if (typeof event.id === 'number') cursor = event.id; handleEvent(event); }

The daemon retains the last 8000 events per session in a ring buffer; gaps beyond that window won’t be re-deliverable.

Voting on permissions

When the agent asks for permission to run a tool, every connected client sees the permission_request event. First responder wins — once one client votes, the rest get 404 if they try to vote on the same requestId.

case 'permission_request': { const req = event.data as { requestId: string; options: Array<{ optionId: string; name: string; kind: string }>; }; // Pick whichever option you want — `proceed_once`, `allow`, etc. const choice = req.options.find((o) => o.kind === 'allow_once') ?? req.options[0]; const accepted = await client.respondToPermission(req.requestId, { outcome: { outcome: 'selected', optionId: choice.optionId }, }); if (!accepted) { console.log('Another client voted first; nothing to do.'); } break; }

Shared-session collaboration

Two clients pointed at the same daemon workspace end up on the same session when they use the default sessionScope: 'single'. For a single-workspace daemon launched as qwen serve --workspace /work/repo (or cd /work/repo && qwen serve), both clients connect to that primary workspace:

// Daemon was launched as `qwen serve --workspace /work/repo` so // `caps.workspaceCwd === '/work/repo'` for both clients. // Client A (e.g. an IDE plugin) const a = await clientA.createOrAttachSession({ workspaceCwd: '/work/repo' }); console.log(a.attached); // false — A spawned the agent // Client B (e.g. a web UI on the same machine) const b = await clientB.createOrAttachSession({ workspaceCwd: '/work/repo' }); console.log(b.attached); // true — B joined A's session console.log(a.sessionId === b.sessionId); // true

Both clients see the same session_update / permission_request stream. Either can send a prompt; they FIFO-queue per the agent’s “one active prompt per session” guarantee.

Workspace mismatch

If workspaceCwd does not match any registered advertised workspace, createOrAttachSession rejects with DaemonHttpError carrying status 400 and a structured body. A registered but untrusted secondary instead returns 403 untrusted_workspace and must not be retried against primary:

import { DaemonHttpError } from '@qwen-code/sdk'; try { await client.createOrAttachSession({ workspaceCwd: '/some/other/project' }); } catch (err) { if (err instanceof DaemonHttpError && err.status === 400) { const body = err.body as { code?: string; boundWorkspace?: string; requestedWorkspace?: string; }; if (body.code === 'workspace_mismatch') { console.error( `Workspace ${body.requestedWorkspace} is not registered. ` + `Refresh capabilities and select an advertised workspace, ` + `or register it before retrying.`, ); } } }

Do not retry against the primary workspace after a mismatch. Refresh /capabilities, select the intended entry from workspaces[], or register an eligible dynamic workspace through POST /workspaces. Use separate daemons only when authentication, rate-limit, or process fault boundaries must also be independent.

Authentication

When the daemon was started with a token (any non-loopback bind requires one):

const client = new DaemonClient({ baseUrl: 'https://your-host:4170', token: process.env.QWEN_SERVER_TOKEN, });

SDK env fallback (PR 27, v0.16-alpha)DaemonClient reads QWEN_SERVER_TOKEN from the environment automatically when token is omitted, mirroring the daemon’s own --token CLI fallback. So if your shell has export QWEN_SERVER_TOKEN=..., this is equivalent to the above:

// Same effect as token: process.env.QWEN_SERVER_TOKEN, but without the boilerplate. const client = new DaemonClient({ baseUrl: 'https://your-host:4170' });

The fallback strips leading/trailing whitespace (handy for export QWEN_SERVER_TOKEN="$(cat token.txt)" where cat adds a newline) and treats empty / whitespace-only values as unset (a stale export QWEN_SERVER_TOKEN="" won’t accidentally send Authorization: Bearer with no token). The fallback runs once at construction; later process.env mutations don’t affect already-built clients. Browser bundles (e.g. via @qwen-code/webui) get undefined cleanly because globalThis.process doesn’t exist there.

Wrong / missing tokens return 401 with a uniform body — the SDK throws DaemonHttpError on any 4xx/5xx from a route handler.

import { DaemonHttpError } from '@qwen-code/sdk'; try { await client.health(); } catch (err) { if (err instanceof DaemonHttpError) { console.error(`Daemon error ${err.status}:`, err.body); } else { throw err; } }

Cancel an in-flight prompt

If your user hits Esc:

await client.cancel(session.sessionId); // In the event stream you'll see the prompt resolve with stopReason: "cancelled"

Cancel only winds down the active prompt — anything you’d already POSTed and that’s still queued behind it will continue to run. (See protocol reference for the rationale.)

What’s next

Last updated on