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):

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

ConceptID shapeMeaning for Mainark
Agentbc-… (cloud)Durable workspace + conversation. Maps to optional agent_runs.metadata_json.agentId (or a future column).
Runrun-…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

ItemFact
CredentialCURSOR_API_KEY (user key or team service account key)
WhereDashboard → API Keys / team service accounts
HTTPBasic (-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

MechanismUse
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/…) from startingRef.
  • autoCreatePR: true (SDK) / autoCreatePR (REST) opens a PR when work lands.
  • Run/agent git snapshot includes { repoUrl, branch?, prUrl? } — store on agent_runs.branch / pr_number (parse from URL).
  • workOnCurrentBranch: true pushes to the starting ref / PR head instead of a new branch (use carefully).

Cancel

  • SDK: run.cancel() if run.supports("cancel"); also Agent.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

SurfaceTokensDollar cost
Live run (result.usage / stream usage)Yes (TokenUsage)No
REST GET /v1/agents/{id}/usageYes (per run)Not in documented response (tokens only)
SDK agent.getUsage() / Agent.getUsage(agentId)YesYes — 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 in metadata_json if useful)
  • cost_usd ← chargedCents / 100 when getUsage returns cost; else null
  • model ← 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:

  1. Repo .cursor/environment.json
  2. Personal saved environment
  3. 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)

ClassSignalMainark action
Startup / auth / configthrown CursorAgentErrorNo successful run; retryable flag; keep work_status if already in_progress
Run executed and failedresult.status === "error"agent_runs → failed; no work_status change
Cancelledcancelledagent_runs → cancelled; no work_status change

Recommendations for MAI-6

  1. Implement CursorCloudExecutor with @cursor/sdk, always cloud: { repos, autoCreatePR, … }. Done (CursorCloudAgentExecutor + SdkCloudAgentClient).
  2. Set external_run_id = run.id; store agentId + requestId in metadata_json. Done — persist IDs before wait() so a killed CLI still has Cursor identity.
  3. After terminal status, best-effort Agent.getUsage(agentId, { runId }) to fill cost/tokens. Done (never invent cost from tokens).
  4. Poll/wait only for v1; stub webhook hook for later. Done — applyCloudRunResult(runId, snapshot) is the shared apply path for wait() and getStatus() (MAI-9 coordinator should reuse it).
  5. Keep REST client optional for debugging parity with docs. Deferred (SDK-only).
  6. 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/sdk version published to npm and pin it. (1.0.28; need ≥ 1.0.27 for Bun agent.send().)
  • Confirm envVars beta enabled for the Fulreach/Cursor org (silent ignore if not).
  • Confirm service-account key can open PRs on fulreach GitHub.
  • Re-read webhook docs if v1 ships before MAI-9.
  • Manual proof: MAINARK_AGENT_EXECUTOR=cursor-cloud mainark dispatch FUL-XXX — issue stays in_progress after a real PR.