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
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.
This skill should be used when the user asks to "create a hook", "add a PreToolUse/PostToolUse/Stop hook", "validate tool use", "implement prompt-based hooks", "use ${CLAUDE_PLUGIN_ROOT}", "set up event-driven automation", "block dangerous commands", or mentions hook events (PreToolUse, PostToolUse, Stop, SubagentStop, SessionStart, SessionEnd, UserPromptSubmit, PreCompact, Notification). Provides comprehensive guidance for creating and implementing Claude Code plugin hooks with focus on advanced prompt-based hooks API.
Build agentic applications with GitHub Copilot SDK. Use when embedding AI agents in apps, creating custom tools, implementing streaming responses, managing sessions, connecting to MCP servers, or creating custom agents. Triggers on Copilot SDK, GitHub SDK, agentic app, embed Copilot, programmable agent, MCP server, custom agent.
Coding Agent Session Search - unified CLI/TUI to index and search local coding agent history from Claude Code, Codex, Gemini, Cursor, Aider, ChatGPT, Pi-Agent, Factory, and more. Purpose-built for AI agent consumption with robot mode.
Destructive Command Guard - High-performance Rust hook for Claude Code that blocks dangerous commands before execution. SIMD-accelerated, modular pack system, whitelist-first architecture. Essential safety layer for agent workflows.
Makepad UI development skills for Rust apps: setup, patterns, shaders, packaging, and troubleshooting.
Secure environment variable management ensuring secrets are never exposed in Claude sessions, terminals, logs, or git commits
Prompt for generating an AGENTS.md file for a repository
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.