Back to Qwen Code

DaemonClient quickstart (TypeScript)

docs/developers/examples/daemon-client-quickstart.md

0.19.1211.0 KB
Original Source

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:

bash
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:

bash
npm install @qwen-code/sdk

Hello daemon

ts
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:

ts
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:

ts
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.

ts
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:

ts
// 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:

ts
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):

ts
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:

ts
// 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.

ts
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:

ts
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