API-only DaemonClient quickstart (TypeScript)
A minimal end-to-end example: start an API-only 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
This walkthrough targets Qwen Code v0.24.0 and
@qwen-code/sdk@0.1.12.
In one terminal:
qwen serve --no-web --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)--no-web removes the Web Shell assets and the surfaces bound to them: POST /workspace/local-control/enable then fails closed with 409 local_control_web_shell_unavailable on every platform, and on macOS the /live/* routes, the /live/host WebSocket and the experimental.liveVoice.* settings keys are not registered — so an integration that drives the SDK’s Live methods must run without --no-web. It is not a feature-profile switch: the session, prompt, workspace, permission and SSE routes are unchanged. 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.
The token-less loopback default is intended for a single-user workstation. On a shared host, set QWEN_SERVER_TOKEN and add --require-auth; non-loopback binds require a token.
In another:
npm install @qwen-code/sdk@0.1.12Hello 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);
}
}Restore, poll status, and read history
Closing a live session does not delete its persisted transcript. Save the id,
close the live owner, then restore it. Use loadSession when the client needs
persisted turns replayed into its SSE stream; use resumeSession when the
client already has those turns rendered and only needs the daemon-side handle
restored. Neither method continues an interrupted turn; call continueSession
separately when that is required.
const savedSessionId = session.sessionId;
await client.closeSession(savedSessionId, session.clientId);
const restored =
process.env.HISTORY_ALREADY_RENDERED === '1'
? await client.resumeSession(savedSessionId, {
workspaceCwd: selectedWorkspace.cwd,
})
: await client.loadSession(savedSessionId, {
workspaceCwd: selectedWorkspace.cwd,
historyPageSize: 100,
});
const status = await client.sessionStatus(
restored.sessionId,
restored.clientId,
);
console.log({
active: status.hasActivePrompt,
waitingForPermission: status.isWaitingForPermission,
});
const history = await client.getSessionTranscriptPage(restored.sessionId, {
limit: 100,
clientId: restored.clientId,
});
console.log(`history events=${history.events.length} more=${history.hasMore}`);sessionStatus reads only a live owner. getSessionTranscriptPage reads
persisted history and returns an opaque nextCursor when another page is
available; pass that value back as cursor rather than constructing one.
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 accept the token-less trusted-loopback primary
listener; non-trusted deployments require bearer or pairing credentials.
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); // trueBoth 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/web-shell) 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
- HTTP protocol reference — full route spec with status codes
- Daemon mode user guide — operator-side docs
- Source:
packages/sdk-typescript/src/daemon/