Freestyle Docs

Freestyle / Guides

How to Give Your Mastra Agent a Sandbox

Give a Mastra agent a fresh Linux VM to work in by implementing Mastra's Workspace sandbox interface on Freestyle — then watch a computer-use agent inspect the machine, write code, and run it. Bring a model API key.

Mastra agents run shell commands in a Workspace sandbox — the isolated machine the agent drives. This guide gives your Mastra agent that machine on Freestyle: implement the sandbox interface against a Freestyle VM, and the agent gets a fresh, disposable Linux box — a “computer-use” loop that inspects the system, writes code, and runs it.

Requirements

  • A Freestyle API key — to create the VM.
  • A model API key — the agent needs an LLM (e.g. OPENAI_API_KEY for openai/gpt-5.5). The sandbox itself works without one; the agent loop doesn’t.
  • Node.js 20+.

Install

pnpm add @mastra/core freestyle
bun add @mastra/core freestyle
npm install @mastra/core freestyle
yarn add @mastra/core freestyle
export FREESTYLE_API_KEY="your-api-key"
export OPENAI_API_KEY="sk-..."   # or whichever provider your model uses

Implement a Freestyle Sandbox

Mastra’s MastraSandbox base class wants four things — id, name, provider, status — plus a start() that boots the machine, a destroy() that tears it down, and an executeCommand() that runs a command and returns a CommandResult. Back each one with the Freestyle SDK: start() creates a VM, executeCommand() runs through vm.exec(), destroy() deletes it.

import { MastraSandbox } from "@mastra/core/workspace";
import type { CommandResult, ExecuteCommandOptions, ProviderStatus } from "@mastra/core/workspace";
import { freestyle } from "freestyle";

type Vm = Awaited<ReturnType<typeof freestyle.vms.create>>["vm"];
type CreateOptions = NonNullable<Parameters<typeof freestyle.vms.create>[0]>;

// POSIX single-quote so args/values with spaces survive the shell.
const sh = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;

export class FreestyleSandbox extends MastraSandbox {
  readonly id = `freestyle-${crypto.randomUUID().slice(0, 8)}`;
  readonly name = "FreestyleSandbox";
  readonly provider = "freestyle";
  status: ProviderStatus = "pending";

  // Public on purpose: reach the full Freestyle SDK — fork, snapshot, VPC, PTY —
  // straight off the wrapper (see "Beyond the Wrapper" below).
  vm?: Vm;
  private _vmId?: string;

  // Pass through any vms.create options — snapshotId, nics (VPC), name, etc.
  constructor(private readonly options: CreateOptions = {}) {
    super({ name: "FreestyleSandbox" });
  }

  // Boot a fresh VM. The base class handles the status transitions.
  async start(): Promise<void> {
    const { vm, vmId } = await freestyle.vms.create(this.options);
    this.vm = vm;
    this._vmId = vmId;
  }

  // Run a command in the VM and map it to Mastra's CommandResult.
  async executeCommand(
    command: string,
    args: string[] = [],
    options: ExecuteCommandOptions = {},
  ): Promise<CommandResult> {
    await this.ensureRunning(); // lazily calls start() if needed

    let line = [command, ...args.map(sh)].join(" ");
    if (options.env) {
      const exports = Object.entries(options.env)
        .filter(([, v]) => v != null)
        .map(([k, v]) => `${k}=${sh(String(v))}`)
        .join(" ");
      if (exports) line = `${exports} ${line}`;
    }
    if (options.cwd) line = `cd ${sh(options.cwd)} && ${line}`;

    const startedAt = Date.now();
    const res = await this.vm!.exec({ command: line, timeoutMs: options.timeout });
    const exitCode = res.statusCode ?? 0;
    return {
      success: exitCode === 0,
      exitCode,
      stdout: res.stdout ?? "",
      stderr: res.stderr ?? "",
      executionTimeMs: Date.now() - startedAt,
      command,
      args,
    };
  }

  async destroy(): Promise<void> {
    if (this._vmId) await freestyle.vms.delete({ vmId: this._vmId });
    this.vm = undefined;
    this._vmId = undefined;
  }

  getInstructions(): string {
    return "You control a fresh Debian Linux VM via execute_command. Run shell commands to inspect the system, install packages, and write and run code.";
  }
}

This implements only execute_command (foreground). Mastra’s get_process_output / kill_process (background processes) need a process manager; without one the runtime returns a clear “feature not supported” error. For long-running processes, stream them with the PTY API.

