
Skill
just-bash-executor
convert remote APIs into bash CLI tools
Description
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.
SKILL.md
@just-bash/executor — agent guide
This 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.
§1. Decide which source kind you have
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.
§2. Inline tools
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).
§3. OpenAPI → tools
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:
- One tool per operation in the spec
- Tool path:
<name>.<firstUrlSegment>.<operationId>— the first URL path segment is included as a grouping prefix (camelCase preserved on the operationId) - Args object = path params + query params + requestBody fields, flattened
- Bash subcommand: kebab-case of
<firstUrlSegment>.<operationId>; original camelCase 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:
specmust be a string (URL, JSON text, or YAML text), not a parsed object- Operations missing
operationIdare skipped; if the user's spec lacks them, add them or fall back to inline tools - All param locations (path, query, body) flatten into one args object — name collisions across locations are the user's problem to resolve
headersare sent on every invocation — fine for static tokens, but for per-request auth wire an inline tool that adds the header dynamically- The plugin is loaded lazily via
setup; install@executor-js/plugin-openapialongside@executor-js/sdkorcreateExecutorwill throw
§4. GraphQL → tools
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:
- One tool per top-level Query and Mutation field
- Tool path:
<name>.query.<fieldName>for queries and<name>.mutation.<fieldName>for mutations (camelCase preserved) - Args object = the field's argument definitions
- Result is the raw GraphQL response envelope:
{ status, data, errors }. Scripts must checkerrorsand readdatathemselves — there is no auto-unwrap. - The plugin auto-generates a shallow selection set. Queries whose return
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
fetchinstead of going through the SDK plugin. - Subscriptions are not currently exposed as callable tools
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:
- Required GraphQL args (
String!,ID!) must be passed; the SDK surfaces validation errors as thrown exceptions inside scripts — wrap calls intry/catchif the agent might call with empty args - For complex
filter/input-object args, prefer--jsonoverkey=value headersapply to introspection AND every tool call — useful for tokens, but don't put per-user identity here- Install
@executor-js/plugin-graphqlalongside@executor-js/sdk
§5. MCP → tools
Ask 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:
- One tool per tool advertised by the MCP server's
tools/listcapability - Tool path:
<name>.<server-tool-name>— server tool names are preserved verbatim (oftensnake_caselikeread_file) - Args object = the MCP tool's input schema
- Subcommand: server tool name → kebab-case; original (snake_case or otherwise) 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"requiresendpoint;transport: "stdio"requirescommand+args- MCP servers with elicitation flows need an
onElicitationhandler other than the default decline-all, otherwise interactive tools will fail - Install
@executor-js/plugin-mcpalongside@executor-js/sdk
§5b. Combining multiple sources in one executor
Real 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.
§6. Calling generated tools — the rules to internalize
These two tables are the only things you need to memorize. They apply to all four source kinds — the conversion is uniform.
JS API (inside 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"]({ path }) |
| 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 CLI (inside 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.
§7. Skeleton an agent can copy and adapt
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);
§8. Verification before reporting "done"
Run these checks in order. Stop at the first failure.
- Exec works. A simple call returns exit 0 with parseable JSON on stdout:
const r = await bash.exec(`<ns> <subcommand> <args>`); JSON.parse(r.stdout); // should not throw - Wrong path errors clearly.
await tools.ns.nope({})throws withUnknown toolin the message — confirms dispatch is wired. - Help reflects discovery.
bash.exec("<ns> --help")lists every tool the user expected. If a tool's missing, the source registration didn't pick it up (most often: missingoperationIdfor OpenAPI; subscription field for GraphQL; capability not advertised for MCP). - Inspect via SDK handle (when
setupwas 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" }); - Approval gates work. If you wired
onToolApproval, deny one path and confirm the call throws insidejs-execrather than silently succeeding.
§9. Anti-patterns
- Don't pass parsed objects to
kind: "openapi".specis a string (JSON or YAML text). UseJSON.stringify(...)orfs.readFileSync(path, "utf8"). - Don't put tool logic inside the
js-execscript.executeruns on the host; the script just calls it. Putting fetches or DB calls in the script defeats the sandbox. - Don't rely on
awaitdoing real async work. Tool calls are synchronous viaAtomics.waitfrom the script's perspective;awaitis for portability with other runtimes. - Don't expose host-FS or shell tools without an
onToolApprovalgate. The default"allow-all"is fine for read-only or pure-compute tools; for anything destructive, gate bytoolPath. - Don't reuse a namespace across sources. Two
sources.addcalls with the samenamewill collide. Use distinct names per source. - Don't skip installing the plugin package.
@executor-js/sdkalone is not enough — each source kind requires its plugin (@executor-js/plugin-openapi,…-graphql,…-mcp).
§10. Cross-references
README.md— conceptual overview, configuration referenceexamples/executor-tools/— runnable end-to-end examples (inline-tools.ts,multi-turn-discovery.ts)@executor-js/sdk— upstream SDK whose plugins drive discovery
More from Vercel Labs
View publisheragent-browser
automate browser interactions for AI agents
agent-browser
Jul 20AgentsAutomationBrowser Automationagentcore
run browser automation on AWS Bedrock
agent-browser
Jul 17AutomationAWSBrowser Automationcore
navigate and interact with web pages
agent-browser
Jul 26AgentsBrowser AutomationNavigationderive-client
reverse engineer internal APIs from browser traffic
agent-browser
Jul 20API DevelopmentAutomationBrowser AutomationWeb Scrapingdogfood
perform exploratory testing on web applications
agent-browser
Jul 17Browser AutomationDebuggingQATestingelectron
automate Electron desktop applications
agent-browser
Jul 17AgentsBrowser AutomationDesktop