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)
doneasync 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.");
}| Status | Meaning |
|---|---|
queued | Accepted, waiting for a worker. |
running | The agent is working. |
succeeded | Finished. Read result. |
failed | Read error.code. result still holds anything proposed before the failure. |
canceled | Canceled 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.code | Meaning |
|---|---|
agent_error | The turn failed. message has the detail. |
timed_out | Hit the model-time budget or the 10-minute wall clock. |
account_not_connected | The project has no connected Google Ads account. |
key_revoked | The key was revoked between queueing and execution. |
internal | A 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-Keyon 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.