Skip to content

Building a Provider

This page is for developers writing their own WorkspaceProvider. If you're using one of the four built-in providers, you don't need to read it.

When you'd build your own

  • The provider you need isn't in the built-in set (e.g., E2B, Modal, Daytona, your own Firecracker host).
  • You have a proprietary backing store and want to plug it into the workspace abstraction.
  • You're benchmarking a new platform.

The WorkspaceProvider<TConfig> contract

typescript
interface WorkspaceProvider<TConfig = unknown> {
  readonly providerId: string;
  open(config: TConfig, session: SessionRef): Promise<OpenedWorkspace>;
  resolve(ref: WorkspaceRef): Promise<Workspace>;
}

interface OpenedWorkspace {
  readonly ws: Workspace;
  readonly ref: WorkspaceRef;
}

interface WorkspaceRef {
  readonly providerId: string;
  readonly ref: unknown;  // your serializable payload
  readonly capabilities: WorkspaceCapabilityFlags;
}

Three required pieces:

  1. providerId — a string the registry uses to find your provider. The discriminator on WorkspaceConfig.provider.kind matches this.
  2. open(config, session) — called the first time the agent uses a workspace tool. Construct the live Workspace object + a serializable WorkspaceRef for crash recovery. Both are returned.
  3. resolve(ref) — called after a runtime boundary (DO hibernation, Temporal replay, executor restart) to reconstruct the workspace from the persisted ref.

The WorkspaceConfig discriminator

Your config type MUST have a kind field (string literal type) — the registry uses it to find your provider:

typescript
interface MyProviderConfig {
  readonly kind: 'my-provider';
  // ... your other config fields
}

The user declares it in defineAgent:

typescript
workspaces: {
  box: {
    provider: { kind: 'my-provider', /* ... your fields ... */ },
    capabilities: { fs: true },
  },
},

The lifecycle

1. User declares workspaces in defineAgent({...}).
2. Framework calls executor.execute(agent, ...).
3. Agent's first tool call hits the workspace registry.
4. Registry sees no live workspace; calls provider.open(config, session).
5. You return { ws, ref }. Live ws goes in the registry; ref is persisted.
6. Subsequent tool calls reuse the cached live ws.

[runtime boundary: DO hibernation, replay, restart]

7. Framework calls executor.resume(...) on a fresh runtime.
8. Registry sees no live workspace; calls provider.resolve(ref).
9. You reconstruct the live Workspace from the ref payload + return it.
10. Tool calls resume normally.

[session end]

11. Framework calls ws.close().

Your job: implement steps 5 and 9 (and step 11 if your workspace needs cleanup).

What goes in the ref payload

Everything resolve() needs to reconstruct the live workspace WITHOUT having the original config or session available. Typical contents:

  • The workspace's identity (id, namespace, etc.).
  • Names of bindings to look up at resolve-time (e.g., R2 bucket binding name; do NOT serialize the bucket object itself).
  • Provider-specific options that affect how the workspace was constructed (workspaceDir, sleepAfter, etc.).

What NOT to put in the ref:

  • Live objects (sandbox stubs, file handles, sockets). They don't survive serialization.
  • Secrets. Refs may be persisted to durable storage you don't fully control.
  • Anything you can re-derive from the runtime context.

Always construct all your modules

This is the biggest pattern lesson from Plans 1–4 of the built-in providers. Your provider should construct ALL the modules it can support, regardless of what capabilities the user declared. The framework's tool-injection layer reads the user's declared capabilities separately to decide which tools to inject.

Don't do this:

typescript
// ❌ WRONG — conditional on capabilities
async open(config, session) {
  const ws = new MyWorkspace();
  if (capabilities.fs) ws.fs = new MyFs();      // capabilities isn't even an arg here!
  if (capabilities.shell) ws.shell = new MyShell();
  return { ws, ref };
}

Do this:

typescript
// ✅ RIGHT — always construct all modules
async open(config, session) {
  const ws = new MyWorkspace({
    fs: new MyFs(/* ... */),
    shell: new MyShell(/* ... */),
    // ... whatever else you support
  });
  return { ws, ref };
}

