# 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

|  | `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.

ts 

1

2

3

4

5

6

7

8

9

```
await client.experimental.workspace.create({
  id: "feature-search",
  branch: "feature/search",
  config: {
    type: "worktree",
    directory: process.cwd(),
    strategy: "git",
  },
})
```

container

ts 

1

2

3

4

5

6

7

8

9

10

11

```
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 

1

2

3

4

5

6

7

8

9

10

11

12

13

```
// 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 

1

2

3

4

5

6

7

8

9

10

11

12

13

```
// 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 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

```
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. |

## Related

-   [Workspaces API Every workspace endpoint and payload.](/docs/api/workspace)
-   [Streaming a response The event feed this guide fans out over.](/docs/guides/streaming)
-   [Sessions What a session is and what binds it to a workspace.](/docs/sessions)
-   [Using the API The client this guide assumes.](/docs/guides/api)

---

Source: https://nikcli-ai.dev/docs/guides/workspaces
