---
name: "assemble-vms"
description: "Set up and use Assemble VMs in an existing project. Use when adding the SDK, connecting a harness, running commands, working with session files, controlling Chromium with Playwright, taking browser screenshots, or verifying pause and reopen behavior."
---

# Add Assemble VMs to a project

You are implementing a basic Assemble VMs integration in the user's existing project. Read this entire guide, inspect the project's conventions, then implement and verify the smallest useful integration. The default is a remote harness: the application stays on the user's infrastructure and accesses a persistent Linux VM through the SDK.

For a browser task in a project that already uses Assemble, reuse its client and session. Follow the browser instructions in section 7 without repeating SDK installation or the initial setup check.

This is the canonical Assemble VMs agent skill at `https://docs.assemble.ai/skill.md`. Read every section before implementing the integration; a summary may omit required verification and cleanup behavior.

Assemble is currently in development preview. The hosted API is available at `https://assemble-vms-api.fly.dev` with an operator-issued product key. npm packages remain unpublished, so use the Assemble checkout or package files the user supplies. Customer signup and key self-service are not implemented. Do not invent a signup page, registry install, or alternative hosted endpoint.

## 1. Inspect the project and inputs

- Find the project's package manager, server entry points, module format, and existing environment-variable pattern. Preserve the current application structure. Add a small server-side client module and a runnable verification script; a new UI is unnecessary for this task.
- Use Node.js 22.19 or later. The Assemble source repository uses npm 11 or later; respect the target project's package manager when installing its dependencies.
- Locate the supplied Assemble checkout or trusted package files for SDK installation. Reading this guide does not require a checkout. If one is available, read `packages/sdk/src/index.ts`, `docs/reference/typescript.mdx`, and `examples/first-session.mjs` for the exact API and runnable reference.
- Required runtime variables are `ASSEMBLE_BASE_URL` and `ASSEMBLE_API_KEY`. Use a dedicated stable session name in `ASSEMBLE_SESSION_NAME`, or choose a descriptive name for this project's setup check. Names must match `^[a-z0-9][a-z0-9_-]*$` and be 1–64 characters long.
- The API key is a product key issued by the service operator. Your Assemble API key is the only service credential required for this setup.

Never put a key in a prompt, committed file, browser bundle, URL, or log. Do not read unrelated credential files or copy infrastructure credentials from another project. An `.env.example` may contain empty variable names. Keep real values in the user's ignored local environment or server secret store. Use the project's existing environment loader; Node does not load an `.env` file automatically.

If the API URL, key, or package files are unavailable, implement the parts that can be completed, validate them locally, and report the exact missing input by name. The user can supply the key through their environment without sending it in chat. Do not claim live verification succeeded until it runs.

## 2. Install the current SDK

Inside the Assemble checkout, the npm workspace makes `@assemble-workspace/sdk` available after:

```sh
npm install
npm run build
```

For another project, use local package archives until publication. From the Assemble repository root, build and pack both the contracts and SDK:

```sh
npm run build
mkdir -p artifacts
npm pack --workspace @assemble-workspace/contracts --pack-destination artifacts
npm pack --workspace @assemble-workspace/sdk --pack-destination artifacts
```

Install both generated archives into the target project in the same command. Replace these paths with the actual supplied or generated files:

```sh
npm install /absolute/path/assemble-workspace-contracts-0.1.0.tgz /absolute/path/assemble-workspace-sdk-0.1.0.tgz
```

Use the filenames returned by `npm pack` if their versions differ. Preserve the target project's lockfile conventions. The CLI is optional; the SDK alone completes this check. Do not use an unverified registry install as a fallback.

## 3. Add the server-side client

Import `AssembleClient` from `@assemble-workspace/sdk`. Read and validate the two runtime variables before constructing it:

```js
import { AssembleClient } from "@assemble-workspace/sdk";

const apiKey = process.env.ASSEMBLE_API_KEY;
const baseUrl = process.env.ASSEMBLE_BASE_URL;
if (!apiKey || !baseUrl) {
  throw new Error("Set ASSEMBLE_API_KEY and ASSEMBLE_BASE_URL in the server environment.");
}

const client = new AssembleClient({ apiKey, baseUrl });
```

