just-bash-executor
$
npx mdskill add vercel-labs/just-bash/just-bash-executorTransform API specs into sandboxed bash commands and JS tools.
- Generates CLI commands and JS APIs from OpenAPI, GraphQL, or MCP sources.
- Integrates with just-bash's js-exec sandbox for secure code execution.
- Uses createExecutor config to map input sources to executable code.
- Delivers runnable tools and CLI interfaces directly to the agent.
SKILL.md
.github/skills/just-bash-executorView on GitHub ↗
---
name: just-bash-executor
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.
---
# `@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
```text
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.
```ts
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):
```text
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:
```ts
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`):
```ts
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:
```ts
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/*`):
```text
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 object
- Operations missing `operationId` are 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
- `headers` are 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-openapi`
alongside `@executor-js/sdk` or `createExecutor` will throw
## §4. GraphQL → tools
Ask the user for: endpoint URL, optional introspection JSON, a namespace `name`,
and optional auth headers.
```ts
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 check `errors` and read `data` themselves — 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 `fetch` instead of going through the SDK plugin.
- Subscriptions are not currently exposed as callable tools
Example, from the public Countries schema (all queries):
```text
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:
```js
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 in
`try/catch` if the agent might call with empty args
- For complex `filter`/input-object args, prefer `--json` over `key=value`
- `headers` apply to introspection AND every tool call — useful for tokens,
but don't put per-user identity here
- Install `@executor-js/plugin-graphql` alongside `@executor-js/sdk`
## §5. MCP → tools
Ask the user for: transport (`"remote"` or `"stdio"`), endpoint URL or
command+args, a namespace `name`.
```ts
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/list` capability
- Tool path: `<name>.<server-tool-name>` — server tool names are preserved
verbatim (often `snake_case` like `read_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`):
```text
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"` requires
`command` + `args`
- MCP servers with elicitation flows need an `onElicitation` handler other
than the default decline-all, otherwise interactive tools will fail
- Install `@executor-js/plugin-mcp` alongside `@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.
```ts
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.
```js
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`.
```ts
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.
1. **Exec works.** A simple call returns exit 0 with parseable JSON on stdout:
```ts
const r = await bash.exec(`<ns> <subcommand> <args>`);
JSON.parse(r.stdout); // should not throw
```
2. **Wrong path errors clearly.** `await tools.ns.nope({})` throws with
`Unknown tool` in the message — confirms dispatch is wired.
3. **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: missing `operationId` for OpenAPI; subscription field
for GraphQL; capability not advertised for MCP).
4. **Inspect via SDK handle (when `setup` was used):**
```ts
// 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" });
```
5. **Approval gates work.** If you wired `onToolApproval`, deny one path and
confirm the call throws inside `js-exec` rather than silently succeeding.
## §9. Anti-patterns
- **Don't pass parsed objects to `kind: "openapi"`.** `spec` is a string
(JSON or YAML text). Use `JSON.stringify(...)` or `fs.readFileSync(path, "utf8")`.
- **Don't put tool logic inside the `js-exec` script.** `execute` runs on the
host; the script just calls it. Putting fetches or DB calls in the script
defeats the sandbox.
- **Don't rely on `await` doing real async work.** Tool calls are synchronous
via `Atomics.wait` from the script's perspective; `await` is for portability
with other runtimes.
- **Don't expose host-FS or shell tools without an `onToolApproval` gate.**
The default `"allow-all"` is fine for read-only or pure-compute tools; for
anything destructive, gate by `toolPath`.
- **Don't reuse a namespace across sources.** Two `sources.add` calls with the
same `name` will collide. Use distinct names per source.
- **Don't skip installing the plugin package.** `@executor-js/sdk` alone is not
enough — each source kind requires its plugin (`@executor-js/plugin-openapi`,
`…-graphql`, `…-mcp`).
## §10. Cross-references
- [`README.md`](./README.md) — conceptual overview, configuration reference
- [`examples/executor-tools/`](../../examples/executor-tools/) — runnable
end-to-end examples (`inline-tools.ts`, `multi-turn-discovery.ts`)
- [`@executor-js/sdk`](https://www.npmjs.com/package/@executor-js/sdk) —
upstream SDK whose plugins drive discovery