Review pull requests in a sandbox you own
Composio usually runs your tools for you. A local sandbox is for the times you need to run them yourself: your filesystem, your shell, your security boundary. You still get managed auth and 1000+ apps; you just keep the code execution.
This example builds a GitHub PR reviewer that does exactly that: it clones a pull request into a sandbox you own, runs the repo's real checks there, and posts one grounded comment. The sandbox here is E2B, but E2B is just the sample. The same pattern works with your own VM, container, Kubernetes job, or internal sandbox service.
It comes down to a handful of Composio pieces:
- A local sandbox session is a Composio session with code execution turned off. Composio still does discovery and auth; it just won't run code for you.
- The helper contract is what comes back: a Python helper exposing the same
run_composio_tool,invoke_llm, andweb_searchtools Composio's managed sandbox runs for you, plus theenvit needs. You inject it into your sandbox and the agent calls it. - Your sandbox is the boundary. Tool execution happens in a box you control. E2B is the replaceable sample runner; the contract it honors is the real interface.
The sandbox holds your project API key
The env that experimental_createLocalWorkbenchSession returns includes your project COMPOSIO_API_KEY, and you inject that env into the sandbox. Anything running there can read it, including the untrusted PR code you clone and build. Treat the sandbox as your trust boundary: run it on infrastructure you control, give the reviewer a key scoped to only what it needs, and rotate the key if a run could have leaked it.
Creates the session with workbench.enable: false, then starts a sandbox you own.
helper + env sandbox
Clones the PR, installs deps, runs the repo's real checks, all inside your boundary.
run_composio_tool Composio
Resolves the right GitHub action, runs it under the user's connection, and returns the result to the sandbox.
- · search + schema discovery
- · managed GitHub auth
- · tool execution + result
Below you build the host orchestration from scratch: a bare client first, then a piece at a time up to the full run loop, then a browse of the real source. You bring a Composio API key and a place to run code. Composio brings the tools.
Setup
You need a Composio API key, an OpenAI API key for the reviewer agent, a GitHub connection for your COMPOSIO_USER_ID, and Bun.
bun add @composio/core e2b @openai/agentsConnect GitHub once for the user id you'll review as, then keep that same id for the review run:
bun run connectBuild the host
src/runner.ts is the host: it owns orchestration, never tool execution. It starts as a bare Composio client and grows into the full run loop, one concept at a time. Each diff below is exactly what that concept adds.
Create the Composio client
The whole thing acts as one stable user, against the connections they own. Start there.
123456import { Composio } from '@composio/core';import { experimental_createLocalWorkbenchSession } from '@composio/core/experimental';import { createE2bSandbox } from './sandbox/e2b';
const composio = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });const userId = process.env.COMPOSIO_USER_ID ?? 'local-pr-reviewer-user';Check the GitHub connection
A local sandbox still leans on Composio for auth and tool discovery; only code execution moves to your side. So before booting any infrastructure, confirm this user actually has GitHub connected, and hand them a connect link if not.
2 unmodified lines345678910111213141516171819202 unmodified linesimport { createE2bSandbox } from './sandbox/e2b';
const composio = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });const userId = process.env.COMPOSIO_USER_ID ?? 'local-pr-reviewer-user';
// Composio runs tools as a user. Before anything else, make sure this user has// an active GitHub connection. There's no point booting a sandbox without one.async function requireGithubConnection() { const list = await composio.connectedAccounts.list({ userIds: [userId], toolkitSlugs: ['github'], statuses: ['ACTIVE'], }); if (list.items?.[0]) return;
const request = await composio.toolkits.authorize(userId, 'github'); throw new Error(`Connect GitHub first: ${request.redirectUrl}`);}Create the local sandbox session
The core of the integration. You create a Composio session yourself with code execution off (workbench.enable: false, so Composio will not run code for you), then hand that session to experimental_createLocalWorkbenchSession. The helper validates the session is local (it errors if the session has the remote workbench enabled, because the managed workbench and a local sandbox can't both run for one session) and returns the pieces you run yourself: a helperSource (a Python helper with run_composio_tool, invoke_llm, and web_search) and the env that helper needs to reach Composio from inside your box.
16 unmodified lines1718192021222324252627282930313233343516 unmodified lines
const request = await composio.toolkits.authorize(userId, 'github'); throw new Error(`Connect GitHub first: ${request.redirectUrl}`);}
// The local sandbox session. Create a normal Composio session yourself with// `workbench.enable: false` (Composio won't run code for you), then hand that// session to the helper, which validates it's local and gives you the pieces to// run code yourself, wherever you choose.async function createWorkbench() { const session = await composio.create(userId, { toolkits: ['github'], workbench: { enable: false }, }); return experimental_createLocalWorkbenchSession(composio, session); // returns { helperSource, env }: // helperSource: a Python helper exposing run_composio_tool / invoke_llm / web_search // env: the variables that helper needs to reach Composio from inside your box}Start your sandbox, inject the helper
Boot a box you control, write helperSource into it as composio_helper.py, and pass env to the process. That helper is the only Composio-specific thing your sandbox has to carry. E2B is the sample runner; swap it for anything that honors the same contract.
31 unmodified lines32333435363738394041424344454647484931 unmodified lines // returns { helperSource, env }: // helperSource: a Python helper exposing run_composio_tool / invoke_llm / web_search // env: the variables that helper needs to reach Composio from inside your box}
export async function runReview(repo: string, pr: number) { await requireGithubConnection(); const workbench = await createWorkbench();
// Start a sandbox you own, inject the helper, and pass the env. E2B is just // the sample runner; swap createE2bSandbox for any box that honors the same // contract: write a file, set env, run a command, stream output, tear down. const sandbox = await createE2bSandbox({ apiKey: process.env.E2B_API_KEY, helperSource: workbench.helperSource, // written as composio_helper.py env: workbench.env, });}Run the reviewer and stream output
Run the agent inside the sandbox and stream its output back. Whenever the agent calls run_composio_tool, the helper routes that GitHub action back through Composio under this user's connection. Tool execution happens in your box; discovery and auth stay managed.
44 unmodified lines4546474849505152535455565758596061626344 unmodified lines apiKey: process.env.E2B_API_KEY, helperSource: workbench.helperSource, // written as composio_helper.py env: workbench.env, });
// Run the reviewer agent inside the sandbox and stream its output back. The // agent calls run_composio_tool from composio_helper.py, which routes GitHub // actions back through Composio under this user's connection. const task = `Review PR #${pr} on ${repo}. Run the repo's real checks in this sandbox.`; try { await sandbox.run('npx --yes tsx agent.ts', { env: { ...workbench.env, TASK: task, OPENAI_API_KEY: process.env.OPENAI_API_KEY }, onStdout: (chunk) => process.stdout.write(chunk), onStderr: (chunk) => process.stderr.write(chunk), }); } finally { await sandbox.teardown(); }}The whole project
The file above is the spine. The real project rounds it out with a CLI, a smoke/dry-run path, the E2B runner behind the sandbox contract, the reviewer agent and its review policy, and the composio_helper.py the helper source compiles to. Here's a slice of the actual source, with the Composio touch-points highlighted. Browse the tree, read the files:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138import { loadConfig, requireLiveConfig } from './config.js';import { buildReviewTask, runReview, type ReviewTarget } from './runner.js';import { createComposioClient, createGithubConnectUrl, getActiveGithubConnection } from './workbench.js';
type Command = 'review' | 'connect-github' | 'help';
interface CliOptions { command: Command; repo?: string; pr?: number; userId?: string; dryRun: boolean;}
function usage(): string { return `Local PR Reviewer
Usage: bun run review -- --repo <owner/repo> --pr <number> bun run connect bun run smoke
Options: --repo <owner/repo> GitHub repository to review --pr <number> Pull request number --user-id <id> Override COMPOSIO_USER_ID for this run --dry-run Validate inputs and print the planned local-workbench flow --help Show this message`;}
function parseArgs(argv: string[]): CliOptions { const args = [...argv]; const command = (args[0] && !args[0].startsWith('--') ? args.shift() : 'review') as Command; const options: CliOptions = { command: command === 'connect-github' || command === 'review' || command === 'help' ? command : 'help', dryRun: false, };
for (let index = 0; index < args.length; index += 1) { const arg = args[index]; const next = args[index + 1];
if (arg === '--repo') { options.repo = next; index += 1; } else if (arg === '--pr') { options.pr = Number(next); index += 1; } else if (arg === '--user-id') { options.userId = next; index += 1; } else if (arg === '--dry-run') { options.dryRun = true; } else if (arg === '--help' || arg === '-h') { options.command = 'help'; } else { throw new Error(`Unknown argument: ${arg}`); } }
return options;}
function parseTarget(options: CliOptions): ReviewTarget { if (!options.repo || !/^[^/\s]+\/[^/\s]+$/.test(options.repo)) { throw new Error('--repo must be in owner/repo format'); } const pr = options.pr; if (!Number.isInteger(pr) || (pr ?? 0) <= 0) { throw new Error('--pr must be a positive integer'); } return { repo: options.repo, pr: pr as number };}
async function connectGithub(options: CliOptions): Promise<void> { const config = loadConfig(); if (options.userId) config.userId = options.userId; if (!config.composioApiKey) throw new Error('COMPOSIO_API_KEY is required to create a GitHub connect URL');
const composio = createComposioClient(config); const existing = await getActiveGithubConnection(composio, config.userId); if (existing) { console.log(`GitHub is already connected for ${config.userId} (${existing.id}).`); return; }
const url = await createGithubConnectUrl(composio, config.userId); console.log(`Open this URL to connect GitHub for ${config.userId}:`); console.log(url);}
function dryRun(target: ReviewTarget, userId: string): void { console.log('Dry run OK.'); console.log(`User: ${userId}`); console.log(`Task: ${buildReviewTask(target)}`); console.log('Flow: create Tool Router session with workbench.enable=false, boot E2B, upload helper + reviewer, run checks, post one grounded PR comment.');}
async function review(options: CliOptions): Promise<void> { const config = loadConfig(); if (options.userId) config.userId = options.userId; const target = parseTarget(options);
if (options.dryRun) { dryRun(target, config.userId); return; }
requireLiveConfig(config); const result = await runReview({ config, target, onEvent: (event) => console.log(`[${event.type}] ${event.detail}`), }); console.log('\nReview result:\n'); console.log(result);}
async function main(): Promise<void> { const options = parseArgs(process.argv.slice(2));
if (options.command === 'help') { console.log(usage()); return; }
if (options.command === 'connect-github') { await connectGithub(options); return; }
await review(options);}
main().catch((error) => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1;});The complete project lives on GitHub: local-pr-reviewer.
Run it
Dry-run first to validate your input with no credentials, network calls, or sandbox startup, then run it for real:
bun run review -- --repo ComposioHQ/composio --pr 123 --dry-run
bun run review -- --repo ComposioHQ/composio --pr 123The host opens a local sandbox session, boots the sandbox, and runs the repo's real checks inside it, then posts one grounded comment, or nothing if it can't build the PR.