Keep this module on the server or in a local Node script. Never use public environment prefixes such as `NEXT_PUBLIC_` or `VITE_` for the product key. Set `ASSEMBLE_BASE_URL=https://assemble-vms-api.fly.dev` for the hosted API, or use the operator-supplied URL for a different service. Remote service URLs require HTTPS; `http://localhost:8787` is the default local API URL when that service has been started. `https://docs.assemble.ai` serves documentation; send SDK requests to the API URL.

## 4. Implement the first-session verification

If a checkout is available, use its `examples/first-session.mjs` as the runnable reference, adapting its location and imports to the target project. When installing from package archives, implement the same flow directly. The flow must perform these steps:

1. Open a dedicated named session with `await client.sessions.open({ name })`. Save its `id`. The default image is `terminal`.
2. Write a small UTF-8 verification message to a fresh path such as `.assemble-setup/<random-identifier>.txt` with `await session.files.write(path, body)`. Use a unique path so existing project files are preserved. Keep the message and exact path available for later comparisons.
3. Run a command that reads that file using `await session.exec({ command, timeoutSeconds: 30 })`. Commands start in `session.rootDirectory`, normally `/workspace`. Correctly shell-quote the path; do not insert arbitrary user text into a shell command.
4. Iterate `execution.events()` to stream `stdout` and `stderr`. Capture stdout as well so it can be compared with the expected message.
5. Await `execution.wait()`. Require both `result.state === "completed"` and `result.exitCode === 0`. Waiting does not turn a failed command into a thrown exception automatically. Compare stdout with the message.
6. Call `await session.files.read(path)`, which returns `Uint8Array`. Compare its exact bytes with those written. `TextDecoder` is appropriate for this UTF-8 example; preserve byte comparisons for binary data.
7. Pause with `await session.pause()`.
8. Reopen using `await client.sessions.open({ name })` under the same API-key owner. Assert the reopened ID equals the original ID and its file contents still match. Opening the same name reuses and resumes the saved session. `client.sessions.get(id)` only retrieves metadata; it does not resume a paused session.
9. Pause the reopened session and report its ID, name, and verification file path. Keep these available for the next run. Use error handling that attempts to pause a created session after a failed check without hiding the original error or a pause failure.

Use `session.files` and `session.exec` for remote operations. Local `fs` and `child_process` calls operate on the machine running this integration, so they cannot substitute for the remote file and command checks.

Add a clear run command to the target project's README or script list. If the shell already provides the variables, a standalone ESM example can run with:

```sh
node examples/first-session.mjs
```

When using an ignored local environment file, load it using the project's existing tooling or Node's explicit `--env-file` option. Do not print environment values in the handoff.

## 5. Respect persistence and cleanup behavior

A session is a retained Assemble VM with its own persistent filesystem. The file API and commands share `/workspace`. Opening the same name under the same owner returns that saved session. Pausing keeps both the session and its files; stopping the local script does not delete the VM or disk.

This check verifies pause/reopen retention in the same environment. It does not prove VM-replacement recovery or crash durability. The session filesystem stages dirty data until it is flushed; a healthy mount or ordinary command success alone is not evidence that all new bytes are durable. The file API explicitly flushes its writes, while arbitrary applications must flush their own data. There is no automatic per-command rollback. A failed or cancelled command may leave changes behind.

Leave the verification session paused by default. Delete only when the user explicitly asks to discard it. `await session.delete()` permanently removes both the VM and its session filesystem. Do not delete the session merely because a test finished or failed, and do not delete unrelated sessions. Report a session that could not be paused so the user can act on it.

## 6. Connect the user's harness after the check

The initial setup needs no model invocation. Give the user a working session integration first. Then follow their chosen mode:

