vercel-labs/just-bash-executor
Convert a GraphQL endpoint, OpenAPI spec, or MCP server into bash CLI commands and a `tools.*` JS API runnable inside `just-bash`'s `js-exec` sandbox. Use when the user wants to expose a remote API to a sandboxed bash agent, build a tool-calling agent on top of just-bash, or generate CLI commands from an existing API spec.
npx skills add https://github.com/vercel-labs/just-bash --skill just-bash-executor
@just-bash/executor — agent guideThis file is for an AI agent writing code that uses @just-bash/executor. It
maps each input (an OpenAPI spec, a GraphQL endpoint, an MCP server, or your
own JS functions) to the exact code to write and the exact surfaces (JS API +
bash CLI) the user gets back.
Read top to bottom on the first task; jump by section number on later tasks.
Have an OpenAPI spec / Swagger doc? → §3 OpenAPI
Have a GraphQL endpoint or SDL? → §4 GraphQL
Have an MCP server (URL or stdio)? → §5 MCP
Defining tools yourself in code? → §2 Inline
Mixing several of the above? → call sources.add() once per source
inside the same setup(); paths stay
namespaced by `name`
For all four, the consuming code is identical (§6, §7) — what changes is the
createExecutor config.
Use when there's no upstream spec — the user wants to expose specific JS
functions to the sandbox.
import { Bash } from "just-bash";
import { createExecutor } from "@just-bash/executor";
const executor = await createExecutor({
tools: {
"ns.action": {
description: "What it does",
execute: async (args: { /* shape */ }) => ({ /* JSON-serializable */ }),
},
},
});
const bash = new Bash({
customCommands: executor.commands,
javascript: { invokeTool: executor.invokeTool },
});
Conversion (single rule):
key in tools: {…} JS bash
"ns.action" → await tools.ns.action(args) ns action key=value
ns action --key value
ns action --json '{"key":1}'
The first dot-segment is the namespace command; the rest is the subcommand
(kebab-cased, with the original form as an alias when different).
Ask the user for: spec source, base endpoint, namespace name, and optional
auth headers.
The spec field accepts three forms:
spec: "https://petstore3.swagger.io/api/v3/openapi.json" // URL — fetched at setup
spec: fs.readFileSync("./openapi.yaml", "utf8") // YAML text
spec: JSON.stringify(specObject) // JSON text
For authenticated APIs, pass headers (and optionally queryParams):
await sdk.sources.add({
kind: "openapi",
spec: "https://api.github.com/openapi.json",
endpoint: "https://api.github.com",
name: "github",
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json",
},
});
Full inline example:
import { createExecutor } from "@just-bash/executor";
import { Bash } from "just-bash";
// `spec` is the raw OpenAPI document as a STRING (JSON or YAML text),
// not a parsed object. Read it from disk if you have a file.
const PETSTORE_SPEC = JSON.stringify({
openapi: "3.0.0",
info: { title: "Petstore", version: "1.0.0" },
paths: {
"/pets": {
get: {
operationId: "listPets",
parameters: [
{ name: "status", in: "query", schema: { type: "string" } },
],
responses: { "200": { description: "ok" } },
},
post: {
operationId: "createPet",
requestBody: {
content: {
"application/json": {
schema: {
type: "object",
properties: { name: { type: "string" } },
},
},
},
},
responses: { "201": { description: "ok" } },
},
},
"/pets/{petId}": {
get: {
operationId: "getPetById",
parameters: [
{ name: "petId", in: "path", required: true, schema: { type: "string" } },
],
responses: { "200": { description: "ok" } },
},
},
},
});
const executor = await createExecutor({
setup: async (sdk) => {
await sdk.sources.add({
kind: "openapi",
spec: PETSTORE_SPEC,
endpoint: "https://petstore.example.com",
name: "pets", // becomes the namespace
});
},
onToolApproval: "allow-all",
});
const bash = new Bash({
customCommands: executor.commands,
javascript: { invokeTool: executor.invokeTool },
});
Conversion rules:
<name>.<firstUrlSegment>.<operationId> — the first URL pathsegment is included as a grouping prefix (camelCase preserved on the operationId)
<firstUrlSegment>.<operationId>; originalcamelCase form is kept as an alias
Example (from the spec above — all under /pets/*):
operationId → tool path JS call bash
listPets → pets.pets.listPets await tools.pets.pets.listPets({ status }) pets pets.list-pets --status open
createPet → pets.pets.createPet await tools.pets.pets.createPet({ name }) pets pets.create-pet --name Fido
getPetById → pets.pets.getPetById await tools.pets.pets.getPetById({ petId }) pets pets.get-pet-by-id --pet-id 42
(The double pets.pets looks awkward but is deterministic: the first pets
is your name, the second is the URL's first path segment.)
Pitfalls:
spec must be a string (URL, JSON text, or YAML text), not a parsed objectoperationId are skipped; if the user's spec lacks them,add them or fall back to inline tools
collisions across locations are the user's problem to resolve
headers are sent on every invocation — fine for static tokens, but forper-request auth wire an inline tool that adds the header dynamically
setup; install @executor-js/plugin-openapialongside @executor-js/sdk or createExecutor will throw
Ask the user for: endpoint URL, optional introspection JSON, a namespace name,
and optional auth headers.
const executor = await createExecutor({
setup: async (sdk) => {
await sdk.sources.add({
kind: "graphql",
endpoint: "https://api.github.com/graphql",
name: "github",
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
},
// Optional: pre-fetched schema; skips the introspection round-trip and
// lets discovery work offline. Recommended for unstable upstreams.
// introspectionJson: INTROSPECTION_JSON,
});
},
onToolApproval: "allow-all",
});
Conversion rules:
<name>.query.<fieldName> for queries and<name>.mutation.<fieldName> for mutations (camelCase preserved)
{ status, data, errors }.Scripts must check errors and read data themselves — there is no
auto-unwrap.
types contain nested object fields will fail server-side validation
("Field X of type Y must have a selection of subfields"). For these,
wrap the call in an inline tool that posts a hand-written GraphQL query
via fetch instead of going through the SDK plugin.
Example, from the public Countries schema (all queries):
Query field → tool path JS call bash
country(code) → geo.query.country await tools.geo.query.country({ code: "JP" }) geo query.country code=JP
countries(filter)→ geo.query.countries await tools.geo.query.countries({ filter: { ... } }) geo query.countries --json '{"filter":{...}}'
continent(code) → geo.query.continent await tools.geo.query.continent({ code: "EU" }) geo query.continent code=EU
continents → geo.query.continents await tools.geo.query.continents({}) geo query.continents
language(code) → geo.query.language await tools.geo.query.language({ code: "en" }) geo query.language code=en
languages → geo.query.languages await tools.geo.query.languages({}) geo query.languages
Reading the response in a script:
const r = await tools.geo.query.country({ code: "JP" });
if (r.errors && r.errors.length) {
throw new Error(r.errors.map((e) => e.message).join("; "));
}
const country = r.data.country;
console.log(country.name);
Pitfalls:
String!, ID!) must be passed; the SDK surfacesvalidation errors as thrown exceptions inside scripts — wrap calls in
try/catch if the agent might call with empty args
filter/input-object args, prefer --json over key=valueheaders apply to introspection AND every tool call — useful for tokens,but don't put per-user identity here
@executor-js/plugin-graphql alongside @executor-js/sdkAsk the user for: transport ("remote" or "stdio"), endpoint URL or
command+args, a namespace name.
const executor = await createExecutor({
setup: async (sdk) => {
// Remote (SSE / HTTP)
await sdk.sources.add({
kind: "mcp",
transport: "remote",
endpoint: "https://mcp.example.com/sse",
name: "docs",
});
// Remote with auth headers
await sdk.sources.add({
kind: "mcp",
transport: "remote",
endpoint: "https://mcp.context7.com/mcp",
name: "context7",
headers: {
Authorization: `Bearer ${process.env.CONTEXT7_TOKEN}`,
},
});
// Stdio (local process) — env vars and cwd are passed to the child
await sdk.sources.add({
kind: "mcp",
transport: "stdio",
command: "npx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "/data"],
env: { LOG_LEVEL: "info" },
cwd: "/work",
name: "fs",
});
},
onToolApproval: async (req) => {
// MCP servers can do destructive things — gate by tool path
if (req.toolPath.endsWith(".write_file")) {
return { approved: false, reason: "writes need review" };
}
return { approved: true };
},
onElicitation: async (ctx) => {
// MCP servers may request user input mid-tool (forms, OAuth URLs).
// Decline by default; implement a real handler for interactive flows.
return { action: "decline" };
},
});
Conversion rules:
tools/list capability<name>.<server-tool-name> — server tool names are preservedverbatim (often snake_case like read_file)
is kept as an alias when different
Example (filesystem-style MCP server with read_file, list_dir):
server tool → tool path JS call bash kebab bash snake alias
read_file → fs.read_file await tools.fs.read_file({ path: "/x.md" }) fs read-file path=/x.md fs read_file path=/x.md
list_dir → fs.list_dir await tools.fs.list_dir({ path: "/" }) fs list-dir path=/ fs list_dir path=/
Pitfalls:
transport: "remote" requires endpoint; transport: "stdio" requirescommand + args
onElicitation handler otherthan the default decline-all, otherwise interactive tools will fail
@executor-js/plugin-mcp alongside @executor-js/sdkReal agents usually need more than one upstream. Add as many sources.add()
calls as you want inside the same setup; each registers its own namespace and
tools land in a single unified tools proxy / bash command set.
const executor = await createExecutor({
setup: async (sdk) => {
// OpenAPI from a URL (no auth)
await sdk.sources.add({
kind: "openapi",
spec: "https://petstore3.swagger.io/api/v3/openapi.json",
name: "petstore",
});
// GraphQL with bearer auth
await sdk.sources.add({
kind: "graphql",
endpoint: "https://api.github.com/graphql",
name: "github",
headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` },
});
// Remote MCP for context lookups
await sdk.sources.add({
kind: "mcp",
transport: "remote",
endpoint: "https://mcp.example.com/sse",
name: "context",
});
},
// Inline tools coexist with discovered ones; inline wins on path conflict.
tools: {
"util.now": {
description: "Wall-clock ISO timestamp",
execute: () => ({ ts: new Date().toISOString() }),
},
},
onToolApproval: async (req) => {
// Different policy per source
if (req.sourceId === "github" && req.toolPath.includes("delete")) {
return { approved: false, reason: "github deletes need review" };
}
return { approved: true };
},
});
A js-exec script can then call across all sources in one turn. Remember the
shape per source kind: GraphQL paths are <name>.query.<field>, OpenAPI paths
are <name>.<firstUrlSegment>.<operationId>, MCP paths are
<name>.<server-tool-name>, inline paths are exactly your key.
const repos = await tools.github.query.search({ query: "stars:>10000", type: "REPOSITORY" });
const pet = await tools.petstore.pet.findPetById({ petId: 1 });
const ctx = await tools.context.lookup({ name: "react" });
const ts = await tools.util.now();
// GraphQL responses are wrapped — unwrap before reading
console.log({
repos: repos.data?.search?.repositoryCount ?? null,
pet, ctx, ts,
});
Use distinct name values per source — collisions silently overwrite tool
paths within the namespace.
These two tables are the only things you need to memorize. They apply to all
four source kinds — the conversion is uniform.
js-exec scripts)| Want | Write |
| ------------------------- | ---------------------------------------------- |
| Call any tool | await tools.<namespace>.<name>(args) |
| Pass no args | await tools.ns.name() or ({}) |
| Catch tool errors | try { ... } catch (e) { e.message } |
| Snake-case server tool | await tools.docs"read_file" |
| Deeply nested path | await tools.a.b.c.d(args) — works as written |
undefined returns reach the script as undefined; everything else is
JSON-serialized and parsed back into a JS value.
bash.exec(...) scripts)| Want | Write |
| ------------------- | --------------------------------------- |
| key=value | ns name a=1 b=2 |
| flags | ns name --a 1 --b 2 |
| --key=value | ns name --a=1 |
| Bool flag | ns name --verbose → {verbose: true} |
| Inline JSON | ns name --json '{"a":1,"b":2}' |
| Piped JSON | echo '{"a":1}' \| ns name |
| Compose with jq | ns name a=1 \| jq -r .field |
| Show help | ns --help or ns name --help |
Mode precedence when more than one is used: flags > --json > stdin.
Values are coerced via JSON.parse first (a=2 → number 2,
ok=true → boolean true, xs=[1,2] → array), falling back to string when
parsing fails.
Tool errors land on stderr with format <namespace>: <subcommand>: <message>
and exit code 1.
Self-contained — pick a source kind, fill in the spec, run with tsx.
import { Bash } from "just-bash";
import { createExecutor } from "@just-bash/executor";
const executor = await createExecutor({
// Pick ONE of: `tools` (inline) or `setup` (SDK), or both.
tools: {
"math.add": {
description: "Add two numbers",
execute: ({ a, b }: { a: number; b: number }) => ({ sum: a + b }),
},
},
// setup: async (sdk) => {
// await sdk.sources.add({ kind: "openapi", spec, endpoint, name });
// },
onToolApproval: "allow-all",
});
const bash = new Bash({
customCommands: executor.commands,
javascript: { invokeTool: executor.invokeTool },
executionLimits: { maxJsTimeoutMs: 30_000 },
});
// 1. JS API
const r1 = await bash.exec(`js-exec -c '
try {
const r = await tools.math.add({ a: 2, b: 3 });
console.log("sum=" + r.sum);
} catch (e) {
console.error("tool failed:", e.message);
}
'`);
process.stdout.write(r1.stdout);
if (r1.stderr) process.stderr.write(r1.stderr);
// 2. Bash CLI — three input modes, all equivalent
for (const cmd of [
"math add a=2 b=3",
"math add --a 2 --b 3",
`echo '{"a":2,"b":3}' | math add`,
]) {
const r = await bash.exec(cmd);
console.log(`${cmd} → ${r.stdout.trim()} (exit=${r.exitCode})`);
}
// 3. Help text
process.stdout.write((await bash.exec("math --help")).stdout);
Run these checks in order. Stop at the first failure.
const r = await bash.exec(`<ns> <subcommand> <args>`);
JSON.parse(r.stdout); // should not throw
await tools.ns.nope({}) throws withUnknown tool in the message — confirms dispatch is wired.
bash.exec("<ns> --help") lists every toolthe user expected. If a tool's missing, the source registration didn't pick
it up (most often: missing operationId for OpenAPI; subscription field
for GraphQL; capability not advertised for MCP).
setup was used): // List everything
const all = await executor.sdk!.tools.list();
console.log(all.map(t => t.id));
// Filter by source
const ghOnly = await executor.sdk!.tools.list({ sourceId: "github" });
// Search descriptions/names
const writes = await executor.sdk!.tools.list({ query: "create" });
onToolApproval, deny one path andconfirm the call throws inside js-exec rather than silently succeeding.
kind: "openapi". spec is a string(JSON or YAML text). Use JSON.stringify(...) or fs.readFileSync(path, "utf8").
js-exec script. execute runs on thehost; the script just calls it. Putting fetches or DB calls in the script
defeats the sandbox.
await doing real async work. Tool calls are synchronousvia Atomics.wait from the script's perspective; await is for portability
with other runtimes.
onToolApproval gate.The default "allow-all" is fine for read-only or pure-compute tools; for
anything destructive, gate by toolPath.
sources.add calls with thesame name will collide. Use distinct names per source.
@executor-js/sdk alone is notenough — each source kind requires its plugin (@executor-js/plugin-openapi,
…-graphql, …-mcp).
README.md — conceptual overview, configuration referenceexamples/executor-tools/ — runnableend-to-end examples (inline-tools.ts, multi-turn-discovery.ts)
upstream SDK whose plugins drive discovery
Take vercel-labs/just-bash-executor from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.