Files

@openwork/headless-threads

Drive a native OpenWork thread from code, without rendering the app.

A "thread" here is an ordinary OpenWork session. Same workspace, same managed OpenCode engine, same session id, same persisted messages, tool activity, and final state the desktop UI shows. A thread this package creates can be opened in the app afterwards, because there is no separate headless thread type.

This is a client, not a runtime. It adds no chat engine, no session store, no model gateway, and no new server route — it is a typed workflow over the official OpenCode SDK through OpenWork's workspace-scoped proxy.

Why

Anything that drives OpenWork without a UI — a load check, an automation harness, an agent-quality benchmark — currently re-implements the same sequence by hand against raw HTTP: create a session, submit prompt_async, poll /session/status, poll messages, work out when the turn actually finished, then dig text back out of message parts. Legacy harnesses contained one such copy. This package is that sequence, typed and tested once.

Note what the hand-rolled version has to get right, and what this package now owns: a thread is still idle in the gap between accepting a prompt and starting work, so waiting for "not busy" alone reports a turn finished before it began. Settling requires a non-running status and an assistant message that was not there when the turn was submitted.

Use

import { createHeadlessThreadClient } from "@openwork/headless-threads";

const threads = createHeadlessThreadClient({
  baseUrl: "http://127.0.0.1:8787",
  workspaceId: "ws_1",
  token: process.env.OPENWORK_TOKEN,
  defaultModel: { providerId: "anthropic", modelId: "claude-sonnet-5" },
});

const thread = await threads.createThread({
  title: "Refund policy",
  prompt: "A customer wants a refund after 40 days. What are their options?",
});

await threads.waitForThread(thread.id, { timeoutMs: 120_000 });

const followUp = await threads.sendTurn(thread.id, {
  prompt: "They also lost the receipt.",
});
const waited = await threads.waitForThread(thread.id, {
  timeoutMs: 120_000,
  since: followUp,
});

if (waited.outcome === "settled") {
  const transcript = await threads.exportTranscript(thread.id);
  console.log(transcript.finalAssistantText);
}

thread.id is the native session id. Opening OpenWork on the same workspace shows this conversation in the sidebar.

Contract

AgentSessionClient is the intentionally small replaceable runtime port: create a thread, send a turn, read a snapshot, and abort. HeadlessThreadClient extends it with polling and transcript helpers. OpenCode is currently the only implementation; UI inventory and cross-workspace plugin routing stay on the native protocol instead of expanding this port speculatively.

Function Does
createThread Creates a thread, optionally with its first turn and a model.
sendTurn Submits a turn and returns an acceptance that records the pre-turn message count.
waitForThread Polls to a bounded deadline. Returns settled, timeout, or aborted — never throws on a slow thread.
getThreadSnapshot Status, messages, and todos as OpenWork stores them.
abortThread Requests a stop. Acceptance is not proof the run ended; wait afterwards to observe idle.
exportTranscript Flattens a snapshot into per-message text, reasoning, and tool calls.

waitForThread reports an outcome rather than throwing, so a caller running many threads can record a timeout as a result instead of losing the run. Transport and payload failures throw HeadlessThreadError, which carries code, status, method, path, and the server's body.

Deterministic by construction: inject fetch, now, and sleep to run the whole contract against fixtures with no wall-clock dependency.

Test

pnpm --filter @openwork/headless-threads test

The end-to-end proof lives with the server it drives, in apps/server/src/headless-threads.e2e.test.ts, and runs against a real OpenWork server:

pnpm --filter openwork-server test src/headless-threads.e2e.test.ts