Skip to content
nikcli
Docs
Documentation

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.

Not the same 'workspace' as an editor's

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

worktreecontainer
IsolationSeparate directory and branchSeparate filesystem and process space
Cost to createCheapPulls or starts an image
Shares your toolchainYesNo — the image provides it
Good forParallel branches on trusted workUntrusted work, or a different runtime
Extra fieldsstrategy: git or cowruntime, 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.

ts
await client.experimental.workspace.create({
  id: "feature-search",
  branch: "feature/search",
  config: {
    type: "worktree",
    directory: process.cwd(),
    strategy: "git",
  },
})
container
ts
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.

ts
// 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",
})
workspace wins over directory

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.

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

copyChanges is not free

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

ts
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

ts
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

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

SymptomCause
Sessions still collideCreated without workspaceID, so they all landed in the host project.
Workspace not foundThe selector names a workspace that was removed, or was never created on this host.
Feed shows only one jobUsing event.subscribe() instead of global.event().
Changes vanish after a warpcopyChanges not set.
Warp times outA large working tree; raise timeoutMs.
Container workspace never startsThe runtime is not available — check adaptor.list().
Branch already checked outAnother worktree holds it; git allows one checkout per branch.