froog / Integrations

Mastra

Give a Mastra agent a Linux machine of its own. Three tools made with createTool run commands and read and write files in a froog sandbox, a full Ubuntu VM with sudo, Docker and internet.

Install

Node.js 22.13 or newer (Mastra needs it). The froog SDK is one TypeScript file that uses fetch: download it next to your code, as the SDK docs say.

$ npm init -y && npm pkg set type=module
$ npm install @mastra/core zod
$ npm install --save-dev typescript @types/node tsx
$ curl -fsSLO https://froog.tech/sdk/froog.ts   # the froog client: one file, uses fetch
$ export OPENAI_API_KEY=...        # your model provider's key
$ export FROOG_API_KEY=froog_...   # dashboard, API keys; or: froog api-key create agents --preset ci

A complete example

Save as agent.ts next to froog.ts and run npx tsx agent.ts. The model string picks the provider; openai/... reads OPENAI_API_KEY.

import { Agent } from "@mastra/core/agent";
import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import { Froog, FroogError } from "./froog";

const froog = new Froog({ apiKey: process.env.FROOG_API_KEY });
const LIMIT = 20_000; // characters of output handed back to the model

// Make sure the sandbox `name` is running: create it, or start it if it was stopped.
async function openSandbox(name: string): Promise<string> {
  let state: string;
  try {
    state = (await froog.getSandbox(name)).state;
  } catch (e) {
    if (!(e instanceof FroogError) || e.status !== 404) throw e;
    await froog.createSandboxes({
      name,
      size: "small",       // 2 vCPU, 4 GB
      ttl_secs: 1800,      // auto-stop 30 minutes after it starts; the disk is kept
      environment: false,  // none of your account's variables or files
      labels: { app: "mastra-demo" },
    });
    state = "creating";
  }
  if (state === "stopping") state = (await froog.waitUntil(name, "stopped")).state;
  if (state === "stopped") await froog.startSandbox(name, {});
  await froog.waitUntil(name, "running");
  return name;
}

const clip = (s: string) => (s.length <= LIMIT ? s : "[...cut...]\n" + s.slice(-LIMIT));

// Tools bound to one froog sandbox.
function sandboxTools(sandbox: string) {
  const runCommand = createTool({
    id: "run-command",
    description:
      "Run a bash command in your Linux sandbox (Ubuntu 24.04, passwordless sudo, Docker, internet). " +
      "The working directory is /home/user. Returns the exit code, stdout and stderr.",
    inputSchema: z.object({
      command: z.string().describe('The bash command to run, e.g. "python3 app.py"'),
      timeoutSecs: z.number().int().min(1).max(3600).optional().describe("Seconds before it is killed (default 300)"),
    }),
    outputSchema: z.object({ exitCode: z.number(), timedOut: z.boolean(), stdout: z.string(), stderr: z.string() }),
    execute: async ({ command, timeoutSecs }) => {
      const r = await froog.exec(sandbox, { command, timeout_secs: timeoutSecs ?? 300 });
      return { exitCode: r.exit_code, timedOut: r.timed_out ?? false, stdout: clip(r.stdout), stderr: clip(r.stderr) };
    },
  });

  const writeFile = createTool({
    id: "write-file",
    description: "Create or overwrite a text file in the sandbox. Parent folders are created.",
    inputSchema: z.object({
      path: z.string().describe("Absolute, or relative to /home/user"),
      content: z.string().describe("The whole file"),
    }),
    outputSchema: z.object({ path: z.string(), characters: z.number() }),
    execute: async ({ path, content }) => {
      await froog.writeFile(sandbox, content, { path });
      return { path, characters: content.length };
    },
  });

  const readFile = createTool({
    id: "read-file",
    description: "Read a text file from the sandbox.",
    inputSchema: z.object({ path: z.string().describe("Absolute, or relative to /home/user") }),
    outputSchema: z.object({ content: z.string() }),
    execute: async ({ path }) => ({
      content: clip(new TextDecoder().decode(await froog.readFile(sandbox, { path }))),
    }),
  });

  return { runCommand, writeFile, readFile };
}

