Skip to main content

Cursor Agent tool calling

Cursor Agent talks to custom OpenAI endpoints with Chat Completions tools — the same shape OpenRouter and friends use. This proxy accepts that wire format, translates it for Codex / Claude Code / Antigravity, then streams Cursor-safe delta.tool_calls so Agent can run tools locally and keep going.

The tools still run inside Cursor (read file, shell, and so on). We never execute workspace tools here; we just carry the protocol.

Read this when you need the exact contracts, or when you’re changing the Go adapters.

What Cursor expects​

Agent only works against a backend that:

  1. Accepts tools, tool_choice, and parallel_tool_calls on POST /v1/chat/completions
  2. Streams complete delta.tool_calls frames and ends with finish_reason: "tool_calls"
  3. Accepts follow-ups with assistant.tool_calls plus matching role: "tool" results
  4. Keeps tool-call IDs stable across turns
  5. Speaks type: "function" tool calls by default (Cursor’s BYOK parser drops type: "custom")

That’s what we do, while authenticating upstream with local CLI/OAuth — not OpenAI sk- keys.

Out of scope

  • /v1/responses (Cursor may probe it; we don’t implement it)
  • Running Cursor’s tools on the server
  • Spreading one chat’s tool turns across random Codex shards (routing stays sticky per chat)

How an Agent turn flows​

sequenceDiagram
participant Cursor
participant API as Open Agent API
participant Up as Upstream (Codex / Gemini / Claude)

Cursor->>API: POST /v1/chat/completions<br/>tools + messages
Note over API: Client tools → minimal mode
API->>API: Normalize tools, context, queue
API->>Up: Provider-specific tool request
Up-->>API: Provider tool events
API->>API: Accumulate complete tool calls
API-->>Cursor: SSE role + delta.tool_calls
API-->>Cursor: finish_reason=tool_calls + [DONE]
Note over Cursor: Run tools locally
Cursor->>API: Continuation with role:tool results
API->>Up: Paired call IDs
Up-->>API: Final assistant text
API-->>Cursor: SSE content + finish_reason=stop

Requests from Cursor​

Fields that matter​

FieldWhat Cursor sendsWhat we do
toolsNested Chat Completions functionsAccept; log as nested / flat / mixed
tool_choiceauto / none / required or a forced functionPass through / flatten per provider
parallel_tool_callsOften trueHonor when the upstream allows
messagesPrior tool_calls + role: "tool"Map into provider history
streamUsually true in AgentUse the Cursor-safe SSE accumulator

Typical nested tool from Cursor:

{
"type": "function",
"function": {
"name": "read_file",
"description": "...",
"parameters": { "type": "object", "properties": { "path": { "type": "string" } } }
}
}

Custom / freeform tools are also accepted:

{
"type": "custom",
"custom": {
"name": "apply_patch",
"description": "...",
"parameters": { "type": "object", "properties": { "input": { "type": "string" } } }
}
}

Wire sniffing lives in internal/server/cursor_wire.go.

Why “faithful” Codex mode turns off​

When the client sends tools (Agent always does), we default to minimal mode (faithful=false). Faithful mode injects the captured Codex CLI tool profile, which clashes with Cursor’s own tools and usually blows up upstream. You can force "faithful": true in the JSON body — Agent shouldn’t need that.

Tool-capable requests also go through the agent queue (CODEX_AGENT_QUEUE_KEY_MODE=cursor by default), with optional context compaction on long histories.

Continuations after a tool runs​

After Cursor executes tools, the next request must include:

  1. The prior assistant message with the same tool_calls IDs
  2. One role: "tool" message per result, with matching tool_call_id
{
"model": "gpt-5.6-terra",
"stream": true,
"tools": [{ "type": "function", "function": { "name": "read_file", "parameters": { "type": "object" } } }],
"messages": [
{ "role": "user", "content": "Read go.mod" },
{
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": { "name": "read_file", "arguments": "{\"path\":\"go.mod\"}" }
}
]
},
{ "role": "tool", "tool_call_id": "call_123", "content": "module github.com/teslashibe/open-agent-api\n..." }
]
}

Responses Cursor can parse​

Streaming (the important path)​

Cursor’s BYOK parser is picky. We don’t forward every upstream argument fragment as a tiny OpenAI delta. We:

  1. Emit delta.role = "assistant"
  2. Accumulate each tool call until name + arguments are complete
  3. Emit one full delta.tool_calls frame per call
  4. Emit finish_reason: "tool_calls"
  5. End with data: [DONE]

Each tool-call delta must include:

FieldRule
indexStable, first-seen order
idNon-empty
type"function" by default for BYOK
function.nameNon-empty
function.argumentsFull string; valid JSON for function tools (default "{}")

