Skip to content

Tools

A tool is a typed action the model can call: hit an API, run a query, write a file. Each file in agent/tools/ defines one tool, and the filename becomes the tool name the model sees. Tools come in two execution flavors: server tools run in-process on the serve host, and agent tools run as scripts where the agent runs. Every server tool can also be called directly, with no model turn.

Define a server tool

By default, execute runs in-process on the serving host with full access to process.env and your agent/lib/ code. Local turns call server tools as SDK custom tools. Cloud turns reach them over authenticated HTTP MCP back to the serve host when --public-url or --cloud-tools-url is set; without either, the server warns at startup and cloud turns omit them (see Cloud runtime).

ts
// agent/tools/inspect_pr.ts
import { defineTool } from "@cursor/july/tools";
import { z } from "zod";

export default defineTool({
  description: "Inspect a pull request before approval.",
  // execution: "server" is the default
  inputSchema: z.object({ prUrl: z.string().url() }),
  async execute({ prUrl }, ctx) {
    return { prUrl, checks: ["unit", "lint"], ready: true };
  },
});

A tool definition needs a filename slug (the model-facing name), a description written for the model, an optional inputSchema, and the code that runs it. With a Zod inputSchema, the input is validated before execute runs and the input type is inferred. A plain JSON Schema object is forwarded as-is and the input arrives as raw JSON.

For multi-line descriptions, reminder prompts, and error messages, use prompt so the string can sit indented with the surrounding TypeScript:

ts
import { prompt } from "@cursor/july";
import { defineTool } from "@cursor/july/tools";
import { z } from "zod";

export default defineTool({
  description: prompt`
    Inspect a pull request before approval.
    Prefer this over guessing from the title alone.
  `,
  inputSchema: z.object({ prUrl: z.string().url() }),
  async execute({ prUrl }) {
    return { prUrl };
  },
});

The ctx parameter

execute receives a ctx with the runtime accessors:

MemberWhat it is
ctx.toolCallIdThe call id, matching the actions.requested / action.result stream events
ctx.sessionRead-only session info: id, channel, mode, auth
ctx.workspaceDirThe session's workspace directory
ctx.stateRootThe agent's durable state root, shared across every session
ctx.hostShared host services (see below)
ctx.send(channelId, message, options?)Start or resume a session on any channel: the cross-channel handoff primitive, e.g. a chat tool opening a drive cloud session for a PR. auth defaults to this call's session principal
ctx.getSession(channelId, sessionId)Look up a session on a channel, e.g. refresh sdkAgentId after a turn
ctx.artifactsSession-bound artifacts facade: tag auto-fills the session

ctx.host carries the shared host services: host.mcp (authored MCP connections: names(), listTools(name), callTool(name, tool, args)), host.github and host.slack (shared platform clients), host.kv and host.files (durable storage), host.otel (custom metrics and session tags; see OpenTelemetry), host.reminders (per-session wakes, when attached), host.evals (playground eval batches, when attached), and host.slackNudges (Slack ask-dedupe helpers).

Return values

execute may return a string (passed to the model verbatim), a JSON-shaped value (serialized), or the envelope { content: [...], isError? } for rich results. Returns must be JSON-shaped: use object literals or type aliases. An interface type fails assignability (no index signature), and tsx won't catch it. Your typecheck will.

Throwing inside execute reports an error result to the model (isError: true). The turn and the server keep running.

Define an agent tool

Set execution: "agent" and the tool materializes as a shell script that runs where the Cursor agent runs: the local harness workspace or the cloud VM. The script receives JSON arguments on stdin and prints its result on stdout. Use this flavor when the tool must run next to the checkout the agent works in; on cloud, server tools stay available too through the host's HTTP MCP endpoint.

ts
import { defineTool } from "@cursor/july/tools";
import { z } from "zod";

