Parallel work with workspaces
Run several sessions against the same repository at once, each in its own worktree or container — and move a session between them without losing it.
Why workspaces
Two sessions editing one directory fight. A workspace gives a session its own checkout, so several can work on the same repository at the same time without stepping on each other.
This is what makes parallel agent work practical: three branches in flight, each with its own files, its own git state and its own session, all on one server.
A nikcli workspace is a sandboxed compute environment for a session — a worktree or a
container — not a multi-root project. The endpoints live under
/experimental/workspace and the surface is still experimental.
Worktree or container
worktree | container | |
|---|---|---|
| Isolation | Separate directory and branch | Separate filesystem and process space |
| Cost to create | Cheap | Pulls or starts an image |
| Shares your toolchain | Yes | No — the image provides it |
| Good for | Parallel branches on trusted work | Untrusted work, or a different runtime |
| Extra fields | strategy: git or cow | runtime, image, containerName |
cow is a copy-on-write clone rather than a git worktree, which is faster on large
repositories where a checkout is the slow part.
Create one
The id is yours to choose — it is the handle you will pass everywhere else.
branch is the branch to check out, or null to stay on the current
one.
await client.experimental.workspace.create({
id: "feature-search",
branch: "feature/search",
config: {
type: "worktree",
directory: process.cwd(),
strategy: "git",
},
}) await client.experimental.workspace.create({
id: "untrusted-pr",
branch: "pr/482",
config: {
type: "container",
directory: process.cwd(),
runtime: "docker",
image: "oven/bun:1",
containerName: "nikcli-pr-482",
},
}) client.experimental.workspace.adaptor.list() reports which backends this host can
actually provide — check it before offering container workspaces in a UI.
Targeting a workspace
Every endpoint takes a workspace selector, exactly like directory.
Pass it per call, or bind a whole client to one.
// one call
await client.session.list({ workspace: "feature-search" })
// a client pinned to the workspace
const inWorkspace = createNikcliClient({
baseUrl: "http://127.0.0.1:4096",
workspace: "feature-search",
})
const { data: session } = await inWorkspace.session.create({
title: "search work",
workspaceID: "feature-search",
})
When both are present the workspace decides, and it replaces the instance directory with the
workspace's own. A session created with workspaceID stays bound to it, so later
calls do not have to repeat the selector.
Warping a session
Warp moves a session into another workspace, optionally carrying its uncommitted changes.
Passing null as the id warps it back to the host project.
// move it into a workspace, taking the working tree along
await client.experimental.workspace.warp({
id: "feature-search",
sessionID: session.id,
copyChanges: true,
})
// bring it home
await client.experimental.workspace.warp({
id: null,
sessionID: session.id,
copyChanges: true,
}) This is the "I started this in the wrong place" move: a session that began in the main checkout and turned into real work can be lifted into isolation without being restarted and without losing its transcript.
It moves the working tree, so it can take a while on a large diff and it accepts a
timeoutMs. Without it the session moves and the changes stay behind.
Status and events
const { data: all } = await client.experimental.workspace.list()
const { data: status } = await client.experimental.workspace.status()
const { data: events } = await client.experimental.workspace.events({ id: "feature-search" }) status is the one to poll in a dashboard: it reports each workspace's state
without having to ask each one in turn.
Cleaning up
await client.experimental.workspace.restore({ id: "feature-search" })
await client.experimental.workspace.remove({ id: "feature-search" }) restore brings a workspace back to a known state;
session.restore does the same for one session inside it. Remove when the branch
has landed — a worktree left behind is a checkout that keeps drifting.
Three branches at once
import { createNikcliClient } from "@nikcli-ai/sdk/httpapi"
const client = createNikcliClient({
baseUrl: "http://127.0.0.1:4096",
directory: process.cwd(),
})
const jobs = [
{ id: "fix-login", branch: "fix/login", task: "Fix the login redirect loop." },
{ id: "bump-deps", branch: "chore/deps", task: "Upgrade dependencies one major at a time." },
{ id: "docs-pass", branch: "docs/api", task: "Document the public API surface." },
]
const started = await Promise.all(
jobs.map(async (job) => {
await client.experimental.workspace.create({
id: job.id,
branch: job.branch,
config: { type: "worktree", directory: process.cwd(), strategy: "git" },
})
const { data: session, error } = await client.session.create({
title: job.id,
workspaceID: job.id,
})
if (!session) throw error
// promptAsync so all three run at once instead of in sequence
await client.session.promptAsync({
sessionID: session.id,
parts: [{ type: "text", text: job.task }],
workspace: job.id,
})
return { ...job, sessionID: session.id }
}),
)
// one feed covers every workspace on the host
const { stream } = await client.global.event()
const done = new Set<string>()
for await (const event of stream) {
if (event.type !== "session.idle") continue
const job = started.find((entry) => entry.sessionID === event.properties.sessionID)
if (!job) continue
const { data: diff } = await client.session.diff({
sessionID: job.sessionID,
workspace: job.id,
})
console.log(`\n=== ${job.id} (${job.branch}) ===`)
console.log(diff)
done.add(job.id)
if (done.size === started.length) break
}
The two things that make this work: promptAsync, so the three run concurrently
rather than one after another, and global.event(), which spans every workspace —
an instance-scoped feed would only see one of them.
Failure modes
| Symptom | Cause |
|---|---|
| Sessions still collide | Created without workspaceID, so they all landed in the host project. |
| Workspace not found | The selector names a workspace that was removed, or was never created on this host. |
| Feed shows only one job | Using event.subscribe() instead of global.event(). |
| Changes vanish after a warp | copyChanges not set. |
| Warp times out | A large working tree; raise timeoutMs. |
| Container workspace never starts | The runtime is not available — check adaptor.list(). |
| Branch already checked out | Another worktree holds it; git allows one checkout per branch. |