Why? provider.open(config, session) doesn't receive the user's declared capabilities — only the provider config. Capabilities live separately and drive tool injection. Always-construct keeps your provider simple and predictable.

WorkspaceCapabilityFlags advertisement on the ref

You DO need to set capabilities on the returned WorkspaceRef:

typescript
const ref: WorkspaceRef = {
  providerId: this.providerId,
  ref: { /* your payload */ },
  capabilities: { fs: true, shell: true },  // what your live ws actually supports
};

This is INFORMATIONAL — the framework persists it alongside the ref for debugging and future use. It doesn't drive tool injection (the user's declared capabilities do that).

Error model

Three error types you need to know:

WorkspaceFailedError — from open() / resolve()

Throw this when the workspace cannot be created or reconstructed. The registry transitions the entry to 'failed' state — subsequent tool calls fail fast with the same error.

typescript
import { WorkspaceFailedError } from '@helix-agents/core';

async open(config, session) {
  const result = await this.connectToBackend();
  if (!result.ok) {
    throw new WorkspaceFailedError(`Backend unavailable: ${result.error}`, {
      workspaceName: 'whatever',
      cause: result.cause,
    });
  }
  // ... happy path
}

WorkspaceEvictedError — from MODULE methods

Throw this from module method implementations (not from open / resolve!) when the underlying resource has been evicted and the framework should re-resolve via resolve(ref).

typescript
import { WorkspaceEvictedError } from '@helix-agents/core';

async readFile(path) {
  try {
    return await this.backend.readFile(path);
  } catch (err) {
    if (isEvictedError(err)) {
      throw new WorkspaceEvictedError(`Backend evicted`, { workspaceName: this.id });
    }
    throw err;
  }
}

The framework's withEvictionRetry (in tool-injection.ts) catches this, marks the registry entry as 'evicted', and the next tool call invokes provider.resolve(ref) to reattach. Useful for sandboxes that auto-evict after idle, tmpdirs that get cleaned, etc.

Don't throw WorkspaceEvictedError from open() or resolve() — the registry can't handle it cleanly there. Use WorkspaceFailedError instead.

Regular Error — from MODULE methods

Anything else propagates as a tool-error message to the LLM. The LLM sees the error message, can decide whether to retry, switch approaches, or surface to the user. Use plain Error (or a subclass) for "the operation failed but the workspace itself is fine."

Testing patterns

Structural test doubles, not implements

Don't make your test fake implements ISandbox (or whatever the upstream interface is). That forces you to fill in every method, even ones you don't use. Instead, build a test double that covers only the methods your adapter calls and cast it via as unknown as TSomeInterface:

typescript
// In your test:
const fake = new FakeBackend();  // not `implements TBackend`
const provider = new MyProvider({ backend: fake as unknown as TBackend });

The cast is local, explicit, and only applies at the boundary. If your adapter starts using a new method, the test fails with a clear "method not implemented" error from the fake, prompting you to add it.

Reference: FakeSandbox from runtime-cloudflare

The @helix-agents/runtime-cloudflare/testing subpath exports FakeSandbox, an in-memory ISandbox subset used by CloudflareSandboxWorkspaceProvider's tests. It's a good worked example — covers fs (Map-backed), exec/code (canned responses), backups (Map-backed). About 600 lines.

Worked example: MyProvider

A minimal provider wrapping a Map-backed filesystem. Demonstrates the full contract.

typescript
// my-provider.ts
import type {
  OpenedWorkspace,
  SessionRef,
  Workspace,
  WorkspaceProvider,
  WorkspaceRef,
  WorkspaceId,
  FileSystem,
  FileEntry,
  FileStat,
  GrepOptions,
  GrepResult,
} from '@helix-agents/core';

// 1. Config type with discriminator.
export interface MyProviderConfig {
  readonly kind: 'my-provider';
  /** Optional: scope for naming inside your backend. */
  readonly namespace?: string;
}

// 2. The fs adapter.
class MyFileSystem implements FileSystem {
  constructor(private readonly files: Map<string, Uint8Array>) {}

  async readFile(path: string): Promise<Uint8Array> {
    const bytes = this.files.get(path);
    if (!bytes) throw new Error(`MyFileSystem: file not found: ${path}`);
    return bytes;
  }

