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 bot/tools/ defines one tool, and the filename becomes the tool name the model sees. Tools come in two execution flavors: server tools run on the serving 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 bot/lib/ code.

ts
// bot/tools/inspect_pr.ts
import { defineTool } from "@grok/agent-sdk/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.

A server tool can call decide from @grok/agent-sdk/extensions/jev for a risk tier or a finding gate without a second model turn.

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 "@grok/agent-sdk";
import { defineTool } from "@grok/agent-sdk/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 };
  },
});

Tool context ​

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.approvedByFor a needsApproval tool, the auth of whoever approved this call, in the same shape as ctx.session.auth. On Slack, attributes.user_id is the person who clicked Approve, who can differ from the person who started the thread. Unset when the call needed no approval
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. auth defaults to this call's session principal
ctx.getSession(channelId, sessionId)Look up a session on a channel
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), and host.reminders (per-session wakes, when attached). Custom metrics use the OpenTelemetry API.

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.

Declare structured output ​

Set outputSchema when MCP clients need a structured result contract. Use a Zod schema or plain JSON Schema:

ts
const Weather = z.object({
  city: z.string(),
  temperatureF: z.number(),
});

export default defineTool({
  description: "Read the current weather.",
  inputSchema: z.object({ city: z.string() }),
  outputSchema: Weather,
  async execute({ city }) {
    return { city, temperatureF: 72 };
  },
});

Agent SDK advertises the schema. It does not validate tool results against it. Plain object returns fill MCP structuredContent automatically. An envelope return must set structuredContent next to content and isError.

Use the exported ToolInput<TSchema> type when another function needs the input type inferred from a tool schema.

Control dry-run effects ​

Set effect to "read", "write", "idempotent", or a predicate over validated input. A dry-run session executes reads and answers writes and idempotent calls without running their tool bodies. An authored tool without effect is treated as a write. "idempotent" may change something outside the session, and repeating the same arguments has no additional effect. It is not a read.

ts
export default defineTool({
  description: "Publish a release.",
  inputSchema: z.object({ version: z.string() }),
  effect: "write",
  dryRunResult: ({ version }) => ({ published: false, version }),
  async execute({ version }) {
    return publishRelease(version);
  },
});

dryRunResult keeps the result shape stable. Without it, a stubbed write returns "Operation acknowledged.". Traces mark the result as stubbed.

Retry a transient read or idempotent call ​

When a read or an idempotent call fails because the service or the network was briefly unavailable, Agent SDK runs that same call again before the model sees the error. A write, and a tool that does not declare an effect, runs once. An advertised MCP tool is retried when its annotations classify as a read, or as idempotent. idempotentHint: true with destructiveHint explicitly true or explicitly false is idempotent. The two hints are orthogonal: a destructive call can still be safe to repeat. A bare idempotentHint, a destructive hint without idempotentHint, and destructiveHint: false without idempotentHint are not eligible. idempotent is not a read and is not a non-destructive claim. Declaring effect: "read" or effect: "idempotent" opts the call into the default policy of 3 attempts.

If the call still fails, the tool error is the last attempt's message, unchanged.

Bound time arguments under asOf ​

Declare timeArgs with the input properties that carry time bounds. In a session created with asOf, every named argument must be an absolute ISO-8601 instant with a timezone, no later than the session's frozen instant. A call that omits one, or supplies a relative or later value, is refused with an error telling the model to retry with absolute bounds. Sessions without asOf ignore the declaration.

ts
export default defineTool({
  description: "Query telemetry between two instants.",
  inputSchema: z.object({ since: z.string(), until: z.string() }),
  effect: "read",
  timeArgs: ["since", "until"],
  async execute({ since, until }) {
    return queryTelemetry(since, until);
  },
});

Every name in timeArgs must exist in the input schema; a session with asOf refuses to start otherwise, because a misspelled name would silently check nothing.

Define an agent tool ​

Set execution: "agent" and the tool materializes as a shell script that runs in the Cursor agent workspace. 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.

ts
import { defineTool } from "@grok/agent-sdk/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"
`,
});

Scripts land in the session workspace with a catalog in AGENTS.md.

Tools from an MCP connection, advertised by name ​

Set advertiseTools: true on a connection to expose its MCP tools by name. Add per-session auth when credentials depend on the caller. Model calls and direct tool calls use the same exposed names. See MCP connections for the full contract.

Call a tool without a model turn ​

Server tools can be called deterministically. You pick the tool and the input. Validation and execution match 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:

text
agent-sdk tool call inspect_pr --dir . \
  --input '{"prUrl":"https://github.com/acme/checkout/pull/42"}'
agent-sdk tool 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:

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

Omit both identifiers to run against an ephemeral workspace that is removed when the call returns. Pass sessionId (HTTP body, --session, or options.sessionId) or continuationToken (<channelId>:<key> as /v1/sessions lists it) to bind the call. The two are mutually exclusive.

RuleWhat happens
Existing sessionId, or a token that maps to an existing sessionSession workspace, owner check, and event recording
Token with no matching sessionScratch workspace; the token's channel id and key are the call identity
Busy session, effect: "read" (or an advertised MCP tool marked read-only)Runs alongside the turn, reading the workspace as the turn left it; recorded under its own turnId
Busy session, write, idempotent, or undeclared409 session_busy until the turn finishes
Session workspace cannot be created, readScratch workspace; the outcome has scratchWorkspace: true
Session workspace cannot be created, write500 workspace_unavailable
ErrorWhen
404 unknown_toolName is not a callable server tool; the response lists available names
400 tool_not_callableAgent-execution tools cannot run on the host
400 invalid_tool_inputZod rejected the input before execute. Plain JSON Schema is forwarded unvalidated
400 invalid_continuation_tokenMalformed token, or passed together with sessionId
404 session_not_foundsessionId does not exist
409 session_busyWrite-effect call while a model turn is running
500 workspace_unavailableWrite-effect call when the session workspace cannot be created
isError: trueThe tool body threw; 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 tool call before blaming prompts. If the tool's output is wrong, no instruction change fixes it.

Declare each tool's effect so dry-run sessions can execute reads and stub writes.

  • MCP connections: tools that come from MCP servers instead
  • Extensions: tools installed as a package under a namespace
  • Evals: gating tool decisions with calledTool