Agent runs

Hand the Ads copilot a plain-English brief over HTTP and poll for the result.

The rest of the API is deterministic: you say what to do, it happens. A run is the other thing — you say what you want, and the Ads copilot decides how. It reads the account, reasons about it, and proposes changes, exactly as it does in the dashboard.

curl -X POST "$BIDANDSCALE_URL/api/v1/runs" \
  -H "Authorization: Bearer $BIDANDSCALE_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "brief": "Find search terms wasting spend in the last 30 days and block the worst ones.",
    "project_id": "'"$PROJECT_ID"'"
  }'
{
  "id": "8f2c1b7e-…",
  "object": "run",
  "status": "queued",
  "poll_url": "https://…/api/v1/runs/8f2c1b7e-…"
}

A turn takes 1–5 minutes, which is longer than any sane HTTP client will hold a connection open. So the call returns 202 Accepted immediately and you poll.

The poll loop

GET /api/v1/runs/{runId} carries Retry-After: 15 while the run is unfinished. Once terminal the header is gone and the response is cached, so a poller that keeps polling costs nothing.

until [ "$STATUS" != "queued" ] && [ "$STATUS" != "running" ]; do
  sleep 15
  STATUS=$(curl -s "$BIDANDSCALE_URL/api/v1/runs/$RUN_ID" \
    -H "Authorization: Bearer $BIDANDSCALE_API_KEY" | jq -r .status)
done
async function waitForRun(runId: string, timeoutMs = 10 * 60_000) {
  const deadline = Date.now() + timeoutMs;

  while (Date.now() < deadline) {
    const res = await fetch(`${base}/api/v1/runs/${runId}`, {
      headers: { Authorization: `Bearer ${apiKey}` },
    });
    const run = await res.json();

    if (!["queued", "running"].includes(run.status)) return run;

    // Honour the server's pacing rather than picking your own.
    const wait = Number(res.headers.get("Retry-After") ?? 15) * 1000;
    await new Promise((r) => setTimeout(r, wait));
  }
  throw new Error("Run did not finish within the timeout.");
}
StatusMeaning
queuedAccepted, waiting for a worker.
runningThe agent is working.
succeededFinished. Read result.
failedRead error.code. result still holds anything proposed before the failure.
canceledCanceled while queued.

The result

The answer text is the least interesting field. actions is why you integrate.

{
  "status": "succeeded",
  "duration_ms": 144000,
  "result": {
    "text": "Over the last 30 days the brand campaign spent €4,180 on …",
    "actions": [
      {
        "id": "3d1a…",
        "kind": "add_negative_keywords",
        "summary": "Add 6 negatives to Brand — Exact",
        "risk_tier": "review",
        "disposition": "queued",
        "policy_reasons": ["Negative keyword adds above 5 terms are reviewed."],
        "approvals_url": "https://…/app/…/approvals"
      }
    ],
    "tool_calls": [
      { "name": "gads_get_search_terms", "input": { "days": 30 }, "ok": true }
    ],
    "usage": { "input_tokens": 41233, "output_tokens": 1820, "steps": 6 }
  },
  "error": null
}

Act on actions with the normal approval endpoints:

curl -X POST \
  "$BIDANDSCALE_URL/api/v1/accounts/$PROJECT_ID/approvals/$ACTION_ID/approve" \
  -H "Authorization: Bearer $BIDANDSCALE_API_KEY"

tool_calls records what the agent looked at, with inputs but not outputs — a single keyword read is tens of kilobytes, and storing those would make every run row enormous. Capped at 50 entries; text is capped at 32,000 characters and sets truncated when it trips.

What a run may do

By default, nothing goes live without you. Every proposal queues, even ones the policy would rate safe.

{ "brief": "…", "project_id": "…", "auto_apply": true }

Opting in lets the safe-apply policy act on bounded, low-risk changes. It still passes four gates: the workspace kill switch, the safe-tier classification, the 20-per-day auto-apply cap, and a re-check by the apply worker. And disposition: "auto_applied" means accepted for apply, not "live" — the worker can still send a change back to the queue.

A run makes at most 10 tool calls, so it cannot propose an unbounded number of changes.

Read keys can run

A read-scoped key starts a run with the write tools removed. "Why did my CPA move last week?" is half the value and carries no risk. Such a run always returns actions: [].

Failure and cancellation

error.codeMeaning
agent_errorThe turn failed. message has the detail.
timed_outHit the model-time budget or the 10-minute wall clock.
account_not_connectedThe project has no connected Google Ads account.
key_revokedThe key was revoked between queueing and execution.
internalA bug on our side.

A failed run keeps whatever it proposed. If it blocked three search terms and then died, those three are still in your approval queue — check result.actions rather than assuming nothing happened.

DELETE /api/v1/runs/{runId} cancels a queued run. A running one cannot be interrupted: the agent executes in a separate worker process. You get a 409, and anything already proposed is in the approval queue.

Notes

  • One run at a time per account. A second returns 409.
  • Runs are one-shot; there is no conversation. Include everything the agent needs in the brief.
  • Idempotency-Key on the POST makes a retry replay the original run instead of starting a second.
  • The full transcript — every tool call and result — lands in the account's history.

On this page