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:
- Accepts
tools,tool_choice, andparallel_tool_callsonPOST /v1/chat/completions - Streams complete
delta.tool_callsframes and ends withfinish_reason: "tool_calls" - Accepts follow-ups with
assistant.tool_callsplus matchingrole: "tool"results - Keeps tool-call IDs stable across turns
- Speaks
type: "function"tool calls by default (Cursor’s BYOK parser dropstype: "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
| Field | What Cursor sends | What we do |
|---|---|---|
tools | Nested Chat Completions functions | Accept; log as nested / flat / mixed |
tool_choice | auto / none / required or a forced function | Pass through / flatten per provider |
parallel_tool_calls | Often true | Honor when the upstream allows |
messages | Prior tool_calls + role: "tool" | Map into provider history |
stream | Usually true in Agent | Use 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:
- The prior assistant message with the same
tool_callsIDs - One
role: "tool"message per result, with matchingtool_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:
- Emit
delta.role = "assistant" - Accumulate each tool call until name + arguments are complete
- Emit one full
delta.tool_callsframe per call - Emit
finish_reason: "tool_calls" - End with
data: [DONE]
Each tool-call delta must include:
| Field | Rule |
|---|---|
index | Stable, first-seen order |
id | Non-empty |
type | "function" by default for BYOK |
function.name | Non-empty |
function.arguments | Full 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_WIRE | Wire to Cursor |
|---|---|
function (default) | type: "function", freeform text in function.arguments |
custom | Keep 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
| Setting | Default | Effect |
|---|---|---|
CODEX_CUSTOM_TOOL_WIRE | function | Keep Cursor’s parser happy with custom tools |
CODEX_AGENT_QUEUE_* | on, per-key 1 | One active Agent stream per chat |
CODEX_CONTEXT_MANAGEMENT_* | enabled | Compact long tool histories without breaking pairs |
GATEWAY_PROVIDERS | codex,gemini,claude | Which surfaces serve tool turns |
How to verify it works
- Start the API with ngrok (BYOK setup) — not localhost.
- New Agent chat:
List the files in this repo. - Logs should show
tools_present=trueand queue acquire/release. - Several tool rounds in the same chat should keep going without Cursor switching providers.
go test ./internal/server ./internal/codex ./internal/claude ./internal/gemini
Code map
| Area | Files |
|---|---|
| Request wire | internal/server/cursor_wire.go |
| Minimal mode + routing | internal/server/server.go |
| Cursor-safe SSE | internal/server/stream_processor.go |
| OpenAI types | internal/openai/openai.go |
| Codex normalize + call IDs | internal/codex/builder.go, events.go |
| Gemini tools | internal/gemini/builder.go, events.go |
| Claude native tools | internal/claude/ (conversation.go, transcript.go, events.go, mcpbridge/) |
| Contract tests | internal/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.