- **Remote harness:** Keep the agent loop in the user's application and map its file and shell tools to `session.files` and `session.exec`. Reconnect to an execution with `await session.execution(executionId)`. Execution event readers can reconnect using `events({ after: sequence })`; disconnecting the reader does not cancel the command. Request cancellation with `await execution.cancel()`, then use `await execution.wait()` and inspect the confirmed terminal state.
- **Harness inside the VM:** Obtain short-lived access with `await session.ssh.create({ expiresInMinutes: 15 })`, then use its returned command. Treat that command as a credential: keep it out of prompts, logs, source control, and screenshots. Raw SSH starts in the image's login home and directory. Explicitly change to `session.rootDirectory`; export `HOME` to `session.homeDirectory` if shared managed-command configuration is needed. Both home directories are VM-local. Revoke the grant with `await session.ssh.revoke(access.id)`. The basic setup does not need to install a CLI agent or its credentials.
- **Provided Pi harness:** Iterate `session.agent.run({ message })` if the operator configured a default model. Optional `provider` and `model` overrides must be supplied together. This is a fresh one-shot agent conversation using the existing session's remote tools; the harness itself runs in the service. Reusing the session retains files, while multi-turn conversation persistence is not implemented. Do not silently switch the user's harness to this mode.

## 7. Control Chromium inside the session when requested

Assemble provides command execution through `session.exec()` and file retrieval through `session.files.read()`. The agent supplies the Chromium command or browser automation script. There is no dedicated Assemble screenshot or browser-control API.

Resume a paused session before using it: `await session.resume()`. The first-session check above leaves its session paused. To create a separate browser session, use `await client.sessions.open({ name: "my-browser-task", image: "browser" })` with a name chosen for this project. Keep the same image when reopening that name; changing an existing session's image returns `image_mismatch`. A browser image selection does not guarantee a particular executable or automation library is installed. Inspect the VM first.

### Take a screenshot with the installed Chromium

This example runs in the caller's Node application with an active `session`. Set `targetUrl` to the page the user requested. A loopback URL such as `http://127.0.0.1:3000` reaches a server inside the VM; it does not reach the caller's laptop.

```js
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";

const targetUrl = "https://example.com/";
const captureDirectory = `.assemble-browser/${randomUUID()}`;
const screenshotPath = `${captureDirectory}/screenshot.png`;
const execution = await session.exec({
  cwd: session.rootDirectory,
  timeoutSeconds: 60,
  env: {
    BROWSER_URL: targetUrl,
    CAPTURE_DIRECTORY: `${session.rootDirectory}/${captureDirectory}`,
  },
  command: `set -eu
browser_bin="$(command -v chromium || command -v chromium-browser || command -v google-chrome || true)"
if [ -z "$browser_bin" ]; then
  printf '%s\\n' 'Chromium is not installed in this session.' >&2
  exit 1
fi
mkdir -p "$CAPTURE_DIRECTORY"
"$browser_bin" --version
"$browser_bin" --headless --window-size=1280,800 --timeout=15000 \\
  --screenshot="$CAPTURE_DIRECTORY/screenshot.png" "$BROWSER_URL"
test -s "$CAPTURE_DIRECTORY/screenshot.png"`,
});

for await (const event of execution.events()) {
  if (event.type === "stdout") process.stdout.write(event.data);
  if (event.type === "stderr") process.stderr.write(event.data);
}
const result = await execution.wait();
if (result.state !== "completed" || result.exitCode !== 0) {
  throw new Error(`Browser capture failed: ${result.state}, exit ${result.exitCode}`);
}
const png = await session.files.read(screenshotPath);
const localPath = `assemble-browser-${captureDirectory.split("/").at(-1)}.png`;
await writeFile(localPath, png);
```

The browser runs remotely; the final `writeFile` saves its retrieved bytes on the caller's machine. Keep PNG data as `Uint8Array`, never decode it with `TextDecoder`. Each file read is limited to 10 MiB. Use a smaller viewport if needed. The fresh output directory prevents a failed capture from returning an old screenshot.

To read the rendered DOM, use the same executable discovery and environment setup, replacing the screenshot command and its following `test -s` with:

```sh
"$browser_bin" --headless --dump-dom --timeout=15000 "$BROWSER_URL" > "$CAPTURE_DIRECTORY/page.html"
test -s "$CAPTURE_DIRECTORY/page.html"
```