// One agent per conversation, with tools bound to that conversation's sandbox.
function codingAgent(sandbox: string) {
  return new Agent({
    id: "coding-agent",
    name: "Coding Agent",
    instructions: "You have a Linux sandbox. Use your tools to write and run code, and check results by running them.",
    model: "openai/gpt-6.1-sol",
    tools: sandboxTools(sandbox),
  });
}

const sandbox = await openSandbox("chat-demo");
try {
  const result = await codingAgent(sandbox).generate(
    "Write primes.py that prints the first 20 primes, run it, and tell me the output.",
    { maxSteps: 20 },
  );
  console.log(result.text);
} finally {
  await froog.deleteSandbox(sandbox); // or froog.stopSandbox(sandbox) to keep the disk
}

How it works

  • openSandbox gets the conversation's sandbox running: it creates it (a small Ubuntu VM, ready in under a minute), or starts it if it was stopped, and waits for running.
  • Three tools bound to that sandbox: run a command (POST /v1/sandboxes/{name}/exec, up to an hour, output and exit code back), write a file and read a file (PUT and GET /v1/sandboxes/{name}/files/content). Output is cut to the last 20,000 characters so a noisy build doesn't fill the context window.
  • Cleanup in finally: the example deletes the sandbox. In a real app, stop it or let its auto-stop timer stop it (see the notes).
  • An agent per conversation. codingAgent(sandbox) builds the agent with tools bound to that conversation's sandbox. Agents are cheap to build.

Notes

  • One sandbox per conversation or user. Name it after the conversation, thread or user id (chat-42, user-7: 1–40 lowercase letters, digits and dashes) so every turn lands in the same machine with the same files. openSandbox above creates it the first time and starts it again if it was stopped. Labels ("labels": {"user": "7"}) let you find a user's sandboxes later with GET /v1/sandboxes?label=user=7.
  • Stop instead of delete when the user may come back. The example's ttl_secs: 1800 stops the sandbox 30 minutes after it starts, busy or not (PATCH a new ttl_secs to push it back while the conversation goes on). Its disk is kept for 14 days, a start takes about 15–30 seconds (about a second within 10 minutes of the stop), and installed packages and files are still there. Delete it when the conversation is over for good.
  • Cost. Running time is billed per second, only while the sandbox runs: small (2 vCPU, 4 GB) is $0.0137 an hour, default (4 vCPU, 8 GB) $0.0274. A 10-minute conversation on small costs about a quarter of a cent; the idle time before the TTL stops it counts as running time, so pick a TTL that matches how long users pause. A stopped sandbox costs no running time. See pricing.
  • Templates. Install what your agent needs once (Python packages, a repo, a database), then save it: froog snapshot my-box --name agent-base. Create each conversation's sandbox with "from": "agent-base" and it starts with all of it, instead of the agent spending its first turns on pip install. See snapshots.
  • Keep your secrets out. "environment": false starts the sandbox without your account's environment variables, repos and secret files, since the model runs whatever it likes in there. Pass only what that sandbox needs in env. Use a scoped API key for your backend (froog api-key create agents --preset ci can only touch the sandboxes it created) and never send it to a browser. See API keys.
  • More tools. The same pattern covers the rest of the API: POST /sandboxes/{name}/urls (hostPort) gives a running web server a public URL the agent can return to the user, startJob runs servers and long builds in the background, execStream streams output as it arrives, and forkSandbox copies a sandbox to try several approaches at once. Every call is in the HTTP API.
  • Registered agents. If you register one agent in a Mastra instance (for mastra dev or its server), the tools can't close over a sandbox name. Read the conversation's sandbox from the request context, the second argument of execute, instead.

Questions

Do the tools work in Mastra workflows too?

Yes. They're ordinary Mastra tools, so a workflow step can call them, or an agent inside the step can. Keep the sandbox name in the workflow's state.

Which Node version do I need?

@mastra/core asks for Node.js 22.13 or newer. The froog SDK itself runs on Node 18+, Deno, Bun or a browser.

7 days free: 25 hours of running time, 2 sandboxes at once. Then from $0.0137 an hour, billed per second.