export default defineTool({
  description: "Echo a message from the agent workspace.",
  execution: "agent",
  inputSchema: z.object({ message: z.string() }),
  script: `#!/usr/bin/env bash
set -euo pipefail
message=$(python3 -c 'import json,sys; print(json.load(sys.stdin)["message"])')
printf '%s\\n' "$message"
`,
});

On the local runtime, scripts land under .agent-serve/tools/ in the session workspace with a catalog in AGENTS.md. On cloud, the catalog and script bodies travel on the first prompt.

Gate a tool on human approval

A server tool can require a person to sign off before it runs. Set needsApproval to true, or to a predicate over the validated input:

ts
export default defineTool({
  description: "Promote a verified build to an environment.",
  needsApproval: true, // or: (input) => input.environment === "production"
  inputSchema: z.object({
    service: z.string(),
    buildId: z.string(),
    environment: z.string(),
  }),
  async execute({ service, buildId, environment }) {
    return { promoted: true, service, buildId, environment };
  },
});

The call parks, the turn stays running, and the stream emits action.approval_requested until someone resolves it from the playground, POST /v1/session/:id/approvals/:callId, or Slack Approve/Deny cards. Approvals exist for server tools on the local runtime only, and parked calls don't survive a host restart. The full lifecycle is in Human-in-the-loop.

Call a tool without a model turn

Server tools can be called deterministically. You pick the tool and the input. Validation and execution behave exactly as they would for a model-initiated call, and no Cursor API key is needed.

Over HTTP, with the same auth chain as the session API:

bash
curl -X POST http://127.0.0.1:3000/<slug>/v1/tools/inspect_pr \
  -H 'content-type: application/json' \
  -d '{"input":{"prUrl":"https://github.com/acme/checkout/pull/42"}}'
# {"ok":true,"toolName":"inspect_pr","callId":"tool_inspect_pr_…",
#  "isError":false,"result":{…},"durationMs":12}

From the CLI, which starts an ephemeral server unless --url targets a running one:

bash
agent-sdk call inspect_pr --dir . \
  --input '{"prUrl":"https://github.com/acme/checkout/pull/42"}'
agent-sdk call inspect_pr \
  --url http://127.0.0.1:3000/<slug> \
  --input '{"prUrl":"https://github.com/acme/checkout/pull/42"}'

Programmatically, callTool(toolName, input, options?) is available on the serve handle, on channel route handlers and onStart args, and on schedule run handlers, so a channel can mix deterministic calls with model turns, fetching PR metadata deterministically and then send()ing the review prompt:

ts
const outcome = await handle.callTool("inspect_pr", {
  prUrl: "https://github.com/acme/checkout/pull/42",
});
// { toolName, callId, isError, result, durationMs }

By default the call runs against an ephemeral scratch workspace under <stateRoot>/tool-calls/<callId>, materialized like a session workspace and removed when the call returns. Pass a sessionId (a body field over HTTP, --session on the CLI, options.sessionId programmatically) to run inside an existing session instead: the tool sees that session's workspace, and the call is recorded on the session's event stream under a per-call turnId, visible in the playground and trajectories like any model-initiated call. Session-bound calls serialize with model turns and return 409 session_busy while a turn runs.

The error semantics match the model path. Unknown tools are rejected with the available names, agent-execution tools cannot be called on the host (400), schema-invalid input is a 400 before the tool body runs (Zod validates; plain JSON Schema passes through unvalidated), and a tool body that throws reports isError: true in the same envelope the model would see.

Design habits

Keep one decision per tool. Small tools with crisp descriptions beat multi-purpose tools with mode flags. The model chooses better and evals gate cleaner.

Put deterministic policy in tool code, not model judgment. A PR-approval tool should re-read the live PR inside the tool before acting, so a spoofed payload can't steer it.

Test tools with call before blaming prompts. If the tool's output is wrong, no instruction change fixes it.

And gate side effects: needsApproval for the calls that need a person's sign-off, and dry-run defaults flipped by an env var for anything destructive.

What's next

Continue with these pages: