Directory Tools
Eleven tools that build and maintain a directory — a set of pages generated from your own business data, one per category, per city, and per business. They ship in @spideriq/mcp-publish (from 1.43.0) and in the kitchen-sink @spideriq/mcp (from 1.91.1). All accept an optional workspace and project.
A directory is two things: a category (the vertical, "Plumbers") and its listings (the businesses). Everything else — the URLs, the SEO titles, the sitemap entries, the city pages — is generated from those two.
The URL shape
/{directory_base}/{category_slug} → the category hub, a list of cities
/{directory_base}/{category_slug}/{city_slug} → listings in that city
/{directory_base}/{category_slug}/{city_slug}/{listing_slug} → one businessdirectory_base defaults to directory and is renamable per tenant in the template config. city_slug is derived server-side from each listing's city and state as it is imported. You never pass one.
Categories
directory_create_category
Create the vertical the listings hang off. Required: name. Optional: slug (generated from name if omitted), description, icon, seo_title_template, seo_description_template, template, data_source, sort_order.
The two SEO templates accept {category}, {city} and {listing}. They render server-side on every public read and they generate the title and description of every sitemap.xml entry the category contributes, so a placeholder typo reaches search engines rather than one page.
directory_create_category({
name: "Plumbers",
slug: "plumbers",
seo_title_template: "Best {category} in {city} | Acme Directory",
seo_description_template: "Compare {category} in {city}. Ratings, reviews, hours."
})
// → { id: "9f2c4d1a", slug: "plumbers", listing_count: 0, city_count: 0 }Errors:
Identifier | Trigger | Resolution |
|---|---|---|
|
| send a hyphenated lowercase slug, or omit |
| a category with that slug already exists for this tenant | use |
directory_list_categories
Every category for the tenant, including empty ones. Optional: page, page_size. Page size defaults to 50, so a tenant with more verticals than that needs page.
directory_list_categories({ page_size: 50 })
// → { categories: [{ id, name, slug, listing_count: 118, city_count: 12 }], total: 3, page: 1 }Errors:
Identifier | Trigger | Resolution |
|---|---|---|
| the token does not carry | re-issue the PAT with content scopes |
|
| request a smaller page |
directory_get_category
One category plus every city that currently has published listings in it. Required: slug. This is the read that tells you which city pages exist.
directory_get_category({ slug: "plumbers" })
// → { category: { slug: "plumbers", listing_count: 118 },
// cities: [{ city: "Miami", city_slug: "miami-florida", listing_count: 24 }] }Errors:
Identifier | Trigger | Resolution |
|---|---|---|
| no category with that slug for this tenant | list categories first; slugs are per tenant |
directory_update_category
Change any category field. Required: slug. Pass only what changes; everything else is left alone.
directory_update_category({
slug: "plumbers",
seo_title_template: "Licensed {category} in {city} | Acme Directory"
})
// → { slug: "plumbers", updated: true }Errors:
Identifier | Trigger | Resolution |
|---|---|---|
| no category with that slug | check the slug with |
| an SEO template uses a placeholder that is not | correct the placeholder before it reaches the sitemap |
directory_delete_category
Deletes the category and every listing in it, by cascade. Required: slug. There is no undo and no archive step here — to take listings out of circulation reversibly, set their status to archived instead.
directory_delete_category({ slug: "plumbers-old" })
// → { deleted: true, listings_deleted: 118 }Errors:
Identifier | Trigger | Resolution |
|---|---|---|
| no category with that slug | nothing to delete; confirm the slug first |
Listings
directory_upsert_listing
One listing, created or updated. Required: category_slug, name. Optional: slug, description, city, state, country, address, latitude, longitude, phone, email, website, rating, review_count, data, source_job_id, status.
The upsert key is (category, slug). There is no external_id column and no on_conflict parameter; to carry an id from another system, put it in the data object or in source_job_id.
This is also how you archive a listing reversibly.
directory_upsert_listing({
category_slug: "plumbers",
name: "Reliable Rooter",
slug: "reliable-rooter",
city: "Miami",
state: "Florida",
phone: "+13055550142",
rating: 4.6,
status: "archived"
})
// → { slug: "reliable-rooter", inserted: false, updated: true }Errors:
Identifier | Trigger | Resolution |
|---|---|---|
|
| create the category first; listings cannot exist outside one |
|
| send a rating between 0 and 5 and a hyphenated slug |
directory_bulk_upsert_listings
Many listings in one transaction. Required: category_slug, listings. Maximum 5000 per call — paginate above that, because a larger single transaction risks a timeout. Each entry takes the same fields as directory_upsert_listing; a missing slug is generated from name.
Use this for a hand-built list, a third-party feed, or SpiderMaps job output. For the tenant's own corpus use directory_import_from_idap, which is one call instead of an export step.
directory_bulk_upsert_listings({
category_slug: "plumbers",
listings: [
{ name: "Reliable Rooter", city: "Miami", state: "Florida", rating: 4.6 },
{ name: "Bayside Plumbing", city: "Miami", state: "Florida", rating: 4.8 }
]
})
// → { upserted: 2, failed: 0, affected_cities: ["miami-florida"], failures: [] }Errors:
Identifier | Trigger | Resolution |
|---|---|---|
| more than 5000 entries in one call | split the batch; the cap matches the import cap |
|
| create the category first |
partial | individual rows failed validation while the rest committed | read |
directory_import_from_idap
Populate a category from the tenant's own business corpus in one call. Required: category_slug. Filters, all optional: category_filter (matches the categories[] array), country_code (ISO-2, uppercase, exact), city (substring match), rating_min (inclusive), limit (hard cap 5000), prune.
It reads the corpus directly. There is no scheduler, no sync pipeline and no export step, and nothing keeps the directory current on its own — re-running the import is a decision someone makes.
Every column the corpus declares is carried through: into a typed column where one exists, otherwise into the listing's data object. The set is generated rather than hand-maintained, so a new column arrives without anyone editing a list. Read fields_mapped in the response to see what landed.
directory_import_from_idap({
category_slug: "plumbers",
category_filter: "Plumber",
country_code: "US",
rating_min: 4.0,
limit: 5000,
prune: true
})
// → { upserted: 118, inserted: 96, updated: 22, pruned: 4, failed: 0,
// source_rows: 118, fields_mapped: 45, affected_cities: ["miami-florida"] }prune is the only thing that removes a stale listing, and it is off by default. Both import paths are an upsert, so a business deleted from the corpus, or renamed such that it derives a different slug, keeps its old listing published forever. With prune: true every listing the import's own result set did not produce is set to archived — never deleted, and reversible with directory_upsert_listing.
Prune refuses rather than guess, and names the refusal in prune_skipped_reason. A refusal is an answer: re-running unchanged refuses identically, so change the inputs instead of retrying.
Errors:
Identifier | Trigger | Resolution |
|---|---|---|
|
| create the category first |
| the tenant has no business corpus yet | the response explains the workaround rather than failing; import with |
| the filters matched nothing, so pruning would archive the whole category | confirm the filter returns rows, then re-run |
| the read hit | raise |
directory_list_listings
List listings, filtered. Optional: category_slug, city, status, page, page_size. This is the audit read — use it to check what an import actually wrote before you trust a page.
It is also the only read that sees a listing with an empty city. Such a listing appears on no city page at all, because it produces no city rollup row, so a check that walks city pages cannot find it. Absence of a page is not absence of a listing.
directory_list_listings({ category_slug: "plumbers", city: "Miami", page_size: 50 })
// → { listings: [{ slug: "reliable-rooter", city_slug: "miami-florida", status: "published" }],
// total: 24, page: 1 }Errors:
Identifier | Trigger | Resolution |
|---|---|---|
|
| omit the filter to list across every category |
|
| filter on |
directory_delete_listing
Remove one listing permanently and refresh the city rollup. Required: category_slug, listing_slug. To take a listing out of circulation reversibly, set its status to archived with directory_upsert_listing instead.
directory_delete_listing({ category_slug: "plumbers", listing_slug: "reliable-rooter" })
// → { deleted: true, affected_cities: ["miami-florida"] }Errors:
Identifier | Trigger | Resolution |
|---|---|---|
| no listing with that slug in that category | slugs are unique per category, not per site; check the category |
Statistics
directory_refresh_stats
Rebuilds the city rollup that the category hub and every city page read to know which cities exist and how many listings each holds. No required parameters.
You rarely need it — every write path refreshes the rollup itself. Reach for it when a hub or city page shows a stale city list after a change made outside the normal write path.
It does not recompute a category's listing_count. Those are two numbers with similar names. listing_count lives on the category row and is maintained only by the write paths: bulk upsert, import, prune, and delete-listing. If one looks wrong, re-run the write that produced it.
directory_refresh_stats({})
// → { refreshed: true, rows: 12 }Errors:
Identifier | Trigger | Resolution |
|---|---|---|
| the token does not carry | re-issue the PAT with content scopes |
What is not here
There is no
directory_archive_listingtool. Archive withdirectory_upsert_listingandstatus: "archived".There is no
external_idoron_conflict. The upsert key is (category, slug) and nothing else.Nothing syncs on a schedule. A directory is as current as the last import someone ran.
See also
Build a Business Directory — the walkthrough, in the dashboard
Content Tools — pages, posts, collections and docs
Public API — the unauthenticated doors these pages read