After successful completion, replace the PNG retrieval with ``await session.files.read(`${captureDirectory}/page.html`)`` and decode those bytes as UTF-8. Keep large HTML out of the command event stream. Chromium's `--timeout` caps capture waiting; it does not assert that an application has finished rendering. For a specific page state, use Playwright and wait for the relevant element. See the [Chrome Headless command-line reference](https://developer.chrome.com/docs/automation-and-testing/headless-cli).

### Click, type, and navigate with Playwright

Run the browser driver inside the VM through `session.exec()`. Reuse the project's installed Playwright when available. Otherwise install `playwright-core` with the project's package manager in a dedicated tooling directory inside the session. Discover Chromium with `command -v` as above, then pass that actual path as `CHROMIUM_PATH` in the execution's `env`. A system Chromium may be incompatible with the chosen Playwright version; report launch failures and follow the project's browser installation convention. See [Playwright browser installation](https://playwright.dev/docs/browsers).

Write a script such as `browser-task.mjs` beside that tooling directory's `package.json` using `session.files.write()`. This script runs **inside the VM**, so its file writes are remote. The following template expects `BROWSER_URL`, `CHROMIUM_PATH`, and a fresh `CAPTURE_DIRECTORY` under `session.rootDirectory`:

```js
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { chromium } from "playwright-core";

const { BROWSER_URL, CHROMIUM_PATH, CAPTURE_DIRECTORY } = process.env;
if (!BROWSER_URL || !CHROMIUM_PATH || !CAPTURE_DIRECTORY) {
  throw new Error("Set BROWSER_URL, CHROMIUM_PATH, and CAPTURE_DIRECTORY.");
}
await mkdir(CAPTURE_DIRECTORY, { recursive: true });
const browser = await chromium.launch({
  executablePath: CHROMIUM_PATH,
  headless: true,
  chromiumSandbox: true,
});
try {
  const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
  page.setDefaultTimeout(15_000);
  const response = await page.goto(BROWSER_URL, { waitUntil: "domcontentloaded" });
  if (!response || !response.ok()) throw new Error("Target page did not load successfully.");
  // Add the user's browser actions and a page-specific readiness check here.
  await page.screenshot({ path: join(CAPTURE_DIRECTORY, "screenshot.png") });
  await writeFile(join(CAPTURE_DIRECTORY, "page.html"), await page.content());
} finally {
  await browser.close();
}
```

For a page whose inspected controls have these labels, actions before the screenshot could be:

```js
await page.getByLabel("Search", { exact: true }).fill("Assemble");
await page.getByRole("button", { name: "Search", exact: true }).click();
await page.getByRole("heading", { name: "Results", exact: true }).waitFor();
```

Choose locators from the actual page. Keep dependent actions in the same browser process; separate Chromium invocations do not share a live tab. Run `node browser-task.mjs` with `session.exec({ command, cwd, env, timeoutSeconds })`, using the tooling directory as `cwd` and a timeout large enough for the whole sequence. Wait for a successful terminal result before retrieving either artifact. Reuse the binary retrieval pattern above. See [Playwright's library guide](https://playwright.dev/docs/library) and [launch options](https://playwright.dev/docs/api/class-browsertype#browser-type-launch).

Only one managed command can run per session, so finish discovery and dependency installation before launching the browser task. Close the browser in `finally`; do not background it or expose a debugging port. Keep sandboxing enabled; if it cannot launch, report the runtime requirement instead of silently adding `--no-sandbox`. Pause after the command is terminal and the requested artifacts have been retrieved, unless the user needs the session left running. On interruption, request cancellation and confirm it with `wait()` before pausing. Report which page and actions were actually verified; a command's exit code alone does not establish that the page behaved correctly.

## 8. Report the result accurately

Finish with the files changed, the exact run command, and a compact verification report. For a new integration, cover SDK loading, file write/read, command stdout and exit code, pause/reopen identity, and retained file contents. For browser work, cover the requested actions, observed page state, and retrieved screenshot or HTML paths. Include the session ID and whether it was successfully paused. Distinguish checks that passed, checks that failed, and checks blocked by missing service access. Never include credentials.

The completed integration should be ready for the user's backend to call and should preserve their existing application behavior. Publication, service deployment, account creation, and destructive cleanup are separate actions from adding this SDK integration.
