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_callsfunction_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

Claude Code doesn’t speak Chat Completions tools natively. We inject a small Cursor tool protocol into the prompt and parse fenced blocks back into OpenAI tool calls:

```cursor_tool_call
{"name":"tool_name","arguments":{}}
```

The bridge scans content and reasoning so those fences never leak as chat text. Disabled entirely when GATEWAY_PROVIDERS omits claude.

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 fence bridgeinternal/claude/tools.go, tool_bridge.go
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.