  async writeFile(path: string, data: Uint8Array | string): Promise<void> {
    const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
    this.files.set(path, bytes);
  }

  async stat(path: string): Promise<FileStat> {
    const bytes = this.files.get(path);
    if (!bytes) throw new Error(`MyFileSystem: not found: ${path}`);
    return { path, type: 'file', size: bytes.length };
  }

  async ls(path: string): Promise<FileEntry[]> {
    const prefix = path.endsWith('/') ? path : path + '/';
    return Array.from(this.files.keys())
      .filter((k) => k.startsWith(prefix))
      .map((k) => ({
        name: k.slice(prefix.length).split('/')[0],
        path: k,
        type: 'file' as const,
        size: this.files.get(k)!.length,
      }));
  }

  async glob(pattern: string): Promise<string[]> {
    const re = new RegExp(pattern.replace(/\*/g, '.*'));
    return Array.from(this.files.keys()).filter((k) => re.test(k));
  }

  async grep(pattern: string, opts?: GrepOptions): Promise<GrepResult[]> {
    const re = new RegExp(pattern, opts?.ignoreCase ? 'i' : '');
    const decoder = new TextDecoder();
    const out: GrepResult[] = [];
    for (const [path, bytes] of this.files) {
      if (opts?.path && !path.startsWith(opts.path)) continue;
      const lines = decoder.decode(bytes).split('\n');
      for (let i = 0; i < lines.length; i++) {
        if (re.test(lines[i])) {
          out.push({ path, lineNumber: i + 1, line: lines[i] });
          if (opts?.maxResults && out.length >= opts.maxResults) return out;
        }
      }
    }
    return out;
  }

  async rm(path: string): Promise<void> {
    if (!this.files.delete(path)) throw new Error(`MyFileSystem: not found: ${path}`);
  }

  async mkdir(): Promise<void> {
    // Implicit — directories aren't tracked separately in this toy impl.
  }
}

// 3. The Workspace aggregator.
class MyWorkspace implements Workspace {
  readonly id: WorkspaceId;
  readonly fs: FileSystem;

  constructor(id: string, fs: FileSystem) {
    this.id = id as WorkspaceId;
    this.fs = fs;
  }

  async close(): Promise<void> {
    // No-op — Map garbage-collects when references drop.
  }
}

// 4. The provider.
export class MyProvider implements WorkspaceProvider<MyProviderConfig> {
  readonly providerId = 'my-provider';

  // External-storage backing — keyed by namespace so resolve() reattaches.
  private static stores = new Map<string, Map<string, Uint8Array>>();

  async open(config: MyProviderConfig, session: SessionRef): Promise<OpenedWorkspace> {
    const namespace = config.namespace ?? session.sessionId;
    let store = MyProvider.stores.get(namespace);
    if (!store) {
      store = new Map();
      MyProvider.stores.set(namespace, store);
    }
    const fs = new MyFileSystem(store);
    const ws = new MyWorkspace(namespace, fs);
    const ref: WorkspaceRef = {
      providerId: this.providerId,
      ref: { namespace },
      capabilities: { fs: true },
    };
    return { ws, ref };
  }

  async resolve(ref: WorkspaceRef): Promise<Workspace> {
    if (ref.providerId !== this.providerId) {
      throw new Error(`MyProvider: refusing to resolve foreign provider ref`);
    }
    const payload = ref.ref as { namespace?: string } | undefined;
    if (!payload?.namespace) {
      throw new Error(`MyProvider: ref payload missing namespace`);
    }
    let store = MyProvider.stores.get(payload.namespace);
    if (!store) {
      // Could throw here if you want to fail; or auto-create as we do.
      store = new Map();
      MyProvider.stores.set(payload.namespace, store);
    }
    const fs = new MyFileSystem(store);
    return new MyWorkspace(payload.namespace, fs);
  }
}

Wire it like any other provider:

typescript
const executor = new JSAgentExecutor(/* ... */, {
  workspaceProviders: new Map([
    ['my-provider', new MyProvider()],
  ]),
});

Reference: existing providers

Read these for full real-world examples:

Source references

Released under the MIT License.