@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