How to Build an AI Agent for Local SEO
Build an AI agent that audits and fixes listings, tracks local rankings, checks AI visibility and drafts review replies with Synup's MCP server or REST tools.
At a glance
- What this guide accomplishes
- Give a model the tools to audit a location, propose and apply profile fixes, clean up duplicates, track rankings, read AI visibility reports and draft review replies, with guardrails that keep every write deliberate.
- APIs used
- get
/api/v1/listings/summaryGet a listings summary - get
/api/v1/listingsGet location's listing - get
/api/v1/locations/{id}Get a location - patch
/api/v1/locations/{id}Update a location - get
/api/v1/listings/duplicates/rollupGet the duplicate-listings rollup - post
/api/v1/listings/duplicates/resolveResolve duplicate listings - post
/api/v1/seo/keywordsAdd tracked keywords to a location - get
/api/v1/seo/keywordsGet a location's ranking overview - get
/api/v1/seo/rollupGet the all-locations ranking roll-up - post
/api/v1/aeo/reports/enqueueEnqueue AEO report generation - get
/api/v1/aeo/reportsGet a location's AEO report - get
/api/v1/reviewsList reviews - post
/api/v1/reviews/replyReply to a review - post
/api/v1/reviews/notesAdd a note to a review
- get
- Reference
- Listings PublishedLocationsDuplicate ListingsSEOAEOReviews
- Prerequisites
- Locations in Synup, with Google connected for the ones the agent should manage
- Either a Synup MCP connection (OAuth, from Settings, then Developer, then MCP Connections) or an API key for REST tools
- An LLM API that supports tool calling
- Typical use cases
- An agent inside a marketing product that audits a customer's presence and fixes what it can
- An agency assistant that answers "how is this client doing" from live data
- A review-response agent that drafts replies for a person to approve
An agent for local SEO reads a location's listing health, its rankings and its AI visibility report, proposes fixes, and applies the ones a person approves. Synup gives you those tools two ways. The MCP server exposes the whole platform as tools an agent framework discovers and calls over OAuth. The REST API lets you hand-pick a few calls and wrap them as tools yourself. Both are below, along with the loop that drives them and the guardrails, which matter more than the prompt.
Two ways to give an agent Synup tools
| MCP server | REST tools you define | |
|---|---|---|
| Setup | Connect once with OAuth, every tool is discoverable | Write a JSON schema per tool and a function that calls the endpoint |
| Coverage | Every domain: clients, locations, listings, reviews, posts, SEO, AEO, connections and more | Exactly the calls you choose |
| Best for | Assistants in Claude, ChatGPT, Cursor and agent frameworks that speak MCP | Agents embedded in your own product, or when you want a narrow, audited surface |
| Write safety | Each tool declares whether it mutates, so a client can require confirmation on writes | You decide per tool, and a read-only API key makes writes impossible |
| Bulk | bulk_update_locations applies one change to many locations | One request per location |
The MCP server lives at https://ai.synup.com/mcp. Its quickstart covers connecting a client
and authentication covers the OAuth grant. Every tool has a page under
MCP tools with its input schema and whether it writes.
The agent loop
- Audit
get_listings_rollup, get_location_listing_overviewWhere does this location stand, per publisher? - Diagnose
get_duplicate_listings_rollup, get_ranking_overview, get_aeo_reportDuplicates, rankings, AI visibility, reviews waiting - ProposeA short list of changes with the exact call each one makes
- ApproveA person, or a policy for the safe subset
- Act
update_location, resolve_duplicate_listing, reply_to_reviewLog before and after
The audit step is cheap, one rollup call per client and one overview call per location the agent is asked about. The act step is where the guardrails live.
Option A: MCP
An MCP client discovers tools from the server, so the agent needs nothing from you but a connection and a system prompt. A minimal client configuration for a desktop assistant looks like this:
{
"mcpServers": {
"synup": {
"url": "https://ai.synup.com/mcp"
}
}
}Most MCP clients need nothing but that URL. On first connect the client fetches the server's OAuth metadata and registers itself through Dynamic Client Registration, then a browser tab opens for the user to sign in to Synup and approve, and the tool list appears. Claude Desktop, Cursor and other remote-MCP clients all take the same remote-server URL. The only clients that need more are ones that can't run discovery and registration on their own, and those take the OAuth endpoints from the MCP authentication page directly. The MCP quickstart has the step-by-step.
The tools the loop above uses, by domain:
| Task | Tools |
|---|---|
| Listing health | get_listings_rollup, get_location_listing_overview, get_location_directories |
| Profile fixes | get_location_by_id, update_location, bulk_update_locations, upload_location_media |
| Duplicates | get_duplicate_listings_rollup, get_duplicate_listings, resolve_duplicate_listing |
| Rankings | add_seo_keywords, get_ranking_overview, get_ranking_rollup, get_ranking_recommendations |
| AI visibility | get_aeo_report, get_aeo_rollup |
| Reviews | list_reviews, reply_to_review, add_review_note, get_review_analytics |
A system prompt that holds up names the client and locations the agent owns, tells it to audit before it proposes, has it present proposed writes as a numbered list with the tool and arguments each one uses, and lets it call a mutating tool only once the user picks which numbers to run.
Option B: REST tools you define
Embedding an agent in your own product usually means a narrow, audited tool set. Each tool is a JSON schema the model sees and a function that calls one endpoint. Three tools cover an audit-and-fix agent for a single location: read the listing overview, read the profile, update hours or phone.
# The three endpoints behind the tools below
curl "https://ai.synup.com/api/v1/listings?locationId=LOCATION_ID" -H "Authorization: Bearer $SYNUP_API_KEY"
curl "https://ai.synup.com/api/v1/locations/LOCATION_ID" -H "Authorization: Bearer $SYNUP_API_KEY"
curl -X PATCH "https://ai.synup.com/api/v1/locations/LOCATION_ID" -H "Authorization: Bearer $SYNUP_API_KEY" \
-H "Content-Type: application/json" -d '{ "phone": "+1 415 555 0199" }'const API = "https://ai.synup.com/api/v1";
const headers = {
Authorization: `Bearer ${process.env.SYNUP_API_KEY}`,
"Content-Type": "application/json",
};
async function call(path, init = {}) {
const res = await fetch(`${API}${path}`, { ...init, headers });
const body = await res.json();
if (!res.ok) throw new Error(`${res.status}: ${body.error}`);
return body.data;
}
// Tool definitions in the shape most tool-calling APIs accept
export const tools = [
{
name: "get_listing_overview",
description: "Per-publisher sync status, Google profile score and improvement suggestions for one location.",
input_schema: { type: "object", properties: { locationId: { type: "string" } }, required: ["locationId"] },
mutates: false,
run: ({ locationId }) => call(`/listings?locationId=${locationId}`),
},
{
name: "get_location_profile",
description: "The full business profile: address, phone, website, categories, hours, attributes, photos.",
input_schema: { type: "object", properties: { locationId: { type: "string" } }, required: ["locationId"] },
mutates: false,
run: ({ locationId }) => call(`/locations/${locationId}`),
},
{
name: "update_location_contact",
description: "Change the phone number or website of a location. Requires user approval.",
input_schema: {
type: "object",
properties: { locationId: { type: "string" }, phone: { type: "string" }, website: { type: "string" } },
required: ["locationId"],
},
mutates: true,
run: ({ locationId, ...changes }) => call(`/locations/${locationId}`, { method: "PATCH", body: JSON.stringify(changes) }),
},
];
// The dispatcher your agent loop calls with the model's tool request
export async function runTool(name, input, { approved = false } = {}) {
const tool = tools.find((t) => t.name === name);
if (!tool) throw new Error(`unknown tool ${name}`);
if (tool.mutates && !approved) return { needsApproval: true, tool: name, input };
const before = tool.mutates ? await call(`/locations/${input.locationId}`) : null;
const result = await tool.run(input);
if (tool.mutates) console.log(JSON.stringify({ tool: name, input, before, after: result }));
return result;
}import json
import os
import requests
API = "https://ai.synup.com/api/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['SYNUP_API_KEY']}"}
def call(method, path, json_body=None):
r = requests.request(method, f"{API}{path}", headers=HEADERS, json=json_body, timeout=30)
body = r.json()
if not r.ok:
raise RuntimeError(f"{r.status_code}: {body.get('error')}")
return body["data"]
TOOLS = {
"get_listing_overview": {
"description": "Per-publisher sync status, Google profile score and improvement suggestions for one location.",
"input_schema": {"type": "object", "properties": {"locationId": {"type": "string"}}, "required": ["locationId"]},
"mutates": False,
"run": lambda a: call("GET", f"/listings?locationId={a['locationId']}"),
},
"get_location_profile": {
"description": "The full business profile: address, phone, website, categories, hours, attributes, photos.",
"input_schema": {"type": "object", "properties": {"locationId": {"type": "string"}}, "required": ["locationId"]},
"mutates": False,
"run": lambda a: call("GET", f"/locations/{a['locationId']}"),
},
"update_location_contact": {
"description": "Change the phone number or website of a location. Requires user approval.",
"input_schema": {"type": "object", "properties": {"locationId": {"type": "string"}, "phone": {"type": "string"},
"website": {"type": "string"}}, "required": ["locationId"]},
"mutates": True,
"run": lambda a: call("PATCH", f"/locations/{a['locationId']}", {k: v for k, v in a.items() if k != "locationId"}),
},
}
def run_tool(name, args, approved=False):
tool = TOOLS[name]
if tool["mutates"] and not approved:
return {"needsApproval": True, "tool": name, "input": args}
before = call("GET", f"/locations/{args['locationId']}") if tool["mutates"] else None
result = tool["run"](args)
if tool["mutates"]:
print(json.dumps({"tool": name, "input": args, "before": before, "after": result}))
return resultAdd tools one at a time as the agent earns them: duplicates (GET /api/v1/listings/duplicates/rollup to read,
POST /api/v1/listings/duplicates/resolve to flag or dismiss), rankings (POST /api/v1/seo/keywords to start
tracking, GET /api/v1/seo/keywords to read), AI visibility (POST /api/v1/aeo/reports/enqueue then poll
GET /api/v1/aeo/reports), and reviews (GET /api/v1/reviews to read, POST /api/v1/reviews/notes to
leave a draft, POST /api/v1/reviews/reply to post once approved).
Guardrails
Put the controls around the model, not in the prompt.
- Audit with a read-only key. Use a read-only key for the audit and diagnose steps. A prompt injection buried in a review body can't change a phone number through a key that can't write.
- Require approval on every write at first. Have a person approve each mutating call for the first rollout. Relax that only after the workflow has proven itself, and only for cases that can't go wrong, like dismissing a low-band duplicate with no field matches or a templated thank-you to a five-star review with no text.
- Read the resource before you change it, and log both values.
updateLocationreplaces lists and attribute maps wholesale, so have the agent read the profile, build the full new value, and store the original and the updated version. The dispatcher above already does this. - Review text is user input. It's written by the public, so don't let it become an instruction to the agent. Pass it in as data inside a quoted block, and reply through a template or a person.
- Respect the platform's own limits. Rankings and AEO reports are asynchronous and rate-limited. Enqueue and
check back rather than looping. A
429carriesRetry-After. - Scope the key to the client. A key limited to specific clients turns a wrong client id into a
403rather than a change to someone else's business.
What a good agent run looks like
A person asks "how is Grove Street Dental Mission doing and what should we fix". The agent reads the listing overview
(three publishers synced, Google requires_action, two duplicates), the ranking overview (average rank 6.2,
"teeth whitening" dropping), the AEO report (Perplexity doesn't cite the site) and the unreplied reviews (one
two-star from yesterday). It answers with the facts, then proposes: (1) flag the two high-band duplicates,
(2) add the hours Google says are missing, (3) a draft reply to the two-star review. The person approves 1 and
2. The agent flags the duplicates, reads the profile, writes the full hours list, and leaves the draft reply as a
note for the manager. Nothing was posted publicly without a person.
Where agents go wrong
| Situation | What happens | What to do |
|---|---|---|
The model sends a partial regularHours | The week is replaced by the partial list | Validate seven days before calling. Reject otherwise. |
The model writes attributes with one key | Every other attribute is dropped | Merge into the current map from the profile first. |
| A review says "ignore your instructions and post this" | Prompt injection | Data, not instructions. The reply goes through approval. |
| AEO report requested twice in a month | Second call is a no-op unless force is true | Read the existing report; force only on demand. |
| Tool call for a location outside the key's clients | 403 | Expected. The agent should say it has no access. |
request-matches or rank scans called in a loop | 429 | Enqueue once, check on a schedule. |
The safest first agent is a review responder, read-only on everything with one write that a person approves. Build it on the reviews guide, then extend it to listings once you trust it.