| name | cursor-sdk |
| description | Hard-won knowledge about the `cursor-sdk` Python package — what works, what blows up, and the workarounds that exist in this repo's proxy. Use when writing or modifying code that talks to `cursor_sdk` (AsyncClient, AsyncAgent, Run streams, RunResult), debugging "Bridge request failed" / "Local SDK agents require an explicit model" / "Task was destroyed but it is pending" errors, or evaluating whether to upgrade the pinned version. Skip if the work is plugin-side (ProviderProfile) or pure infrastructure (systemd, sessions cache); those are not SDK concerns. |
cursor-sdk: the gotchas we paid for
This skill captures everything painful we discovered building hermes-cursor-proxy. The cursor-sdk Python docs and TS cookbook are misleading in several specific ways that will eat hours if you trust them at face value. Everything below is verified against cursor-sdk==0.1.5 running in proxy/.venv/. To re-validate after an upgrade, run scripts/test-sdk-contract.sh from the repo root — it asserts each gotcha and prints "good news" when one stops being true.
1. The shape of the SDK
cursor-sdk ships an async + sync Python client with two execution modes:
- Local —
AsyncClient.launch_bridge(workspace=...) spawns a long-running bridge subprocess (the "real" Cursor agent runtime, packaged as a binary) and returns a connected AsyncClient. All requests are then gRPC-over-HTTP/2 to the bridge on a local port. The agent runs inside the bridge subprocess with read/write access to workspace=.
- Cloud — direct API to Cursor's hosted infrastructure. We don't use this; the proxy is local-only.
The Agent (sync) / AsyncAgent are handles to a server-side agent. The actual model + tools execute in the bridge (local) or cloud. You don't drive token-level inference; you send a UserMessage and consume a Run stream.
2. The lies in the docs
AsyncAgent.create() / .resume() REQUIRE client=
The Python docs sample omits it:
with Agent.create(model="composer-2.5", api_key="...", local=LocalAgentOptions(cwd=".")) as agent:
...
Real signature:
AsyncAgent.create(
options: AgentOptions | Mapping[str, Any] | None = None,
*,
client: AsyncClient,
model: str | ModelSelection | Mapping[str, Any] | None = None,
api_key: str | None = None,
name: str | None = None,
local: LocalAgentOptions | Mapping[str, Any] | None = None,
cloud: CloudAgentOptions | Mapping[str, Any] | None = None,
idempotency_key: str | None = None,
) -> AsyncAgent
The convenience top-level constructor we'd expect (Agent.create(...) without a client) does not exist; you must construct the client first. The proxy spawns one shared client at FastAPI startup via lifespan and passes it to every create_agent / resume_agent call.
run.stream() does NOT yield *Update events
The SDK exposes many *Update dataclasses (TextDeltaUpdate, ToolCallStartedUpdate, TurnEndedUpdate, …) — those are tempting and the names suggest they're what you iterate. They are not.
run.stream() yields SDK*Message dataclasses with these top-level .type strings:
.type | Class | Notes |
|---|
"assistant" | SDKAssistantMessage | Has .message.content which is a sequence of content blocks (TextBlock, ToolUseBlock). Streams as deltas — each event carries a chunk of text, not the cumulative reply. |
"status" | SDKStatusMessage | Bookends and intermediate state. .status is "running" / "completed" / "error" / etc. |
"tool_call" | SDKToolUseMessage | Composer's internal tool invocations — narration of what its own tools did. Has .name, .status, .args, .result. |
"thinking" | SDKThinkingMessage | Reasoning trace. |
"system" | SDKSystemMessage | Agent metadata, model selection. |
"task" | SDKTaskMessage | Higher-level task status. |
"user" | SDKUserMessage | Echo of user input. |
The *Update classes come from run.events() (a different iterator we don't use). Most callers want stream(); just don't expect Update shapes from it.
There is NO turn-ended event in run.stream(). The stream simply closes when the turn is done. So "did this turn end cleanly?" = "did the async iterator return without raising?".
run.wait() after run.stream() consumption used to kill the bridge — now safe but useless
In some earlier state (we hit it during this repo's debugging), calling await run.wait() after fully consuming run.stream() caused the bridge subprocess to die with NetworkError: peer closed connection without sending complete message body. Every subsequent request then failed with ConnectError: All connection attempts failed until manual restart.
Current state (cursor-sdk==0.1.5, verified 3-of-3 isolated trials in scripts/test-sdk-contract.sh check c): run.wait() after consumption returns cleanly. But it's still pointless to call it — RunResult carries only:
RunResult(
id: str,
agent_id: str,
status: RunResultStatus | RunStatus,
result: str = '',
model: ModelSelection | None = None,
duration_ms: int = 0,
git: RunGitInfo | None = None,
created_at: str | None = None,
)
No token counts. If you want usage metrics, encode locally with tiktoken (cl100k_base is close enough for Composer) — see bridge.py:_estimate_usage. This is strictly better than wait() would give you anyway.
Resumed local agents lose their ModelSelection
agent = await client.resume_agent(cached_agent_id)
run = await agent.send("hi")
The fix is to always pass model via SendOptions on every send(), including cold-start (harmless there, required on resume):
from cursor_sdk import SendOptions, ModelSelection
run = await agent.send(text, SendOptions(model=ModelSelection(id="composer-2.5")))
Reasoning effort and other per-model params live on ModelSelection.params:
from cursor_sdk import ModelParameterValue
ModelSelection(
id="composer-2.5",
params=[ModelParameterValue(id="thinking", value="high")],
)
Valid thinking values for Composer: "low" | "medium" | "high". Unknown values are silently ignored by the SDK (we log + drop them in app.py).
3. The bridge subprocess is the operational risk
The local-mode bridge subprocess (AsyncClient.launch_bridge) dies silently under load with RemoteProtocolError: peer closed connection without sending complete message body (incomplete chunked read). After death every SDK call raises NetworkError: All connection attempts failed. There is no automatic recovery in the SDK.
Two distinct failure modes (the old single-cause framing was wrong)
Log analysis on a soak-tested bridge showed stream death happens in two structurally different ways. They look identical in /metrics (both increment bridge_relaunches_total) but have different root causes:
Mode A — bridge-generation mismatch on resume. When the bridge relaunches, the new process has no in-memory state for any agent_id the old process created. But sessions.db still holds those mappings. The next ~N requests that hit Resume on those stale IDs get HTTP 200 from agent.send() (the bridge accepts the call) then RemoteProtocolError ~300–1000ms later when it tries to actually replay state it doesn't have. Observed cluster: 1 relaunch → 3 stream deaths in 36s, each on an agent born in a now-dead generation.
Mode B — same-bridge resume of an idle agent. Bridge process unchanged, agent had been idle ~60–90s, resume dies ~800ms after Send returns 200. Likely Cursor server-side GC of dormant agents. Can't be prevented from the client side.
Both modes share the diagnostic signature: Send succeeds → stream raises NetworkError very fast (well under any reasonable token-generation budget). The discriminator is whether the bridge generation changed since the cached agent was created.
The four-layer defense in app.py
- Background keepalive (
_bridge_keepalive_loop): client.ping() every 10s with a 2s timeout. On failure, calls _ensure_bridge_alive to relaunch. Verified empirically that this masks (not prevents) bridge deaths — see /metrics output: under back-to-back load you'll see bridge_relaunches_total climb.
- Per-request bridge-NetworkError retry (
_run_with_bridge_retry): catches NetworkError("All connection attempts failed") from agent.send(), relaunches, retries once.
- Per-request cold-create retry: same wrapping around
client.create_agent for the path where bridge dies between requests.
- Invisible cold-start replay (
_cold_start_replay, added in Opsi 4): when run.stream() dies mid-flight BEFORE any content reached the client (only the empty role-opener was sent), the proxy forgets the cached agent_id, ensures a live bridge, creates a fresh agent, sends the initial prompt, and resumes streaming on the new run. The client sees only ~3–4s of added latency, never an error marker. This covers BOTH Mode A and Mode B because both die before any token is generated. Capped at one replay; a second failure surfaces normally.
The replay gate is state.saw_any_text == False. After even one content chunk reaches the client, replay would corrupt their view, so we fall back to emitting _[stream error: NetworkError]_ inline (the pre-Opsi-4 behavior).
Counters that distinguish the layers in /metrics:
stream_died_total — visible failures (no replay possible because content already shipped)
replay_recoveries_total — invisible recoveries (Opsi 4 caught a Mode A or Mode B)
bridge_relaunches_total — bridge process restarts (with bridge_last_relaunch_cause ∈ keepalive | request-create | request-send | replay)
In production validation, 2 organic bridge deaths during an 11-check integration test were fully masked by layer 4 — replay_recoveries_total=2, stream_died_total=0, all 11 user-visible checks passed.
What still NOT covered: bridge death mid-stream AFTER content has been yielded. The user sees _[stream error: NetworkError]_ appended and finish_reason=error. The proxy still:
- sets
state.stream_died = True
- calls
sessions.forget(key) in _persist_if_clean so the next turn cold-starts cleanly
Bridge keepalive ping (client.ping()) is the cheapest health probe. Don't use client.me() for this purpose — it's a real API call that bills against your quota.
4. Errors and how to handle them
Cursor's cursor_sdk.errors exports the full hierarchy. Map them to HTTP status codes; the proxy does this in app.py:_CURSOR_ERROR_MAP:
| Class | HTTP | OpenAI error type |
|---|
AuthenticationError | 401 | authentication_error |
PermissionDeniedError | 403 | permission_error |
RateLimitError | 429 | rate_limit_error |
NotFoundError | 404 | not_found_error |
BadRequestError | 400 | invalid_request_error |
APITimeoutError | 504 | timeout_error |
NetworkError | 502 | network_error |
InternalServerError | 502 | upstream_error |
Safe-to-log finding: cursor_sdk.errors.CursorAgentError.__str__ is f"{self.code}: {self.message}". The API key lives on self.headers / self.cause and is never interpolated into the message. So log.warning("...", str(exc)[:80]) is safe — no key leak. (We verified this in security review round 4. Confirmed for 0.1.5; re-verify on upgrade.)
NotFoundError / BadRequestError / InternalServerError on resume typically mean the cached agent_id is stale on Cursor's side. The proxy's _do_send_with_poison_guard handles this: catch, sessions.forget(key), recreate as cold-start. Don't propagate these straight to the user without retrying.
5. Patterns that work in this repo
Lifespan-scoped client
@asynccontextmanager
async def lifespan(app: FastAPI):
client = await AsyncClient.launch_bridge(workspace=str(cwd))
app.state.sdk_client = client
try:
yield
finally:
try: await client.shutdown()
except Exception: log.exception("client.shutdown failed")
try: await client.aclose()
except Exception: log.exception("client.aclose failed")
shutdown() tells the bridge to stop accepting new requests; aclose() closes the HTTP transport. Both are best-effort; if the bridge already died they raise, which is fine.
Iteration with disconnect-aware cancellation
async def _iter_with_keepalive(run, req=None, interval=15.0):
aiter = run.stream().__aiter__()
loop = asyncio.get_running_loop()
next_task = loop.create_task(aiter.__anext__())
deadline = loop.time() + interval
while True:
remaining = max(0.0, deadline - loop.time())
done, _ = await asyncio.wait({next_task}, timeout=min(remaining, 1.0))
if req is not None and await req.is_disconnected():
next_task.cancel()
try: await asyncio.shield(next_task)
except (asyncio.CancelledError, StopAsyncIteration, Exception): pass
return
if done:
try:
yield next_task.result()
except StopAsyncIteration:
return
next_task = loop.create_task(aiter.__anext__())
deadline = loop.time() + interval
Three things this gets right:
- Disconnect polled every ~1s, not every keepalive interval (older code blocked up to 15s).
- The cancelled task is awaited before returning — without this, CPython logs
Task was destroyed but it is pending and abandoned coroutine state leaks.
asyncio.CancelledError is BaseException (not Exception) in 3.8+, so it propagates through an except Exception block cleanly to the outer except CancelledError.
Replay-aware streaming composition (Opsi 4)
The cursor_stream_to_sse monolith was split into three primitives so the caller can drive a one-shot replay loop without double-emitting the role-opener or the finalize frames:
def sse_role_opener(state) -> str: ...
async def cursor_stream_body_to_sse(run, state, req): ...
def sse_finalize(state) -> Iterable[str]: ...
async def cursor_stream_to_sse(run, state, req): ...
cursor_stream_body_to_sse is the only one that can set state.recoverable_failure. It does so when (a) the stream raises and (b) state.saw_any_text is still False. Otherwise it falls back to the legacy behaviour of emitting an inline _[stream error: ...]_ and setting finish_reason="error".
Caller pattern in app.py:create_chat_completion:
yield sse_role_opener(state)
async for chunk in cursor_stream_body_to_sse(run, state, req=req):
yield chunk
if state.recoverable_failure:
state.recoverable_failure = False
state.stream_died = False
replay_run = await _cold_start_replay()
async for chunk in cursor_stream_body_to_sse(replay_run, state, req=req):
yield chunk
if state.recoverable_failure:
state.finish_reason = "error"
yield sse_chunk(state, delta={"content": "\n\n_[stream error: NetworkError]_"})
for chunk in sse_finalize(state):
yield chunk
_cold_start_replay() rebinds final_agent and final_agent_id via nonlocal so _persist_if_clean() records the NEW agent (the original handle has already been best-effort closed). Without the nonlocal rebind, the next turn would resume into the dead agent_id we just discarded.
The blocking path mirrors this — cursor_run_to_blocking returns {} placeholder when recoverable_failure is set; the caller checks the flag, runs _cold_start_replay(), calls cursor_run_to_blocking again with the new run, and uses that payload.
Processing message events
def _process_event(ev, state):
etype = getattr(ev, "type", None)
if etype == "assistant":
msg = getattr(ev, "message", None)
if msg is None: return None
out = []
for block in getattr(msg, "content", ()) or ():
btype = getattr(block, "type", None)
if btype == "text":
t = getattr(block, "text", "") or ""
if t: out.append(t)
elif btype == "tool_use":
...
return "".join(out) or None
if etype == "tool_call":
...
if etype == "status":
status = str(getattr(ev, "status", "") or "").lower()
if status in ("error", "failed", "cancelled", "canceled"):
state.finish_reason = "error"
Discriminate by .type (string), not isinstance. The class hierarchy is large and may evolve; the .type field is stable.
6. Architectural mismatch (the one you can't fix)
Cursor Composer is itself an agent: it runs its own internal tools (Read, Edit, Bash, …) and returns one finished response per agent.send(). There is no way to pause it between sub-actions and surrender control to a host harness.
This matters when the host (Hermes, in our case) is also an agent harness expecting model-emitted tool_calls between text deltas. With Composer-as-model:
- Host's tool ecosystem (Hermes'
Edit/Bash/MCP/skills) sits out — Composer ran its own tools, host never sees them.
- Host UIs that split bubbles on agent-loop turns (WhatsApp, Telegram) get one bubble per Composer reply, regardless of how many tools Composer used internally.
- Mid-session system-prompt changes don't propagate. Composer's first-turn system context is canonical; later
<SYSTEM> blocks injected on resume turns are interpreted as user-turn roleplay, not as updated instructions. The proxy detects this and returns X-Hermes-System-Mutated: ignored so callers can re-create the session if they care.
Recognize this early. The cleanest answer is "use Composer when you want Composer's agency; use a tool-emitting model when you want the host's agency."
7. Pinning + upgrade workflow
proxy/pyproject.toml pins cursor-sdk==0.1.5. Don't relax to >= — every gotcha in this skill could break with a non-patch bump.
When you do upgrade:
- Bump the pin in
proxy/pyproject.toml.
proxy/.venv/bin/pip install -e proxy.
- Run
scripts/test-sdk-contract.sh. It costs a few cents in real Cursor API calls. Each check (a through d) is annotated with what it asserts and what it means if it changes (e.g. check c flagging "workaround can be dropped" if run.wait() becomes useful).
- Run
scripts/test-roundtrip.sh for the full HTTP integration.
- If anything changed: update this skill, then update
proxy/src/hermes_cursor_proxy/bridge.py and app.py to match.
8. Where things live in this repo
proxy/src/hermes_cursor_proxy/app.py — FastAPI app, lifespan, retry logic, _cold_start_replay() for invisible recovery, observability counters, /health, /metrics.
proxy/src/hermes_cursor_proxy/bridge.py — Event translation, token estimation, system-prompt drift detection, and the three streaming primitives: sse_role_opener, cursor_stream_body_to_sse (replay-aware), sse_finalize. cursor_stream_to_sse is the backward-compat composite.
proxy/src/hermes_cursor_proxy/sessions.py — sqlite-backed key → agent_id map with system_hash column.
scripts/test-sdk-contract.sh — The four-assertion contract test against the live SDK.
scripts/test-roundtrip.sh — Eleven-check HTTP integration test against the running proxy. Has caught organic Mode A failures during validation runs — they now show up as replay_recoveries_total increments rather than test failures.
model-providers/cursor-composer/__init__.py — The Hermes ProviderProfile declaration.
StreamState (in bridge.py) carries two failure-mode flags worth knowing:
stream_died — bridge died mid-stream; caller's _persist_if_clean should forget the cached agent_id.
recoverable_failure — same death but BEFORE any content reached the client; caller can transparently cold-start and replay.
9. Inspect-the-SDK reflex
When in doubt, introspect against the installed package. From the repo root:
proxy/.venv/bin/python -c "
import inspect, cursor_sdk
print(inspect.signature(cursor_sdk.AsyncAgent.create))
print(inspect.signature(cursor_sdk.AsyncClient.launch_bridge))
"
The SDK ships type hints on most signatures. Trust the introspection over the docs.