Empty tool deltas are dropped. Bad JSON arguments become an error chunk, not a fake tool_calls finish.

Implemented in internal/server/stream_processor.go; locked in by assertExactCursorToolSSE in the server tests.

Custom tools and CODEX_CUSTOM_TOOL_WIRE​

Some upstreams emit freeform custom tool calls (for example apply_patch). Cursor BYOK drops type: "custom", so the default is to rewrite them as functions:

CODEX_CUSTOM_TOOL_WIREWire to Cursor
function (default)type: "function", freeform text in function.arguments
customKeep type: "custom" with custom.name / custom.input

How each upstream is adapted​

Codex / ChatGPT​

Chat Completions nested tools become flat Responses tools:

{"type":"function","function":{"name":"X","parameters":{...}}}
→ {"type":"function","name":"X","parameters":{...}}

History maps tool_calls → function_call and role: "tool" → function_call_output. Codex caps call_id at 64 characters; longer Cursor IDs are hashed so the pair still matches. Event shapes: docs/codex-tool-events.md.

Gemini / Antigravity​

Tools become Gemini functionDeclarations. Rejected JSON Schema keywords are stripped; custom tools get an input: string fallback when needed. Same Cursor-facing SSE rules on the way out.

Claude Code​

Cursor's tools are handed to the pinned claude CLI as real tools through a stdio MCP bridge (open-agent-api claude-mcp), so the model emits native tool_use blocks, including parallel calls. The CLI runs with --permission-mode dontAsk --max-turns 1: it never executes a tool, and the gateway stops it at the end of the model turn. Each tool_use becomes one complete delta.tool_calls frame with the upstream toolu_… ID and the mcp__c__ prefix stripped.

History is replayed natively. Earlier turns are written as a short-lived CLI transcript and loaded with --resume, and the final assistant tool_use turn plus its tool_result turn go over --input-format stream-json, so the model sees real call/result pairs and Cursor's call IDs. Orphaned calls or results and repeated IDs are repaired first. Set CLAUDE_HISTORY_MODE=text to flatten history instead if a CLI upgrade changes the transcript format.

Each request runs in an isolated, empty working directory with Cursor's system prompt replacing Claude Code's (--system-prompt-file), no built-in tools, and --setting-sources= / --strict-mcp-config, so host hooks, plugins, MCP servers and CLAUDE.md do not apply. tool_choice: "none" drops the tools; a forced function becomes a system instruction; parallel_tool_calls: false asks for one call per turn. In Cursor use api/claude-* model IDs (bare claude-* names go to Cursor's Anthropic key). Design notes: docs/specs/claude-code-native-tools.md.

Config that affects Agent tools​

SettingDefaultEffect
CODEX_CUSTOM_TOOL_WIREfunctionKeep Cursor’s parser happy with custom tools
CODEX_AGENT_QUEUE_*on, per-key 1One active Agent stream per chat
CODEX_CONTEXT_MANAGEMENT_*enabledCompact long tool histories without breaking pairs
GATEWAY_PROVIDERScodex,gemini,claudeWhich surfaces serve tool turns

How to verify it works​

  1. Start the API with ngrok (BYOK setup) — not localhost.
  2. New Agent chat: List the files in this repo.
  3. Logs should show tools_present=true and queue acquire/release.
  4. Several tool rounds in the same chat should keep going without Cursor switching providers.
  5. go test ./internal/server ./internal/codex ./internal/claude ./internal/gemini

Code map​

AreaFiles
Request wireinternal/server/cursor_wire.go
Minimal mode + routinginternal/server/server.go
Cursor-safe SSEinternal/server/stream_processor.go
OpenAI typesinternal/openai/openai.go
Codex normalize + call IDsinternal/codex/builder.go, events.go
Gemini toolsinternal/gemini/builder.go, events.go
Claude native toolsinternal/claude/ (conversation.go, transcript.go, events.go, mcpbridge/)
Contract testsinternal/server/server_test.go

Changing this safely​

Keep the Cursor SSE contract (one complete tool frame, no empty delta.tool_calls, finish_reason: "tool_calls" before [DONE]). Leave the custom→function default unless your client understands type: "custom". Pair call IDs across turns. Don’t re-enable faithful Codex tools for Agent requests that already send tools. Update tests and this page when wire shapes change — and never log tool arguments or prompt text.

Claude preserves top-level anyOf, oneOf and allOf constraints through an input wrapper, then returns the original argument shape to the client. Normal object schemas retain local $defs and $ref. Schemas that need wrapping and contain references return HTTP 400 before provider work, because wrapping would change the reference root. Boolean or non-object schema documents are rejected explicitly. Scalar and array types within a schema object remain supported.