Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@
"supported-sandboxes/e2b",
"supported-sandboxes/flyio",
"supported-sandboxes/modal",
"supported-sandboxes/northflank"
"supported-sandboxes/northflank",
"supported-sandboxes/opsen"
]
},
{
Expand Down
95 changes: 95 additions & 0 deletions docs/supported-sandboxes/opsen.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
title: 'opsen'
description: 'Configure VibeKit with an opsen sandbox'
---

opsen runs agent sandboxes and proxies their model calls, so machine, model and tool spend land under one task id. You can read more about it [here](https://opsen.dev).

## Installation

First, install the opsen provider package:

```bash
npm install @vibe-kit/opsen
```

## How to use

To use opsen with VibeKit, you need to configure opsen when creating a new VibeKit instance. You can get your API key from the [opsen dashboard](https://opsen.dev/keys).

### Using the provider directly

```typescript
import { VibeKit } from "@vibe-kit/sdk";
import { createOpsenProvider } from "@vibe-kit/opsen";

const opsenProvider = createOpsenProvider({
apiKey: process.env.OPSEN_API_KEY!,
taskId: "code-review", // Groups this agent's spend
budgetUsd: 2.0, // Hard cap, enforced mid-run
});

const vibeKit = new VibeKit()
.withAgent({
type: "grok",
provider: "xai",
apiKey: process.env.XAI_API_KEY!,
model: "grok-4",
})
.withSandbox(opsenProvider);

// Generate code
const result = await vibeKit.generateCode({
prompt: "Create a simple web server",
mode: "ask"
});

// Clean up
await vibeKit.kill();
```

## Cost attribution

VibeKit takes the model provider and key separately from the sandbox, so a team running an agent today holds two bills with no shared identifier between them. opsen sits in both paths:

```typescript
await instance.cost();
// { total_usd: 0.41, compute_usd: 0.02, tokens_usd: 0.39,
// calls: 34, task_id: "code-review" }
```

`budgetUsd` is a ceiling enforced during the run — an agent that would cross it is refused mid-flight rather than found on an invoice.

The agent type is recorded as a label when the session starts, so "which agent cost what" stays answerable across grok, claude, codex, gemini and opencode.

## Configuration

| Option | Default | Description |
| --- | --- | --- |
| `apiKey` | — | Required. From opsen.dev/keys |
| `taskId` | `vibekit-{agent}` | Groups this agent's spend |
| `budgetUsd` | none | Hard cap, enforced mid-run |
| `labels` | `{}` | Arbitrary tags to group spend by |
| `runtime` | `auto` | `auto`, `e2b`, `modal`, or your own machines |
| `baseUrl` | `https://opsen.dev` | For self-hosted deployments |

## `getHost`

Port exposure depends on the runtime underneath. It works on E2B, works on Modal when the port was declared at sandbox creation, and throws with a message naming the limitation where unavailable — rather than returning a URL that does not answer.

## ENV variables and secrets

You can use environment variables for your opsen configuration:

```bash
OPSEN_API_KEY=your_opsen_api_key_here
```

Then reference them in your code:

```typescript
const opsenProvider = createOpsenProvider({
apiKey: process.env.OPSEN_API_KEY!,
taskId: "code-review",
});
```
80 changes: 80 additions & 0 deletions packages/opsen/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# @vibe-kit/opsen

Run VibeKit agents on [opsen](https://opsen.dev) — with machine, model and
tool spend on one bill and a budget enforced mid-run.

## Installation

```bash
npm install @vibe-kit/opsen
```

## Usage

```typescript
import { VibeKit } from "@vibe-kit/sdk";
import { createOpsenProvider } from "@vibe-kit/opsen";

const opsen = createOpsenProvider({
apiKey: process.env.OPSEN_API_KEY!,
taskId: "code-review",
budgetUsd: 2.0,
});

const vibeKit = new VibeKit()
.withAgent({
type: "grok",
provider: "xai",
apiKey: process.env.XAI_API_KEY!,
model: "grok-4",
})
.withSandbox(opsen);

const result = await vibeKit.generateCode({ prompt: "Fix the failing test" });
```

Get an API key at https://opsen.dev/keys

## Why another sandbox provider

Every sandbox in the current list bills for the machine and leaves the
model bill with a different vendor. VibeKit takes the model provider and
key separately from the sandbox, so a team running Grok on a sandbox holds
two bills with no shared identifier between them.

opsen sits in both paths:

```typescript
await instance.cost();
// { total_usd: 0.41, compute_usd: 0.02, tokens_usd: 0.39,
// calls: 34, task_id: "code-review" }
```

`budgetUsd` is a ceiling enforced during the run — an agent that would
cross it is refused mid-flight rather than found on an invoice.

The agent type is recorded as a label when the session starts, so "which
agent cost what" stays answerable across grok, claude, codex, gemini and
opencode.

## Configuration

| Option | Default | Description |
| --- | --- | --- |
| `apiKey` | — | Required. From opsen.dev/keys |
| `taskId` | `vibekit-{agent}` | Groups this agent's spend |
| `budgetUsd` | none | Hard cap, enforced mid-run |
| `labels` | `{}` | Arbitrary tags to group spend by |
| `runtime` | `auto` | `auto`, `e2b`, `modal`, or your own machines |
| `baseUrl` | `https://opsen.dev` | For self-hosted deployments |

## Note on `getHost`

Port exposure depends on the runtime underneath. It works on E2B, works on
Modal when the port was declared at sandbox creation, and throws with a
message naming the limitation where unavailable — rather than returning a
URL that does not answer.

## License

MIT
30 changes: 30 additions & 0 deletions packages/opsen/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@vibe-kit/opsen",
"version": "0.0.1",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.cjs"
}
},
"scripts": {
"build": "tsup src/index.ts --format esm,cjs --dts",
"dev": "tsup src/index.ts --watch",
"type-check": "tsc --noEmit"
},
"peerDependencies": {
"@vibe-kit/sdk": "*"
},
"dependencies": {
"@opsendev/opsen": "^0.1.5"
},
"devDependencies": {
"@types/node": "^22.15.18",
"tsup": "^8.4.0",
"typescript": "^5.8.3"
}
}
167 changes: 167 additions & 0 deletions packages/opsen/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { Opsen, Session } from "@opsendev/opsen";

// Declared here rather than imported from @vibe-kit/sdk, matching the
// other providers in this repo: the SDK is a peer dependency, and a
// provider that imports its types at build time cannot be compiled
// independently of it.
export interface SandboxExecutionResult {
exitCode: number;
stdout: string;
stderr: string;
}

export interface SandboxCommandOptions {
timeoutMs?: number;
background?: boolean;
onStdout?: (data: string) => void;
onStderr?: (data: string) => void;
}

export interface SandboxCommands {
run(
command: string,
options?: SandboxCommandOptions
): Promise<SandboxExecutionResult>;
}

export interface SandboxInstance {
sandboxId: string;
commands: SandboxCommands;
kill(): Promise<void>;
pause(): Promise<void>;
getHost(port: number): Promise<string>;
}

export interface SandboxProvider {
create(
envs?: Record<string, string>,
agentType?: "codex" | "claude" | "opencode" | "gemini" | "grok",
workingDirectory?: string
): Promise<SandboxInstance>;
resume(sandboxId: string): Promise<SandboxInstance>;
}

export type AgentType = "codex" | "claude" | "opencode" | "gemini" | "grok";

export interface OpsenConfig {
apiKey: string;
/** For self-hosted deployments. */
baseUrl?: string;
/** Groups this agent's spend. One id, one line on the spend page. */
taskId?: string;
/** Hard cap. A budgeted session is refused rather than spending past it. */
budgetUsd?: number;
/** Arbitrary tags to group spend by — customer, environment. */
labels?: Record<string, string>;
/** "auto" lets opsen's router choose from what the workload needs. */
runtime?: "auto" | "local" | "e2b" | "modal" | string;
}

export class OpsenSandboxInstance implements SandboxInstance {
constructor(private session: Session) {}

get sandboxId(): string {
return this.session.id;
}

get commands(): SandboxCommands {
return {
run: async (command: string, options?: SandboxCommandOptions) => {
const res = await this.session.exec(
command,
options?.timeoutMs ? Math.ceil(options.timeoutMs / 1000) : 300
);
// Called with the completed output rather than incrementally:
// opsen's exec returns a finished result, so invoking these
// keeps the callback contract without pretending the output
// streamed. Silently dropping them would be worse.
if (options?.onStdout && res.stdout) options.onStdout(res.stdout);
if (options?.onStderr && res.stderr) options.onStderr(res.stderr);
return {
exitCode: res.exitCode,
stdout: res.stdout,
stderr: res.stderr,
};
},
};
}

async kill(): Promise<void> {
await this.session.kill();
}

async pause(): Promise<void> {
await this.session.suspend();
}

async getHost(port: number): Promise<string> {
// Depends on the runtime underneath: E2B fronts every port, Modal
// needs the port declared at sandbox creation, and a local runtime
// has no public address at all. Where it is unavailable this throws
// with a message naming the runtime rather than returning a URL that
// does not answer — a dead address turns "preview my server" into a
// debugging session in someone else's framework.
return await this.session.host(port);
}

/**
* Machine, model and tool spend for this agent, under one task id.
*
* Not part of `SandboxInstance`. It is here because VibeKit takes the
* model provider and key separately from the sandbox, so a team
* running Grok on a sandbox today holds two bills with no shared
* identifier between them. opsen sits in both paths.
*/
async cost() {
return await this.session.cost();
}
}

export class OpsenSandboxProvider implements SandboxProvider {
private client: Opsen;

constructor(private config: OpsenConfig) {
this.client = new Opsen({
apiKey: config.apiKey,
baseUrl: config.baseUrl,
});
}

async create(
envs?: Record<string, string>,
agentType?: AgentType,
workingDirectory?: string
): Promise<SandboxInstance> {
const session = await this.client.createSession({
taskId: this.config.taskId ?? `vibekit-${agentType ?? "agent"}`,
budgetUsd: this.config.budgetUsd,
runtime: this.config.runtime,
// `workingDirectory` is not a session option in opsen; the sandbox
// starts in its own working directory. Passed through as an
// environment variable so an agent that needs it can read it,
// rather than accepted and silently ignored.
env: {
...(envs ?? {}),
...(workingDirectory ? { VIBEKIT_WORKDIR: workingDirectory } : {}),
},
// The agent type is recorded as a LABEL at creation. "Which agent
// cost what" is only answerable if it is written down when the
// session starts; afterwards the runs are indistinguishable.
labels: {
...(this.config.labels ?? {}),
...(agentType ? { agent: agentType } : {}),
},
});
return new OpsenSandboxInstance(session);
}

async resume(sandboxId: string): Promise<SandboxInstance> {
const session = await this.client.getSession(sandboxId);
await session.resume();
return new OpsenSandboxInstance(session);
}
}

export function createOpsenProvider(config: OpsenConfig): OpsenSandboxProvider {
return new OpsenSandboxProvider(config);
}
9 changes: 9 additions & 0 deletions packages/opsen/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}