Executor runs tools for your users. You deploy an app: a small TypeScript file that declares tools, such as "list my GitHub issues". Your users connect their own accounts. Your code, an agent or an MCP client then calls the tools on a user's behalf through Executor.
This gist has three guides:
| File | What it covers |
|---|---|
0-README.md (this file) |
Getting a key, installing the SDK, deploying an app, calling it |
1-eve-agent.md |
An Eve agent that uses a user's Executor tools |
2-mcp-server.md |
Executor as an MCP server for Claude Code, Cursor, Claude Desktop or Eve |
The other files in the gist are the two examples' source. Each guide says where to save them.
Links:
- Console: https://platform.executor.sh/platform
- API reference: https://platform.executor.sh/platform/docs (OpenAPI: https://platform.executor.sh/platform/openapi.json)
- Writing apps: https://v2.executor.sh/docs/author-an-app
- SDK on npm:
@executor-js/sdk, version2.1.0-beta.0
This is a beta. Expect rough edges, and pin exact versions.
- Open https://platform.executor.sh/platform and sign in with an email code, Google or GitHub.
- Create an organization if the console asks for one. Its first project, Default, is created with it.
- Open Project keys and select Create key. Copy the key when the console shows it. It
starts with
exk_, and the console never shows it again.
A project key can do everything in its project. Keep it on your server or your own machine. Do not put it in browser code or commit it.
npm install --save-exact @executor-js/sdk@2.1.0-beta.0The SDK is a Promise API over fetch. It runs in Node 20 or newer, Bun, Deno and edge runtimes.
export EXECUTOR_BASE_URL="https://platform.executor.sh/platform"
export EXECUTOR_API_KEY="exk_..."Always pass baseUrl. The SDK's built-in default is a production address that does not serve
the platform yet.
Every call names an owner: your own ID for one of your users, such as user_a. Apps,
accounts and profiles belong to an owner. There is nothing to create first; an owner exists once
a call names it. Owners are per project, so user_a in one project is not user_a in another.
Save this app as hello/index.ts. It needs no account, so its tools work at once. Executor builds
and runs it; you don't install apps yourself.
import { defineApp, object, query, router, string } from "apps";
export default defineApp(
{ accounts: {} },
{
tools: router({
greet: query(
{ description: "Greet someone by name.", input: object({ name: string() }) },
async (_ctx, { name }) => ({ message: `Hello, ${name}!` }),
),
}),
},
);Then save this as quickstart.ts next to it, in a project with "type": "module":
import { readFileSync } from "node:fs";
import { createRemoteExecutor } from "@executor-js/sdk";
const executor = await createRemoteExecutor({
baseUrl: process.env.EXECUTOR_BASE_URL ?? "https://platform.executor.sh/platform",
apiKey: process.env.EXECUTOR_API_KEY!,
});
const owner = "user_a";
// Deploy the app's source for this owner, or a new version of it. Executor builds it.
const name = "Hello";
const [existing] = await executor.apps.list({ owner, name });
const files = [
{ path: "index.ts", content: readFileSync("hello/index.ts", "utf8") },
{
path: "package.json",
// The apps version must be one the platform runs.
content: JSON.stringify({ name: "hello", dependencies: { apps: "0.0.1-beta.22" } }),
},
];
const { app } = await executor.apps.deploy(
existing === undefined ? { owner, name, files } : { owner, app: existing.id, files },
);
// A profile holds the accounts an app's tools run with. This app needs none. The idempotency key
// makes a second run return the same profile.
const profile = await executor.apps.profiles.create({
owner,
app: app.id,
subject: "default",
idempotencyKey: "hello-default",
accounts: {},
});
const tools = await executor.tools.list({ app: app.id, profile: profile.id });
console.log(tools.items.map((tool) => tool.name)); // [ "greet" ]
const result = await executor.tools.call({
app: app.id,
profile: profile.id,
tool: "greet",
kind: "query", // "query" for read-only tools, "mutation" for the rest
input: { name: "Ada" },
});
console.log(result); // { status: "completed", value: { message: "Hello, Ada!" } }Run it again after editing the app: it deploys the new version to the same app.
Node 22.18 and newer run TypeScript directly: node quickstart.ts. On Node 20, use
npx tsx quickstart.ts.
See Author an app for mutations, approvals, accounts and everything else an app can declare.
There is no app catalog on this deployment yet. Write the apps you need, or copy one already in
your project with executor.apps.copy.
An app can declare accounts, such as a GitHub sign-in. Until the owner's profile selects one, the
app's tools fail with AccountRequired. The provider an app needs is
app.requirements.accounts.<slot>.provider, from executor.apps.list({ owner }).
API key providers. Add the account, then select it in the profile:
const account = await executor.accounts.add({
owner,
provider: app.requirements.accounts.demo.provider,
method: "apiKey", // the auth method's name in the provider's declaration
fields: { token: "the user's API token" },
});
await executor.apps.profiles.update({
app: app.id,
profile: profile.id,
expectedRevision: profile.revision,
accounts: { demo: account.id },
});Sign-in (OAuth) providers, with a connect link from the console. No code of your own:
- GitHub and Google need your own OAuth app. Create one with the service and set its callback URL
to
https://platform.executor.sh/platform/connect/callback. In the console, open OAuth clients, select Register client, pick the owner and enter the client ID and secret. - Open Owners, select New auth link, enter the owner ID, choose the provider and client, and create the link.
- Send the link to your user. They sign in with the service; they don't need an Executor account.
- The link adds the account to the owner, not to an app. Find it with
executor.accounts.list({ owner })and select it withexecutor.apps.profiles.updateas above.
Sign-in (OAuth) providers, with only the key. Your own app hosts the OAuth callback:
executor.accountConnections.create({ owner, target: { app, profile, requirement: slot } })executor.accountConnections.startOAuth({ connection, owner, method: "oauth", redirectUri, client }), whereredirectUriis your own callback URL andclientis{ clientId, clientSecret }from your OAuth app, or{ id }of a client registered withexecutor.oauthClients.register.- Send the user to the returned
authorizationUrl. - In your callback route, call
executor.accountConnections.completeOAuth({ connection, owner, callbackUrl })with the full callback URL. Because the connection named atarget, the account fills that profile slot.
A failed call rejects with an instance of an exported error class. Check it with instanceof:
import { ProjectKeyInvalid, TransportError } from "@executor-js/sdk";
try {
await executor.accounts.list({ owner: "user_a" });
} catch (error) {
if (error instanceof ProjectKeyInvalid) console.error("Check EXECUTOR_API_KEY");
else if (error instanceof TransportError) console.error(error.reason);
else throw error;
}| Error | Status | Meaning |
|---|---|---|
ProjectKeyInvalid |
401 | The key is missing, unknown or revoked. |
OwnerRequired |
400 | The call did not name an owner. |
ProjectResourceNotFound |
404 | A referenced app or account is not in the key's project. |
ProjectOperationUnavailable |
403 | The operation works on data no project owns. |
AccountRequired |
409 | The app needs an account for a slot. Nothing ran. |
TransportError |
- | No valid answer arrived. The call may still have run. |
Each operation's own errors, such as AppNotFound or ToolNotFound, are exported too. The
API reference lists them per operation.
A tool with an approval policy does not run until someone approves it. The call returns
{ status: "approval-required", requestId, elicitation } and nothing has run yet. Show
elicitation.message to the user, then answer with executor.tools.resume, naming the owner:
if (result.status === "approval-required") {
console.log(result.elicitation.message);
const resumed = await executor.tools.resume({
owner,
requestId: result.requestId,
response: { action: "accept" }, // or { action: "decline" }
});
if (resumed.status === "completed") console.log(resumed.value);
}A running tool can ask its user a question, such as a name or a choice (ctx.elicit in the app).
Pass an elicitation handler to answer it during the call. Without one, the call fails with
ToolElicitationFailed when the tool asks, and anything it did before asking has already happened.
const asked = await executor.tools.call(
{ app: app.id, profile: profile.id, tool: "greetMe", kind: "query", input: {} },
{
elicitation: async (request) => {
console.log(request.message); // request.requestedSchema describes the form
return { action: "accept", content: { name: "Ada" } };
},
},
);The handler answers questions only. An approval still returns approval-required, with or
without a handler.
The package ships its own declarations and has no dependencies. Use moduleResolution
bundler, node16 or nodenext, and include the DOM library or @types/node. baseUrl is
required, and so is owner on every operation that takes one, so the compiler catches a missing
one. The full SDK README is on npm.