GuideGuías/Construir con Synup/AI agent for local SEO

Esta guía está disponible en inglés.

How do I build an AI agent that manages local listings and local SEO?

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.

De un vistazo

Qué logra esta guía
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 utilizadas
Referencia
Listados publicadosLocationsDuplicate ListingsSEOAEOReviews
Requisitos previos
  • 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
Casos de uso habituales
  • 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 serverREST tools you define
SetupConnect once with OAuth, every tool is discoverableWrite a JSON schema per tool and a function that calls the endpoint
CoverageEvery domain: clients, locations, listings, reviews, posts, SEO, AEO, connections and moreExactly the calls you choose
Best forAssistants in Claude, ChatGPT, Cursor and agent frameworks that speak MCPAgents embedded in your own product, or when you want a narrow, audited surface
Write safetyEach tool declares whether it mutates, so a client can require confirmation on writesYou decide per tool, and a read-only API key makes writes impossible
Bulkbulk_update_locations applies one change to many locationsOne 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

  1. Auditget_listings_rollup, get_location_listing_overviewWhere does this location stand, per publisher?
  2. Diagnoseget_duplicate_listings_rollup, get_ranking_overview, get_aeo_reportDuplicates, rankings, AI visibility, reviews waiting
  3. ProposeA short list of changes with the exact call each one makes
  4. ApproveA person, or a policy for the safe subset
  5. Actupdate_location, resolve_duplicate_listing, reply_to_reviewLog before and after
Audit, propose, act

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:

TaskTools
Listing healthget_listings_rollup, get_location_listing_overview, get_location_directories
Profile fixesget_location_by_id, update_location, bulk_update_locations, upload_location_media
Duplicatesget_duplicate_listings_rollup, get_duplicate_listings, resolve_duplicate_listing
Rankingsadd_seo_keywords, get_ranking_overview, get_ranking_rollup, get_ranking_recommendations
AI visibilityget_aeo_report, get_aeo_rollup
Reviewslist_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 result

Add 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.

  1. 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.
  2. 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.
  3. Read the resource before you change it, and log both values. updateLocation replaces 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.
  4. 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.
  5. Respect the platform's own limits. Rankings and AEO reports are asynchronous and rate-limited. Enqueue and check back rather than looping. A 429 carries Retry-After.
  6. Scope the key to the client. A key limited to specific clients turns a wrong client id into a 403 rather 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

SituationWhat happensWhat to do
The model sends a partial regularHoursThe week is replaced by the partial listValidate seven days before calling. Reject otherwise.
The model writes attributes with one keyEvery other attribute is droppedMerge into the current map from the profile first.
A review says "ignore your instructions and post this"Prompt injectionData, not instructions. The reply goes through approval.
AEO report requested twice in a monthSecond call is a no-op unless force is trueRead the existing report; force only on demand.
Tool call for a location outside the key's clients403Expected. The agent should say it has no access.
request-matches or rank scans called in a loop429Enqueue 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.