Try It Without an Agent

Drop the sandbox into a Workspace and call it directly — no LLM needed — to confirm the Freestyle wiring before you spend tokens:

import { Workspace } from "@mastra/core/workspace";
import { FreestyleSandbox } from "./freestyle-sandbox";

const workspace = new Workspace({ sandbox: new FreestyleSandbox() });
await workspace.init(); // boots the VM

const uname = await workspace.sandbox!.executeCommand!("uname", ["-a"]);
console.log(uname.stdout.trim()); // Linux … 6.1.0-15-freestyle … x86_64

const cwd = await workspace.sandbox!.executeCommand!("pwd", [], { cwd: "/tmp" });
console.log(cwd.stdout.trim()); // /tmp

await workspace.destroy(); // deletes the VM

executeCommand returns { success, exitCode, stdout, stderr, executionTimeMs }, with cwd, env, and timeout honored.

Build the Computer-Use Agent

Give the workspace to a Mastra Agent. Mastra automatically exposes execute_command as a tool, so the model can drive the VM — read the system, write a script, run it — to satisfy a prompt. The VM is created on the first command.

import { Agent } from "@mastra/core/agent";
import { Workspace } from "@mastra/core/workspace";
import { FreestyleSandbox } from "./freestyle-sandbox";

const workspace = new Workspace({ sandbox: new FreestyleSandbox() });

const agent = new Agent({
  id: "computer-use",
  model: "openai/gpt-5.5", // any model Mastra supports; set that provider's key
  instructions:
    "You operate a fresh Linux VM. Use execute_command to run shell commands — " +
    "inspect the system, install tools, write and run code — to accomplish the task.",
  workspace,
});

const result = await agent.generate(
  "What OS, kernel, and CPU count does your machine have? Then write a Python " +
    "script that prints the first 10 primes and run it.",
);
console.log(result.text);

await workspace.destroy(); // delete the VM when the session is done

The agent calls execute_command itself — uname/nproc, then cat > primes.py and python3 primes.py — all inside the Freestyle VM, and summarizes what it found.

A Sandbox per User

In a multi-tenant app, give each user their own VM by passing a resolver instead of a single instance. Mastra calls it per request, and the caller owns the returned sandbox’s lifecycle:

const workspace = new Workspace({
  sandbox: ({ requestContext }) => {
    const userId = requestContext.get("user-id") as string;
    // look up or lazily create this user's snapshot, then boot from it
    return new FreestyleSandbox({ snapshotId: snapshotFor(userId) });
  },
});

Pair this with a snapshot of each user’s prepared environment and Freestyle boots a ready-to-use machine per session.

Beyond the Wrapper

Mastra’s interface only needs executeCommand — but the sandbox is a real Freestyle VM, and we kept its handle public as sandbox.vm. Everything the Freestyle SDK can do is still there, none of which Mastra’s abstraction models: forking the VM, snapshots, putting it on a VPC, mapping a domain, or opening a PTY. Reach for them straight off the wrapper.

For example, let an agent prepare an environment once, then fork that VM into N copies — each inherits the parent’s disk and memory, so the setup is already done in every one — to fan work out in parallel:

import { Workspace } from "@mastra/core/workspace";
import { FreestyleSandbox } from "./freestyle-sandbox";

const sandbox = new FreestyleSandbox();
const workspace = new Workspace({ sandbox });
await workspace.init();

// Prepare the environment once (clone a repo, install deps, …).
await sandbox.executeCommand("git", ["clone", "https://github.com/acme/app", "."], { cwd: "/root" });
await sandbox.executeCommand("npm", ["install"], { cwd: "/root" });

// Fan the prepared VM out into 5 ready-to-go copies.
const { forks } = await sandbox.vm!.fork({ count: 5 });

const results = await Promise.all(
  forks.map(({ vm }, i) =>
    vm.exec({ command: `npm test -- --shard=${i + 1}/${forks.length}`, timeoutMs: 300_000 }),
  ),
);

Because the constructor passes its options straight to vms.create, networking is just as direct — boot the agent’s sandbox on a private VPC by handing it a NIC:

import { freestyle } from "freestyle";

const { vpcId } = await freestyle.vpc.create({ cidr: "192.168.10.0/24" });

const sandbox = new FreestyleSandbox({
  nics: [{ default: true, vpc: vpcId, mode: "routed", ipv4: "192.168.10.10" }],
});

The Mastra wrapper gives the agent a clean execute_command; sandbox.vm gives you the whole platform underneath it.

esc