When It Rains Agents, Bring a mainbrella · Mainbrella

7 min read Original article ↗
← Engineering blog

· Andrew Arrow

An agent writes a script, tries to run it, and gets “file not found.” Both tools worked; they were looking at different disks. That’s a stupid failure to hand back to a model and ask it to debug. Mastra’s workspace interface lets us connect file tools and command execution to the same Mainbrella Linux machine.

The @mastra/mainbrella package in our Mastra fork does that wiring. It supplies a sandbox, an HTTP filesystem, and a provider for Mastra’s editor. The examples here use that October 8 checkout, which still has a path bug and a command-history limit we’ll get to.

Like our Deep Agents adapter, this keeps the framework’s tools and changes where they run. Mastra also separates a running job from its output connection and input pipe. That lets us recover a dropped output stream without starting the command again.

Three mechanical assistants collaborate beneath one umbrella: one supplies tabular input, one operates a calculating machine, and one inspects its printed report. All use the same workbench.

Two tools, one disk

Mastra is a TypeScript framework for agent applications. Its workspace gives an agent file operations and command execution, enough to inspect input, write a reporting script, run it, and fix whatever fails. The model uses Mastra’s tools without needing to know Mainbrella’s API.

Pass the same sandbox object to the filesystem and the workspace. File writes then go to the machine that runs the commands, without an object-storage mount or a second copy to synchronize:

import { Workspace } from '@mastra/core/workspace';
import {
  MainbrellaFilesystem,
  MainbrellaSandbox,
} from '@mastra/mainbrella';

const sandbox = new MainbrellaSandbox({ catalogId: 'node' });
const filesystem = new MainbrellaFilesystem({ sandbox });
const workspace = new Workspace({ sandbox, filesystem });

Both default to /workspace, but their path syntax differs. The filesystem API accepts paths relative to that root, while command arguments use Linux paths:

With /workspace as the command’s working directory, node report.cjs sees the file written as report.cjs. If you choose another filesystem basePath, set the sandbox’s workingDirectory to match and initialize the writable filesystem before running commands there.

This commit gets one part of that wrong: stat('report.txt') returns /workspace/report.txt, but handing that value back to readFile() looks under /workspace/workspace/report.txt. The path advertised by metadata should be usable by the file tools. For now, keep filesystem calls in the workspace-relative form shown above.

Reconnect to the job that’s already running

Suppose the reporting script prints its first progress message, then the output connection drops. Rerunning it might append the same rows twice. We need the next output from the job that’s already running.

The process manager starts a managed Mainbrella execution and keeps its execution ID. Output events carry increasing sequence numbers. When a stream fails, the adapter can reopen it from the SDK’s last received cursor, while the original command continues. It retries eligible stream failures up to three times; it never submits a second command to repair a failed output connection.

A client starts job J once, receives output event 1, and loses its stream. Job J keeps running and records event 2 during the gap. The client reconnects with cursor 1 and receives event 2 and completion from the same job.

In Mastra, executeCommand() uses this process manager and waits for completion. A background process gives you a handle sooner, so you can inspect output, cancel it, or send stdin. Its pid is a Mainbrella execution UUID, not an operating-system PID. An input pipe also needs an end: after feeding cat, for example, call closeStdin() so it can finish.

The result includes stdout, stderr, an exit code, and a success flag. A timeout maps to exit code 124; cancellation maps to 137. Interruption and output-limit termination also report failure. The agent can see how far the program got without treating a progress message as a finished report.

Reconnect output

Known execution ID, last received cursor. Read the next events from that job.

Repeat a side effect

Unknown command or stdin outcome. Sending it again may do the work twice; the adapter surfaces the error for reconciliation.

Keeping those results costs a job record: Mainbrella retains 32 execution records per container for one hour from admission. This adapter uses a managed job even for a short, ordinary executeCommand() call. Finishing the command releases Mastra’s local handle, but keeps the server’s record. Run 32 commands before any records expire and command 33 is rejected with execution_history_limit.

A coding agent can spend 32 commands just getting acquainted with a repository. Using a retained job for every command is a bad fit for a long editing session, and this version needs a better execution strategy. Restarting the machine to clear its history would lose the files along with the records.

Remember which machine did the work

Mastra’s sandbox.id names the adapter in your application. To find the remote machine, Mainbrella needs both id and createdAt. Container slots get reused, so the creation timestamp tells this run apart from whatever occupies the slot later.

The sandbox saves that pair after startup and uses it for files, jobs, reconnects, and cleanup. Attachment fails if that generation has stopped or been replaced. Resuming on another disk would leave the agent reasoning about a report it can’t reach.

Before a machine exists, save a creationKey and the creation options if you need recovery across application restarts. If the reply disappears, a retry with that key and those options can find the original creation within the 24-hour window. Generating another key asks for another start. The lost-reply article follows how the backend keeps track of the first attempt.

A static workspace also points every agent using it at the same machine. The HTTP filesystem queues its own writes, but it can’t order writes from a shell or another adapter. Its read-only flag only restricts file tools; an enabled shell can still write. If users need separate files or permissions, arrange that across both routes. Checking “read-only” on half the tools won’t do it.

Try a small report before adding a model

Use the file tools to write input, process it inside Linux, then read the result through the file tools. With the objects above, this example should print total=42:

try {
  await workspace.init();
  await filesystem.writeFile('input.json', '[12, 17, 13]');
  await filesystem.writeFile('report.cjs', `
    const fs = require('node:fs');
    const values = JSON.parse(fs.readFileSync('input.json', 'utf8'));
    const total = values.reduce((sum, value) => sum + value, 0);
    fs.writeFileSync('report.txt', 'total=' + total + '\\n');
  `);

  const result = await sandbox.executeCommand('node', ['report.cjs']);
  if (!result.success) throw new Error(result.stderr || 'Report failed');
  console.log(await filesystem.readFile('report.txt', { encoding: 'utf8' }));
} finally {
  await workspace.destroy();
}

From a checkout of the linked fork, run pnpm --filter @mastra/mainbrella build. Save the two code blocks together as workspaces/mainbrella/report-demo.mjs, or download the complete example, then run node report-demo.mjs from that directory with MAINBRELLA_API_KEY configured. The account needs an active compute allowance.

For a local Mainbrella service, set MAINBRELLA_API_URL=http://localhost:8787 and use that service’s credential. The key belongs to the application making API calls; it doesn’t need to enter the guest’s command environment.

The example shows the intended flow; we haven’t run it with a model or qualified this adapter against a live service for this article. Review used the fake HTTP API, where we reproduced the metadata-path bug and the rejection at the retained-history limit. Passing those unit tests leaves the live integration work to do.

The example reads the report before cleanup because workspace.destroy() stops this statically configured sandbox and discards its unsaved files. Saving a conversation doesn’t save that disk. Individual HTTP file transfers are limited to 1 MiB; managed output is bounded to 1 MiB, and jobs have a maximum 15-minute timeout within the machine’s lease. Export the finished artifact or explicitly save a workspace through sandbox.mainbrella if it needs to survive.

After that example works against your service, try the same small task with a Mastra agent and check its total. Keep the session short until the execution strategy is fixed; even pwd takes one of those 32 records.