Extend
Build an integration (for coding agents)
TL;DR
To connect another system to Agent Planners, pick a credential, read GET /api/v1/capabilities, find tools with GET /api/v1/tools?q=…&schema=1 and run them with POST /api/v1/tools/{name}. A 202 means a person must approve the write; the six verify checks below prove the integration works.
Decide: MCP, an API key or an OAuth app
| You are building | Use | Credential |
|---|---|---|
| An AI assistant or coding agent session that works for a person (Claude, Codex, Cursor, Grok …) | The MCP server https://www.agentplanners.com/api/mcp — see Connect Agent Planners to Claude, Codex, Grok and Muse | Sign in with OAuth (the host does it), or an API key |
| A script, job or backend that works in your own workspace | The REST API https://www.agentplanners.com/api/v1 | An API key from API & MCP → New key |
| An app people sign in to, in your own workspace | The REST API | A private OAuth app (API & MCP → Your own OAuth apps, Only this workspace) |
| A product that many Agent Planners workspaces connect | The REST API | A published OAuth app (Any workspace on Agent Planners); each customer's admin approves it and your server uses that customer's token |
Every option reaches the same tools, scopes, approvals, guardrails, credits and audit trail. Platform credentials (Google, Meta, store tokens) never leave Agent Planners.
The call sequence
- 1Authenticate. API key: send it as Authorization: Bearer. OAuth: authorization code + PKCE S256 at https://www.agentplanners.com/oauth/authorize with resource=https://www.agentplanners.com/api/v1 (a token requested without resource works only on the MCP server), then exchange the code at https://www.agentplanners.com/oauth/token. Read the granted scope from the token response.
- 2GET /api/v1/me — check the workspace (organization.id) and, for OAuth, oauth.clientId and oauth.connectionId. Store them with the token.
- 3GET /api/v1/capabilities — read tools.enabled, tools.families, tools.writes (queued_for_approval, apply_with_guardrails or not_allowed), assembly, tasks, limits and errors. Stop and tell the user when a family you need is missing or tools.notEnabledBecause is set.
- 4GET /api/v1/accounts — pick the accountId for each platform. Never pick by list position; match the account the user named.
- 5GET /api/v1/tools?q=<what you want to do>&schema=1 — choose the tool; check requiredScope and runs. GET /api/v1/tools/{name} for its full documentation.
- 6POST /api/v1/tools/{name} with the arguments as the JSON body. For a write, send Idempotency-Key. 200: done. 202: { queued, requestId, statusUrl } — poll GET /api/v1/requests/{id} (or wait for the write.decided webhook) until executed, failed, rejected or expired.
- 7No tool fits? Only with tools:raw: GET /api/v1/tools/<family>__raw_request to read the rules, then POST it with { accountId, method, path, query, body }. Reads run; writes are queued for a person.
- 8Afterwards: GET /api/v1/requests (what you queued), GET /api/v1/audit (your audit trail), GET /api/v1/usage (your credits), GET /api/v1/organization (plan, credits left, connected platforms).
Minimal client: Node (fetch)
Node 18 or later, no dependencies. It runs the OAuth sign-in for a Public app registered on API & MCP → Your own OAuth apps with the redirect URI http://127.0.0.1:8787/callback, then makes one read, one write and polls the write. With an API key instead, skip the sign-in and set the access token to the key.
javascript
// node ap-client.mjs — credentials come from the environment, never from the repository
import crypto from "node:crypto";
import http from "node:http";
const ISSUER = "https://www.agentplanners.com";
const API = ISSUER + "/api/v1";
const CLIENT_ID = process.env.AGENTPLANNERS_CLIENT_ID; // apc_… of your Public app
const REDIRECT = "http://127.0.0.1:8787/callback"; // registered on the app, exactly
const b64url = (buf) => buf.toString("base64url");
// 1. PKCE + state, fresh for every sign-in
const verifier = b64url(crypto.randomBytes(32));
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest());
const state = b64url(crypto.randomBytes(16));
const authorize = new URL("/oauth/authorize", ISSUER);
authorize.search = new URLSearchParams({
response_type: "code", client_id: CLIENT_ID, redirect_uri: REDIRECT,
code_challenge: challenge, code_challenge_method: "S256",
scope: "accounts:read tools:read tools:write", state, resource: API,
}).toString();
console.log("Open this URL and approve:", authorize.href);
// 2. The redirect back: check state and iss before using the code
const code = await new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
const q = new URL(req.url, REDIRECT).searchParams;
res.end("You can close this tab.");
server.close();
if (q.get("state") !== state || q.get("iss") !== ISSUER) return reject(new Error("state or iss mismatch"));
if (q.get("error")) return reject(new Error(q.get("error")));
resolve(q.get("code"));
}).listen(8787, "127.0.0.1");
});
async function token(params) {
const r = await fetch(ISSUER + "/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ client_id: CLIENT_ID, ...params }),
});
const body = await r.json();
if (!r.ok) throw new Error(r.status + " " + body.error + ": " + (body.error_description || ""));
return body; // { access_token, token_type, expires_in, refresh_token, scope }
}
let tok = await token({ grant_type: "authorization_code", code, redirect_uri: REDIRECT, code_verifier: verifier, resource: API });
await saveRefreshToken(tok.refresh_token); // every refresh replaces it — keep only the newest
// 3. One helper: refresh once on 401, wait out 429 / 503 once, same Idempotency-Key on the retry
async function call(method, path, body, idempotencyKey, retried = false) {
const headers = { Authorization: "Bearer " + tok.access_token };
if (body) headers["Content-Type"] = "application/json";
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const r = await fetch(API + path, { method, headers, body: body ? JSON.stringify(body) : undefined });
if (!retried && r.status === 401) {
tok = await token({ grant_type: "refresh_token", refresh_token: tok.refresh_token });
await saveRefreshToken(tok.refresh_token);
return call(method, path, body, idempotencyKey, true);
}
if (!retried && (r.status === 429 || r.status === 503)) {
await new Promise((ok) => setTimeout(ok, 1000 * Number(r.headers.get("Retry-After") || 30)));
return call(method, path, body, idempotencyKey, true);
}
return { status: r.status, body: await r.json() };
}
// 4. Discover, read, write, poll
const caps = (await call("GET", "/capabilities")).body;
if (!caps.tools.enabled) throw new Error("Tools are off: " + caps.tools.notEnabledBecause);
const accounts = (await call("GET", "/accounts")).body.accounts;
const gads = accounts.find((a) => a.provider === "google_ads"); // match the account the user named, not the first one
const health = await call("POST", "/tools/google_ads__get_account_health", { accountId: gads.id });
console.log(health.status, health.body.result);
const write = await call("POST", "/tools/google_ads__mutate_campaign", {
accountId: gads.id,
campaignResourceName: "customers/1234567890/campaigns/111222333",
finalUrlSuffix: "utm_source=google&utm_medium=cpc",
}, "utm-suffix-campaign-111222333-v1");
if (write.status === 202) {
let decision;
do {
await new Promise((ok) => setTimeout(ok, 5000));
decision = (await call("GET", "/requests/" + encodeURIComponent(write.body.requestId))).body.result;
} while (decision.status === "pending_approval" || decision.status === "approved");
console.log("decided:", decision.status); // executed | failed | rejected | expired
}
async function saveRefreshToken(rt) { /* write it to your secret store — not a file in the repository */ }Minimal client: Python (requests)
Python 3.9 or later with requests. The same flow; the person pastes the URL they were sent back to, so no local server is needed.
python
# python ap_client.py — credentials come from the environment, never from the repository
import base64, hashlib, os, secrets, time, urllib.parse
import requests
ISSUER = "https://www.agentplanners.com"
API = ISSUER + "/api/v1"
CLIENT_ID = os.environ["AGENTPLANNERS_CLIENT_ID"] # apc_... of your Public app
REDIRECT = "http://127.0.0.1:8787/callback" # registered on the app, exactly
def b64url(b): return base64.urlsafe_b64encode(b).rstrip(b"=").decode()
def save_refresh_token(rt): pass # write it to your secret store, not a file in the repository
verifier = b64url(secrets.token_bytes(32))
challenge = b64url(hashlib.sha256(verifier.encode()).digest())
state = b64url(secrets.token_bytes(16))
print("Open this URL and approve:", ISSUER + "/oauth/authorize?" + urllib.parse.urlencode({
"response_type": "code", "client_id": CLIENT_ID, "redirect_uri": REDIRECT,
"code_challenge": challenge, "code_challenge_method": "S256",
"scope": "accounts:read tools:read tools:write", "state": state, "resource": API}))
back = urllib.parse.parse_qs(urllib.parse.urlparse(input("Paste the URL you were sent back to: ")).query)
if back.get("state") != [state] or back.get("iss") != [ISSUER]: raise SystemExit("state or iss mismatch")
if "error" in back: raise SystemExit(back["error"][0])
def token(**params):
r = requests.post(ISSUER + "/oauth/token", data={"client_id": CLIENT_ID, **params}, timeout=30)
body = r.json()
if r.status_code != 200: raise RuntimeError(f"{r.status_code} {body.get('error')}: {body.get('error_description', '')}")
return body
tok = token(grant_type="authorization_code", code=back["code"][0], redirect_uri=REDIRECT, code_verifier=verifier, resource=API)
save_refresh_token(tok["refresh_token"]) # every refresh replaces it: keep only the newest
def call(method, path, json=None, idempotency_key=None, retried=False):
global tok
headers = {"Authorization": "Bearer " + tok["access_token"]}
if idempotency_key: headers["Idempotency-Key"] = idempotency_key
r = requests.request(method, API + path, json=json, headers=headers, timeout=60)
if not retried and r.status_code == 401:
tok = token(grant_type="refresh_token", refresh_token=tok["refresh_token"])
save_refresh_token(tok["refresh_token"])
return call(method, path, json, idempotency_key, True)
if not retried and r.status_code in (429, 503):
time.sleep(int(r.headers.get("Retry-After", "30")))
return call(method, path, json, idempotency_key, True)
return r.status_code, r.json()
_, caps = call("GET", "/capabilities")
if not caps["tools"]["enabled"]: raise SystemExit("Tools are off: " + caps["tools"]["notEnabledBecause"])
_, accts = call("GET", "/accounts")
gads = next(a for a in accts["accounts"] if a["provider"] == "google_ads") # match the account the user named
status, health = call("POST", "/tools/google_ads__get_account_health", {"accountId": gads["id"]})
print(status, health.get("result"))
status, write = call("POST", "/tools/google_ads__mutate_campaign", {
"accountId": gads["id"],
"campaignResourceName": "customers/1234567890/campaigns/111222333",
"finalUrlSuffix": "utm_source=google&utm_medium=cpc",
}, idempotency_key="utm-suffix-campaign-111222333-v1")
if status == 202:
while True:
time.sleep(5)
_, r = call("GET", "/requests/" + urllib.parse.quote(write["requestId"]))
if r["result"]["status"] not in ("pending_approval", "approved"): break
print("decided:", r["result"]["status"]) # executed | failed | rejected | expiredRules an agent must not break
- Never store a key, token, client secret or webhook secret in the repository, a URL, a log, a test fixture or a browser bundle. Read it from the environment or a secret store (AGENTPLANNERS_TOKEN, AGENTPLANNERS_CLIENT_ID in these examples) and keep env files out of version control.
- Never retry a write blindly. Send an Idempotency-Key (up to 128 characters) that names the change, reuse the same key on every retry of that change, and after a 500 or a timeout read GET /api/v1/requests and GET /api/v1/audit before deciding to send it again. A 422 means change the arguments, not resend them.
- Never try to approve your own write. A 202 waits for a person in the workspace (API & MCP → Write approvals); no endpoint approves. Tell the user, poll GET /api/v1/requests/{id} or wait for the write.decided webhook.
- Keep the newest refresh token. Each refresh returns a new refresh token and spends the one you sent: store the new one before using the new access token. Presenting a spent refresh token later ends the whole connection.
- Discover, never guess: tool names from GET /api/v1/tools, account ids from GET /api/v1/accounts, request ids from your own 202 answers. A 404 means re-discover.
- Prefer a dedicated tool. Use <family>__raw_request only when no tool does the job — never to get around a refusal or a guardrail.
- Respect the answer: 402 and 403 are not retried (tell the user); 429 and 503 wait Retry-After; slow down when RateLimit-Remaining is low.
- Ask only for the scopes you use (the consent page shows them to a person), and request resource=https://www.agentplanners.com/api/v1 for a REST token.
Errors to handle
| Status | What to do |
|---|---|
| 400 | The request or arguments are malformed: fix them (GET /api/v1/tools/{name} has the schema). Do not resend unchanged. |
| 401 | Refresh the OAuth token once, then sign in again. code wrong_resource: authorize again with resource=https://www.agentplanners.com/api/v1. |
| 402 | The workspace is out of credits: stop and tell the user to top up. Do not retry. |
| 403 | Missing scope (insufficient_scope) or the platform is not exposed to this credential: read GET /api/v1/capabilities and ask the user to reconnect with it. Do not retry. |
| 404 | Unknown tool or request (or one this credential did not queue): re-discover. |
| 409 | No connected account for that platform, or an ambiguous accountId: pass an id from GET /api/v1/accounts, or ask the user to connect the platform. |
| 413 | The body is over 1 MB: send less per call. |
| 422 | The tool or the platform refused: read error, change the arguments. |
| 429 | Wait Retry-After seconds; retry with the same Idempotency-Key. |
| 500 | Unexpected error: retry a read; before resending a write, check GET /api/v1/requests and GET /api/v1/audit, then reuse the Idempotency-Key. |
| 503 | Temporarily unavailable (retryable): wait Retry-After, retry the same request. |
Prove it works
- 1GET /api/v1/me → 200 and the expected workspace.
- 2GET /api/v1/capabilities → tools.families contains every family the integration needs.
- 3POST /api/v1/tools/google_ads__get_account_health (or another read tool you use) → 200.
- 4A write → 202 with a requestId that GET /api/v1/requests?status=pending lists; a person rejects it on API & MCP → Write approvals so nothing changes.
- 5GET /api/v1/audit → an event that names that requestId.
- 6GET /api/v1/usage?days=1 → totalCredits and calls above 0.
The REST API guide has every endpoint, the full status table and webhooks; the OAuth guide has registration, scopes, lifetimes and the token errors; the OpenAPI document is https://www.agentplanners.com/api/v1/openapi.json.
Frequently asked questions
- Which endpoint should an agent call first?
- GET /api/v1/capabilities (after GET /api/v1/me to confirm the workspace). It says which tool families the credential may use, whether writes wait for a person, what may be assembled raw, the rate limit and how to handle each error — all as JSON.
- Can an agent approve the changes it proposes?
- No. A write that waits answers 202 with a requestId; a person in the workspace decides it on API & MCP → Write approvals. The agent polls GET /api/v1/requests/{id} or waits for the write.decided webhook.
- MCP or REST for a coding agent?
- If the agent itself should use Agent Planners while it works, connect the MCP server. If the agent is writing code for another system that will call Agent Planners, that code uses the REST API with an API key or OAuth token, as on this page.