ADR-002: Cursor Cloud Agent programmatic API (MAI-5 spike)
Date: 2026-08-15
Status: Accepted; MAI-6 implemented against @cursor/sdk@1.0.28
Repos: Mainark (docs/architecture/)
Sources (verified 2026-08-15):
- Cloud Agents API endpoints
- TypeScript SDK
- Cloud environment setup
- Cursor SDK skill (local copy under
~/.cursor/skills-cursor/sdk)
Decision
For MAI-6 CursorCloudExecutor, prefer the TypeScript @cursor/sdk cloud runtime (Agent.create + cloud: { repos, … }) as the primary control surface. Use the REST https://api.cursor.com/v1/agents API as the wire-level reference and as a fallback when SDK coverage lags.
Do not design Mainark around the legacy v0 Cloud Agents API. Do not assume webhooks on v1 (documented as “coming soon”; v0 still has webhooks).
Model: Agent vs Run
| Concept | ID shape | Meaning for Mainark |
|---|---|---|
| Agent | bc-… (cloud) | Durable workspace + conversation. Maps to optional agent_runs.metadata_json.agentId (or a future column). |
| Run | run-… | One prompt / implement attempt. Maps to agent_runs.external_run_id. |
This matches Mainark’s issue ≠ run invariant: one FUL/MAI issue can have many agent_runs; each cloud implement attempt is one run (optionally reusing the same agent for fix follow-ups).
Auth
| Item | Fact |
|---|---|
| Credential | CURSOR_API_KEY (user key or team service account key) |
| Where | Dashboard → API Keys / team service accounts |
| HTTP | Basic (-u API_KEY:) or Bearer |
| Not supported (yet) | Team Admin API keys for this surface |
Mainark must fail closed if the key is missing; never silently fall back to local when cloud was requested.
Create (dispatch)
SDK (preferred)
import { Agent } from "@cursor/sdk";
await using agent = await Agent.create({
apiKey: process.env.CURSOR_API_KEY!,
model: { id: "composer-2.5" }, // pass explicitly; cloud may default, local requires it
cloud: {
repos: [
{
url: "https://github.com/org/fulreach",
startingRef: "main", // or feature branch / SHA
},
],
autoCreatePR: true,
// envVars?: Record<string, string> // beta; names must not start with CURSOR_
},
});
const run = await agent.send(implementBrief);
// agent.agentId → bc-…
// run.id → run-…
Critical trap: omitting cloud / local defaults to local. MAI-6 must set cloud: { … } explicitly.
REST
POST /v1/agents with prompt, optional model, repos[] (url, startingRef, optional prUrl), autoCreatePR, optional env / envVars, MCP, etc. Response returns durable agent + initial run.
Status / poll
| Mechanism | Use |
|---|---|
await run.wait() / Agent.getRun(runId) | Primary completion path for MAI-6 |
GET /v1/agents/{id} | Agent metadata; execution status lives on runs (latestRunId) |
GET /v1/agents/{id}/runs/{runId} | Terminal result, durationMs, git.branches[] / prUrl |
GET …/runs/{runId}/stream (SSE) | Live progress; resume with Last-Event-ID; may 410 after retention → fall back to Get Run |
Run statuses (API): e.g. CREATING / RUNNING / FINISHED / …
SDK RunStatus: running | finished | error | cancelled.
Map into Mainark agent_runs.status: queued ← creating; running; succeeded ← finished; failed ← error; cancelled.
Branch + PR
- Default: Cursor pushes an auto branch (
cursor/…) fromstartingRef. autoCreatePR: true(SDK) /autoCreatePR(REST) opens a PR when work lands.- Run/agent
gitsnapshot includes{ repoUrl, branch?, prUrl? }— store onagent_runs.branch/pr_number(parse from URL). workOnCurrentBranch: truepushes to the starting ref / PR head instead of a new branch (use carefully).
Cancel
- SDK:
run.cancel()ifrun.supports("cancel"); alsoAgent.cancelRun(runId). - REST:
POST /v1/agents/{agentId}/runs/{runId}/cancel
Already-terminal →409 run_not_cancellable.
Mainark AgentExecutor.cancel should call this and set agent_runs.status=cancelled without changing work_status.
Cost / tokens
| Surface | Tokens | Dollar cost |
|---|---|---|
Live run (result.usage / stream usage) | Yes (TokenUsage) | No |
REST GET /v1/agents/{id}/usage | Yes (per run) | Not in documented response (tokens only) |
SDK agent.getUsage() / Agent.getUsage(agentId) | Yes | Yes — cost.rawCostCents / chargedCents (may lag after run end; absent until settled) |
MAI-6 mapping to agent_runs:
input_tokens/output_tokens← usage (plus cache fields inmetadata_jsonif useful)cost_usd←chargedCents / 100whengetUsagereturns cost; else nullmodel← selected model id
Do not invent cost from token counts.
Webhooks
- v1: webhooks “coming soon” (as of this spike).
- v0: still documents webhooks — do not build MAI-6 on v0.
Implication: unattended dispatch (MAI-9/10) should poll Get Run / SDK wait, optionally SSE, until webhooks are GA. Design the executor interface so a webhook adapter can later call the same “run finished” path.
Environment (repo side — FUL-867)
Cloud VMs resolve config in order:
- Repo
.cursor/environment.json - Personal saved environment
- Team saved environment
install runs during Builds (idempotent dependency prep). Secrets belong in the Cloud Agents Secrets tab (or session envVars), not in git.
Mainark must pass the repository validation command in the implement brief (pnpm mainark:validate for Fulreach) — never hardcode Nx inside Mainark.
Failure classes (do not conflate)
| Class | Signal | Mainark action |
|---|---|---|
| Startup / auth / config | thrown CursorAgentError | No successful run; retryable flag; keep work_status if already in_progress |
| Run executed and failed | result.status === "error" | agent_runs → failed; no work_status change |
| Cancelled | cancelled | agent_runs → cancelled; no work_status change |
Recommendations for MAI-6
- Implement
CursorCloudExecutorwith@cursor/sdk, alwayscloud: { repos, autoCreatePR, … }. Done (CursorCloudAgentExecutor+SdkCloudAgentClient). - Set
external_run_id = run.id; storeagentId+requestIdinmetadata_json. Done — persist IDs beforewait()so a killed CLI still has Cursor identity. - After terminal status, best-effort
Agent.getUsage(agentId, { runId })to fill cost/tokens. Done (never invent cost from tokens). - Poll/wait only for v1; stub webhook hook for later. Done —
applyCloudRunResult(runId, snapshot)is the shared apply path forwait()andgetStatus()(MAI-9 coordinator should reuse it). - Keep REST client optional for debugging parity with docs. Deferred (SDK-only).
- Gate real dispatch on FUL-867 (env Build green) + secrets documented. Soft gate — unit tests do not call Cursor/Fulreach.
startingRef vs result branch
Cloud clone base is startingRef (DispatchInput.startingRef or GIT_DEFAULT_BRANCH or main). That is not DispatchInput.branch (local working-branch hint). Cursor’s result branch (cursor/…) lands on agent_runs.branch. Resolved SHAs, if Cursor returns them, stay in metadata_json.git — no base_sha column.
Non-goals (this spike)
- No Mainark executor code in this ADR.
- No commitment to a pinned SDK version beyond “current public beta docs”.
- No multi-repo cloud env design (can revisit with MAI-11).
Open items to re-check before coding MAI-6
- Confirm
@cursor/sdkversion published to npm and pin it. (1.0.28; need ≥ 1.0.27 for Bunagent.send().) - Confirm
envVarsbeta enabled for the Fulreach/Cursor org (silent ignore if not). - Confirm service-account key can open PRs on
fulreachGitHub. - Re-read webhook docs if v1 ships before MAI-9.
- Manual proof:
MAINARK_AGENT_EXECUTOR=cursor-cloud mainark dispatch FUL-XXX— issue staysin_progressafter a real PR.