Concepts

Refs & state

Capture exec output and thread values between steps using ExecResult and the workflow builder's vars.

There's no special Ref<T> type. Output from one step is just a TypeScript variable — you capture it from the ExecResult and pass it to the next step however you like.

Capturing stdout

await sb.exec(cmd) returns { stdout, stderr, exitCode }. Use stdout directly in subsequent commands:

const sb = await client.sandbox({
  image: "node:20-slim",
  resources: { cpu: "500m", memory: "256Mi" },
});
try {
  const { stdout: nodeVersion } = await sb.exec('node -e "process.stdout.write(process.version)"');

  // Use the captured value in the next exec
  await sb.exec(`echo "Running on Node ${nodeVersion.trim()}"`).pipe(process.stdout);

  // Or write it to a file
  await sb.writeFile(
    "/tmp/info.json",
    JSON.stringify({ node: nodeVersion.trim(), capturedAt: new Date().toISOString() }),
  );
} finally {
  await sb.close();
}

Reading files as state

sb.readFile(path) returns the file content as a string:

await sb.exec("node -e \"require('fs').writeFileSync('/tmp/version.txt', process.version)\"");
const version = await sb.readFile("/tmp/version.txt");
console.log(version.trim()); // "v20.x.x"

State in the workflow builder

When using @alineo-labs/workflow, the SandboxBuilder doesn't return values from exec() — steps are queued, not executed yet. Use readFile(path, as) to capture a file into the vars map, then read from vars after the workflow resolves:

import { workflow } from "@alineo-labs/workflow";

const { vars } = await workflow(client)
  .sandbox({ image: "node:22", resources: { cpu: "500m", memory: "256Mi" } }, (sb) => {
    sb.exec("node --version > /tmp/version.txt");
    sb.readFile("/tmp/version.txt", "nodeVersion"); // captured into vars["nodeVersion"]
  })
  .result();

console.log(vars.nodeVersion); // "v22.x.x\n"

The when predicate also receives runtime state:

sb.exec("test -f /etc/hostname", { strict: false });
sb.when(
  (ctx) => ctx.exitCode === 0, // ctx.exitCode, ctx.stdout, ctx.vars
  (sb) => sb.exec("echo exists"),
  (sb) => sb.exec("echo missing"),
);

ctx.stdout is the accumulated stdout from all execs in the sandbox so far. ctx.vars holds anything captured via readFile(..., as).

Passing state between sandboxes

In .sequence(), each stage receives the previous stage's WorkflowResult:

await workflow(client)
  .sequence([
    {
      image: "node:22",
      name: "build",
      resources: { cpu: "1", memory: "512Mi" },
      run: (sb) => {
        sb.exec("npm run build 2>&1 | tee /tmp/build.log");
        sb.readFile("/tmp/build.log", "buildLog");
      },
    },
    {
      image: "ubuntu:22.04",
      name: "deploy",
      resources: { cpu: "500m", memory: "256Mi" },
      run: (sb, prev) => {
        // prev.vars.buildLog from the first sandbox
        sb.exec(`echo "Build output lines: $(echo '${prev?.vars.buildLog}' | wc -l)"`);
      },
    },
  ])
  .pipe(process.stdout);