Control SDK (`@zana-ai/zcc-control`)
Node client for live-driving a running Zana Command Center over loopback
product HTTP. Tests, one-off scripts, and the operator CLI (zcc thread /
zcc agent) use the same client.
It is not a Playwright replacement, not part of plugin-sdk, and not
a new zcc control namespace. Plugin origin stays plugin. This client always
stamps origin: 'sdk'.
Operator surface: `docs/cli.md`. Coupling note: CLAUDE.md
(“Verify threads / CLI Agents still launch” → pnpm live:mode-reasoning
then pnpm live:memory).
What it is / is not#
| Is | Is not |
|---|---|
Attach to a running app (:8780 / :8781) |
Playwright / Electron E2E |
| Isolated server + host-daemon for threads | Isolated CLI Agent (needs Electron) |
Shared client for live tests and zcc thread / zcc agent / zcc browser |
Plugin SDK (definePluginApp) |
Tagged live-test janitor ([zcc-live:<runId>]) |
Operator titles (CLI uses tagged: false) |
| Modern threads + CLI Agents + desktop-browser product HTTP | teams.launch, shell PTY (POST /api/v1/terminals), in-app chrome (find-in-page, Take over) |
Connect#
import { Zcc } from '@zana-ai/zcc-control';
const zcc = await Zcc.connect();
const project = await zcc.projects.ensureLiveSandbox();
await zcc.harness.preflight({ surface: 'thread', providerId: 'claude-code' });
const thread = await zcc.threads.spawn({
projectId: project.id,
prompt: 'reply with PONG and stop',
permissionMode: 'accept-edits'
});
await thread.wait({ until: 'idle', onInteraction: 'fail' });
await thread.send('second turn: reply PONG2 and stop');
await thread.wait({ until: 'idle' });
await zcc.cleanup();Zcc.connect() attaches to a running app. It probes
http://127.0.0.1:8780 (packaged / pnpm start) then :8781
(pnpm dev + ~/.zcc-dev). If both answer, set ZCC_SERVER_URL.
Zcc.launch({ isolated: true }) starts server + enrolled host-daemon in a
temp data dir (threads only). CLI Agent cannot run here until host-rpc
harness.start exists. Parallel CLI Agent in v1 = many sessions on one
desktop, not N Electron-less workers.
Do not call Zcc.connect() from operator CLI if that would mint a live-run
journal. zcc thread spawn / zcc agent launch construct ProductHttpClient
and call spawnThread / launchCliAgent with tagged: false.
Host shell and credentials#
Live tests and mutating CLI must run from a host shell, not a ZCC agent
terminal. ZCC_SESSION_ID (injected by the app) marks the caller as an agent:
mutating product HTTP returns FORBIDDEN_AGENT (CLI exit 5). Unset it before
live scripts (unset ZCC_SESSION_ID).
CLI Agent HTTP (POST /api/v1/cli-agents) uses a boot-injected
product-server credential in Electron main. That credential is not on
Electron process.env. Human zcc over UDS still confirms. Unattended HTTP
does not mint consent — some executionState values return a clean DENIED.
Fake binaries and PATH#
provider: 'fake' cannot rewrite an already-running Electron PATH.
Zcc.connect({ provider: 'fake' }) throws. Use Zcc.launch({ isolated: true, fake: true })
or start the app with fake binaries already on PATH / config *Binary keys.
Helpers live in @zana-ai/zcc-control/testing.
Surfaces#
| Surface | HTTP | Backend |
|---|---|---|
| Modern thread | POST /api/v1/threads |
host-daemon ACP |
| CLI Agent | POST /api/v1/cli-agents |
Electron createTerminalConfined via product-server credential (no native confirm) |
| Desktop browser | POST /api/v1/desktop-browsers/* |
Electron BrowserView broker (attach only; isolated stacks skip) |
Do not overload POST /api/v1/terminals (shell PTY). Team launch is a product
HTTP + control-plane confirm path, not an SDK verb. In-app browser chrome
(find-in-page, Take over, plugin-page Browser tab) stays Playwright
(e2e/desktop-browser-broker.spec.ts). This client never imports cookies in
live tests — listing import sources is enough to prove cookie values stay in
main.
API#
`Zcc`#
| Method | Notes |
|---|---|
Zcc.connect(opts?) |
Attach. Probes 8780 then 8781. Mints a runId. |
Zcc.launch({ isolated: true }) |
Isolated stack, threads only. |
health() |
Product health + whether a host is connected. |
projects.list() |
Registered projects. |
projects.ensureLiveSandbox() |
Dedicated live-sandbox project. |
hosts.list() |
Registered machines (GET /api/v1/hosts). |
harness.preflight({ surface, providerId?, profile? }) |
Fail fast if the harness is missing. |
threads.spawn(spec) |
Tagged live spawn (visibility: 'hidden' unless set). |
cliAgents.launch(spec) |
Tagged live launch. Throws ISOLATED_CLI_AGENT on isolated stacks. |
browsers.pickInstance({ hostId? }) |
First connected desktop window, or { skip, reason } (isolated / no host / no BrowserView). |
browsers.session(scope) |
Thread-scoped tab/lease handle. |
browsers.listInstances(hostId) / listImportSources / importCookies |
Same HTTP as zcc browser. |
cleanup({ runId?, stale? }) |
Stop journaled ids for this run, or all stale journals. |
close() |
Cleanup then stop an isolated stack. |
`ThreadHandle`#
| Method | Notes |
|---|---|
snapshot() / status |
Last fetched row. |
refresh() |
GET /api/v1/threads/:id |
wait({ until, timeoutMs, onInteraction }) |
idle (default) / quiet / error / needs_you. onInteraction: fail (default) / deny / approve-safe. |
waitForEvent(type) |
Long-poll thread events. |
| send(text, { mode, model, acpMode }?) | Follow-up turn. |
| stop() | Stop the thread. |
| fork() | Fork history into a new thread. |
| timeline() | Timeline snapshot. |
| interactions() / resolveInteraction(id, resolution) | Pending permission prompts. |
| assertHealthy() | Throws UNHEALTHY if status === 'error'. |
Raw product HTTP history (GET /api/v1/threads/:id/events) is paginated: the
default is the latest 500 events, with limit capped at 1,000 and a 16 MiB
payload budget. Events are ordered oldest to newest within each page. When
hasOlder is true, pass the returned nextBeforeSeq as beforeSeq to read the
preceding page. A single event exceeding the budget returns HTTP 413
(history-read-too-large); the stored conversation is preserved. Event waits
filter by type and sequence inside SQLite, and timeout diagnostics request only
the latest 20 events.
`CliAgentHandle`#
| Method | Notes |
|---|---|
snapshot() / status |
Last fetched row. |
refresh() |
GET /api/v1/cli-agents/:id |
wait({ until, timeoutMs }) |
idle (default) / working / done / exited. idle also accepts done/exited. |
reply(text) |
Inject a follow-up at the live prompt. |
stop() |
Stop the PTY session. |
`DesktopBrowserHandle`#
Thread-scoped native tabs on an attached Electron window. Isolated
Zcc.launch({ isolated: true }) cannot run this — there is no BrowserView.
const picked = await zcc.browsers.pickInstance();
if ('skip' in picked) return;
const thread = await zcc.threads.spawn({ projectId: project.id, prompt: 'stop immediately' });
const browser = zcc.browsers.session({ ...picked.instance, threadId: thread.id });
const cycle = await runDesktopBrowserLeaseCycle(browser, { probeCdp: true });| Method | Notes |
|---|---|
listTabs() / create({ url, presentation }) |
Hidden about:blank automation profile by default. |
acquire({ tabIds, controllerLabel, ttlMs?, allowPersonal? }) |
Exclusive lease. Personal tabs need allowPersonal. |
connection(leaseId) |
Loopback ws://127.0.0.1 only; non-loopback throws. |
capture(tabId) |
Bounded JPEG (no focus required). |
reveal(tabId) / close(tabId) / release(leaseId) |
Reveal is a no-op unless that thread is already focused. |
runDesktopBrowserLeaseCycle (create → acquire → CDP Browser.getVersion →
capture → release → close) is the live-test helper.
runDesktopBrowserImportProbe then POSTs importCookies into the tagged
thread's automation partition (never personal). The default probe uses a
missing source profile so it does not copy signed-in cookies; set
ZCC_LIVE_BROWSER_IMPORT=1 to import a listed profile when one is available.
Launch context (tagged vs operator)#
ctx: { runId: string; dataDir: string; tagged?: boolean }
// tagged defaults trueTagged (Zcc.threads.spawn, live tests) |
Untagged (zcc thread spawn / zcc agent launch) |
|
|---|---|---|
| Title | [zcc-live:<runId>] … |
User title as given |
| Thread visibility | hidden unless set |
visible unless set |
| Journal | {dataDir}/live-runs/ |
none |
origin |
'sdk' |
'sdk' |
| Role XOR model | assertRoleXorModel |
same |
Launch specs#
Thread `providerId` vs CLI `profile`#
| Harness | Thread providerId |
CLI Agent profile |
|---|---|---|
| Claude Code | claude-code |
claude |
| Cursor | acp-cursor |
cursor |
| Codex | codex |
codex |
| OpenCode | acp-opencode |
opencode |
Modes (threads)#
| Harness | How to set mode |
|---|---|
| Claude Code | Leading /plan in the prompt (slash Plan). Default agent has no acpMode. |
| Codex | Same /plan prompt convention. |
| Cursor | acpMode: 'agent' | 'plan' |
| OpenCode | acpMode: 'build' | 'plan' |
Thread reasoning is reasoningLevel: low / medium / high.
Thread permission: permissionMode accept-edits / auto / full.
Modes (CLI Agents)#
Trusted harnessRouting.byAdapter[profile]:
| Field | Values | Notes |
|---|---|---|
executionState |
plan / interactive / accept-edits / autonomous |
Write-allow uses trusted autonomous (Claude bypassPermissions, Cursor --force, Codex sandbox+never, OpenCode --auto). |
modelLevel |
low / medium / high / extra-high |
Cursor has no low / extra-high mapping. |
roleTargetId |
OpenCode build / plan |
XOR catalog model (modelTargetId / modelLevel / compatibility.model). A native role pins its own model. |
assertRoleXorModel rejects roleTargetId together with any catalog model.
OpenCode roleTargetId + executionState is also denied at the product server.
Unattended product-server HTTP cannot mint consent. accept-edits sets
unattendedAllowed: false on every harness target, so those launches return a
clean DENIED. Some interactive mappings do the same. That is policy, not
a crash.
extraArgs sanitization#
Do not pass --extra-args from the operator CLI. Denied flags would be
stripped (sanitizeExtraArgs) and look like they worked.
Stripped tokens include --force, --permission-mode, -s, --auto,
--dangerously-skip-permissions, --mcp-config, --agent, and the rest of
DENIED_LAUNCH_FLAGS in @zana-ai/zcc-domain. Write-allow belongs on
trusted harnessRouting.executionState: 'autonomous'.
Cleanup, tagging, live scripts#
Tagged launches are hidden, titled [zcc-live:<runId>] …, and journaled under
{dataDir}/live-runs/. zcc live cleanup --stale (or zcc.cleanup({ stale: true }))
stops leftover tagged threads/agents and project processes after a killed Vitest.
Operator CLI launches are not tagged and are not journaled — live cleanup
will not reap them.
pnpm live:smoke # attach: threads + CLI Agent
pnpm live:matrix
pnpm live:cli-scenarios # second turn, file edit, stop mid-run
pnpm live:mode-reasoning # required after spawn / mode / reasoning changes
pnpm live:browser # desktop-browser product HTTP (attach Electron)
pnpm live:memory # required with mode-reasoning; Memory catalog injection
zcc live cleanup --staleRun live scripts from a host shell: unset ZCC_SESSION_ID && pnpm live:mode-reasoning && pnpm live:memory.
CLI Agent scenarios (`pnpm live:cli-scenarios`)#
Against Claude Code, Cursor, Codex, and OpenCode:
- Second turn — launch → idle →
reply()→ idle. - File edit — write
zcc-live-<runId>-<profile>.txtunder live-sandbox with trustedexecutionState: 'autonomous'. - Stop mid-run —
stop()once working; session leaves the live list and its pid is dead.
OpenCode launches with native role build and no catalog --model.
Mode and reasoning (`pnpm live:mode-reasoning`)#
Additive (each mode once, each reasoning once), not cartesian. Asserts the spawn
stays alive then stop() — no PONG or wait-until-idle. Missing harnesses
console.warn and skip. Keep this suite off pnpm live:matrix.
Required after spawn / mode / reasoning changes on threads or CLI Agents
(see CLAUDE.md). Follow with pnpm live:memory on the same attach.
Desktop browser (`pnpm live:browser`)#
Against an attached Electron app (not an isolated stack): pick a connected
desktop window, spawn a tagged hidden thread, then
runDesktopBrowserLeaseCycle — create a hidden automation tab, acquire
control, probe loopback CDP Browser.getVersion, capture a JPEG, release,
close. Then runDesktopBrowserImportProbe POSTs importCookies into that
thread's automation partition (never personal) and asserts cookie values
never leave main. The default probe does not copy signed-in cookies; set
ZCC_LIVE_BROWSER_IMPORT=1 to import a listed profile when one is available.
Missing host / no registered desktop window / harness console.warn and skip.
A connected host that answers desktop_browser_unavailable is a failure
(product HTTP cannot see Electron's BrowserView broker). Keep this off
pnpm live:matrix. Native chrome (find-in-page, Take over)
stays e2e/desktop-browser-broker.spec.ts.
Required after desktop-browser product HTTP, broker lease/CDP, or
zcc browser changes (see CLAUDE.md).
Memory plugin (`pnpm live:memory`)#
Against an attached app with the Memory plugin installed and running:
product-HTTP CLI (add / catalog / search / get / isolate / forget), then a
tagged hidden Claude Code thread that must quote a seeded catalog summary
from contributeInstructions (no tools). Missing plugin or harness
console.warn and skip — an empty verify that finishes in milliseconds is a
false green. Keep this off pnpm live:matrix.
Required after Memory plugin CLI, catalog injection, or zcc memory changes,
and after thread / CLI Agent spawn changes (see CLAUDE.md).
CLI mapping#
Each SDK verb has a zcc counterpart. Operator commands pass tagged: false.
| SDK | CLI |
|---|---|
spawnThread |
zcc thread spawn |
ThreadHandle.wait (idle / quiet) |
zcc thread wait / spawn --wait (--until turn → idle, quiet → quiet) |
ThreadHandle.send |
zcc thread tell |
ThreadHandle.stop |
zcc thread stop |
ThreadHandle.fork |
zcc thread fork |
ThreadHandle.timeline |
zcc thread log |
ThreadHandle.interactions |
zcc thread interactions |
launchCliAgent |
zcc agent launch |
CliAgentHandle.wait |
zcc agent wait / launch --wait |
CliAgentHandle.reply |
zcc agent reply |
CliAgentHandle.stop |
zcc agent stop |
browsers.listInstances |
zcc browser instances |
browsers.session().listTabs |
zcc browser tabs |
browsers.session().create |
zcc browser create |
browsers.session().acquire |
zcc browser acquire |
browsers.session().connection |
zcc browser connection |
browsers.session().release |
zcc browser release |
browsers.session().reveal / close / capture |
zcc browser reveal / close / capture |
browsers.listImportSources / importCookies |
zcc browser import-sources / import-cookies |
cleanupRun / cleanupStale |
zcc live cleanup --tag / --stale |
zcc thread spawn --project <id> --prompt "…" [--provider claude-code] [--model <id>] \
[--acp-mode <mode>] [--reasoning-level low|medium|high] [--permission-mode accept-edits|auto|full] [--wait]
zcc agent launch --project <id> --prompt "…" [--profile claude] [--persona <id>] [--title] \
[--execution-state plan|interactive|accept-edits|autonomous] \
[--model-level low|medium|high|extra-high] [--role <id>] [--wait] [--timeout]
zcc agent wait <id> [--until idle|working|done] [--timeout]
zcc agent reply <id> "…"
zcc agent stop <id>--role XOR --model-level. zcc agent ls and zcc term reply stay on the
control plane.
Not wired#
teams.launch— types exist (TeamLaunchSpec); the SDK has nozcc.teams.launch. Usezcc team launch(product HTTP).- Shell PTY —
zcc terminal create/POST /api/v1/terminals. Not a harness launch. - Isolated CLI Agent — rejected until host-rpc
harness.startexists.