Adding a new tool
Every file that must change to ship a new tool - the worker handler, its credit cost and section, the dashboard view and renderer, translations, tests and documentation.
Last updated 2026-08-06
Summary#
A tool is not one file. It is a handler in _worker.js, a cost, a section, a cache lifetime, a view in dashboard.html, a navigation entry, a renderer, a set of translatable strings, a smoke case and a knowledge-base article. Miss one and the tool still appears to work in local testing while being wrong in a way nobody notices for weeks: it charges the wrong number of credits, it never shows up in the administrator's cache screen, or it stays English in five languages.
This page is the checklist. Work through it in order and nothing is left behind.
Purpose#
The platform has around seventy tools sharing one endpoint pair, one quota gate, one cache, one result shell and one export engine. That is what makes it feel like one product rather than seventy. The cost of that design is that a new tool must register itself in several small maps rather than bringing its own infrastructure, and every one of those maps has a default that silently absorbs an omission.
Three of those defaults are worth knowing before you start:
getToolCost(type)returns 1 for any tool it does not recognize. A tool you forgot to price is not free; it quietly charges one credit.TOOL_SECTIONfalls back toOther. A tool you forgot to place still appears in the administrator's Tools and Cache screen, in a group calledOther.- A tool absent from the shared cache map is simply never cached. It works, and every run bills the provider again.
None of those produce an error. They produce a slightly wrong product.
Requirements#
| Requirement | Detail |
|---|---|
| A working local setup | wrangler pages dev dist, with the dist/ copy step. See Local development |
| Provider credentials | DATAFORSEO_LOGIN and DATAFORSEO_PASSWORD for real-data tools, OPENAI_API_KEY for prompt-driven ones. See Environment variables and secrets |
| A decision on data source | Real measured data, or an AI synthesis over real context. This determines almost everything else |
| The design system | Read The design system before you write markup. The ratchet will reject a hardcoded color, an inline style= or an emoji |
Permissions#
Adding a tool requires push access to the repository. dashboard.html belongs to the main branch, so this work is done on main or a branch off it, never on redesign. Because dashboard.html merges badly, coordinate with the other developer before you start rather than after. See Code conventions and file ownership.
Nothing here requires access to the Cloudflare dashboard unless your tool needs a new provider key, which is a Pages environment variable.
Navigation Path#
_worker.js handler, cost, section, cache TTL
dashboard.html view markup, nav entry, router map, renderer dispatch
js/ a component file, only if the tool needs one
tests/smoke.mjs one case
docs/kb/ one article, plus its manifest entryDecide which endpoint your tool uses#
There are exactly two, and the choice is not cosmetic.
POST /api/tools | POST /api/premium-ai | |
|---|---|---|
| Body | { type, url, user_email? } | { type, query, country, device, range, ... , user_email } |
| Data | A direct HTTP fetch of the target site or a Google API. No AI | DataForSEO and an LLM |
| Type list | A hard whitelist of eighteen values, enforced before anything is charged | Any type with a prompt entry |
| Typical cost | 0 | 1 to 8 |
| Anonymous callers | Allowed, with an hourly cap keyed on the caller's IP address | Rejected when the cost is above 0 |
| Used by | The dashboard and every public /free-tools/ page | The dashboard only |
If your tool reads the customer's own site and returns a verdict, it belongs on /api/tools. If it asks a question about a market, a keyword or a competitor, it belongs on /api/premium-ai. The worker (_worker.js) describes both handlers in detail.
Step-by-Step Guide#
1. Add the handler in _worker.js#
For /api/tools: add your type to the whitelist that handleTools checks, then add its branch. A type that is not on the whitelist returns 400 {"error":"Invalid tool type: <type>"} before the quota gate runs, which is deliberate: an unknown type must never be able to charge anyone. Return { "data": { ... } } with corsHeaders(), and put every URL you fetch through the SSRF guard, which is what produces Only http(s) URLs are allowed and That host is not allowed.
For /api/premium-ai: add a key to the prompts object inside handlePremiumAI. The prompt must state the exact JSON shape you expect and must forbid markdown and code fences, because the response is parsed as JSON and a fenced reply produces {"error":"Failed to parse AI response","raw":"..."}. The default model is gpt-4o, max_tokens defaults to 4000 and response_format is {"type":"json_object"}. Add a max_tokens override only if your schema genuinely needs more.
If the tool must never invent a number, add its type to REAL_DATA_TYPES and add a branch to the real-data switch that calls your fetchReal* function. A type in that list with no data returns HTTP 200 and a noDataAvailable: true payload rather than an AI guess. That behavior is a product promise, not an implementation detail: see Tool catalog and credit cost for how it is described to customers.
2. Price it in TOOL_CREDIT_COST#
The map is banded by measured backend cost per run.
| Cost | Band |
|---|---|
| 0 | A pure HTTP fetch with no DataForSEO or LLM spend |
| 1 | Light. A single endpoint, under $0.04 per run |
| 3 | Medium. $0.04 to $0.10 per run |
| 5 | Heavy, multi-endpoint. $0.10 to $0.15 per run |
| 6 | The Domain Overview flagship, $0.18 per run |
| 8 | Ultra-heavy. Influencer Analysis $0.30, PLA Research $0.26 |
Two consequences follow from the number you choose. A cost below 3 is never blocked by the monthly quota; it goes through the hourly fair-use limiter instead. A cost of 0 is runnable by an anonymous caller. Anything at 3 or above is a premium report, counts against the plan's monthly allowance, and is refused outright on the Free plan with This tool needs a paid plan. Free includes the 10 technical SEO tools; upgrade to Pro to unlock the rest.
Warning: TOOL_CREDIT_COST currently defines ai_visibility twice, as 0 in the free band and 1 in the light band. JavaScript keeps the last value, so getToolCost('ai_visibility') returns 1, and handleTools works around it by pinning ai_visibility, geo_audit, ai_overview and ai_citation to 0 inside that endpoint. Do not remove the duplicate without checking the paid path first.
Then update the published number: the customer-facing cost table is Credit cost table, and it must not drift from the map.
3. Place it in TOOL_SECTION#
TOOL_SECTION maps a tool type to one of the nine names in TOOL_SECTION_ORDER: Get Started, Site Health, Keyword & Content Research, Competitors & Backlinks, AI Visibility, Write & Optimize, Promote, Track & Report, Other. This map exists only to group the administrator's Tools and Cache screen, and an unlisted tool lands in Other. Use the same section the tool sits in on the dashboard sidebar, so the two views agree.
4. Decide the cache lifetime#
Two caches matter, and they are described fully in Caching architecture.
- The shared cross-customer cache. A tool is cached here only if its type appears in
MV_CACHE_TTL_MS. Add an entry with a TTL that matches how fast the underlying data really moves: one day for SERP-shaped data, seven to ten days for domain-level metrics. A cache hit still charges the customer their normal credit; only the provider bill is saved. - The raw DataForSEO cache. Add per-call TTLs to
TOOL_CACHE_TTLSso the administrator's Tools and Cache screen shows real numbers instead of the default of one day, and so a per-tool override actually binds. If the tool shares its TTLs with the content family, add it toTOOL_CACHE_SHAREDinstead and it will be shown as not individually bindable.
Important: cache and saved-result identity both come from mvToolParamsKey, which is built from type, query, country, device, range or rangeDays, minVolume and intent. If your tool adds an input that changes the result and is not one of those, two different runs collide on one key and the second customer is served the first one's answer. Either reuse an existing field or fold the value into query.
5. Build the view in dashboard.html#
Four edits, all in the one file. It is 7.2 MB and CRLF, so write your search-and-replace patterns with \r?\n.
- The view container. Add
<div id="view-<key>" class="view" hidden>in the views region. Every view is in the DOM from first paint; the router only toggleshidden. - The tool contract. Inside it, the three elements the generic runner depends on:
``html <input data-in type="text" placeholder="e.g. best running shoes" /> <span data-run="<tool_type>" data-kind="ai" style="display:none"></span> <div class="mv-results" data-res></div> ``
data-kind="ai" routes the run through /api/premium-ai; data-kind="tool" routes it through /api/tools, and ai is the default when the attribute is absent. One delegated click handler serves every tool: a click on any [data-run] element runs it directly, and a click on a .sa-run, .cb-compare, .rk-start, .sa-sample or [data-sample] element falls back to the first [data-run] in the same view. Pressing Enter in a [data-in] field does the same. That is why the data-run element can be a hidden <span> while the visible button carries the styling.
- The navigation entry. Add an
I("Label","icon",{view:"<key>"})entry to the right section ofSECTIONS. The label is what appears in the sidebar and the breadcrumb. - The router map. Add
<key>: document.getElementById("view-<key>")to the hand-writtenviewsobject. A view with markup and a nav entry but no router entry is unreachable: clicking the sidebar item does nothing.
6. Render the result#
window.mvRenderOld(res, data, type, query) is the single entry point from a run to a rendered result. It walks a fixed chain and stops at the first match: the BES premium-renderer map, then AIINTEL, then BATCH6, then INLINE, then the generic mvRenderAny, then a last-resort card reading No legacy renderer for <type>.
A new tool with no entry anywhere in that chain falls through to mvRenderAny and produces a generic dump of your JSON. That is a usable starting point and a poor finish. Add your renderer to the appropriate map and follow Building a result renderer: renderers return data, the shared .mvr-* shell renders layout. No renderer may emit style=, a raw hex color, an emoji or a glass-card div.
The verified-source header and the Get Recommendations panel are attached automatically after any successful render, so do not add them yourself.
7. Wire the optional extras#
| Extra | Where | When to add it |
|---|---|---|
| Recommendation rules | js/mv-guide-rules.js, keyed by tool type | When a result has findings worth acting on. A rule must fire only on evidence; an empty result must draw no button |
| A tool explainer | The .mvte block inside the view | Every customer-facing tool should have one. Demo values live in data attributes |
| A component script | A new file in js/ | Only when the tool needs behavior too large for the monolith. Classic script, no ES modules, no top-level const or let, linked at the exact position it must run |
| A stylesheet | A new file in css/ | Namespace it, and link it in the block after legacy-mvx.css so it wins the cascade |
8. Localize it#
Binding, from the first commit, not as a later audit. The scanner reads shipped HTML and JavaScript, so plain markup text and template-literal markup are picked up for free. What is not picked up: a string concatenated with a value, an attribute other than placeholder, title, aria-label, alt, data-tip or data-tooltip, and text that no runtime pass ever visits.
If you added a new file under js/ that builds UI, add it to JS_FILES in tests/i18n-extract.mjs, or none of its strings ever enter the dictionary. This is exactly how two components shipped English-only. Then run the pipeline:
node tests/i18n-extract.mjs
node tests/i18n-generate.mjs
node tests/i18n-generate.mjs --emit
node tests/i18n-verify.mjsThe last command must report that all current UI strings are covered. Detail in Localization system.
9. Add a smoke case#
One line in the CASES array in tests/smoke.mjs:
{ kind: 'ai', tool: 'your_type', query: 'adidas.com', required: ['fieldA'], minLen: { rows: 3 } },kind: 'ai' posts to /api/premium-ai, kind: 'tool' posts to /api/tools. required accepts dotted paths. This is the check that catches a renderer that throws silently while the button and the spinner both look healthy, which is the failure mode the file was written for.
10. Run the gates#
node tests/chkblk.mjs dashboard.html # must report 0 broken
node tests/guard.mjs dashboard.html # no </script> inside a template literal
node tests/design-lint.mjs # no tracked debt count may rise
node tests/i18n-verify.mjs # translation coverage
node tests/kb-build.mjs # after you write the articleNone of these run in CI. Every one is described in Verification scripts.
11. Document it#
Add the article to docs/kb/_meta/manifest.json and write it into docs/kb/02-tools/. Update the shared reference tables that enumerate tools: the catalog in Tool catalog and credit cost and the cost table in Credit cost table. Then confirm the plan and the reality still agree:
node tests/kb-manifest.mjs
node tests/kb-build.mjsAn article that is not in the manifest is reported as UNPLANNED, and an article nobody planned is an article nobody maintains.
The complete checklist#
| # | File | Change | Skippable? |
|---|---|---|---|
| 1 | _worker.js | Handler or prompt entry | No |
| 2 | _worker.js | REAL_DATA_TYPES and a fetchReal* branch | Only for AI-synthesis tools |
| 3 | _worker.js | TOOL_CREDIT_COST | No. The default of 1 is silent |
| 4 | _worker.js | TOOL_SECTION | No. The default is Other |
| 5 | _worker.js | MV_CACHE_TTL_MS | Only if the tool must always be fresh |
| 6 | _worker.js | TOOL_CACHE_TTLS or TOOL_CACHE_SHARED | Only for tools with no DataForSEO calls |
| 7 | dashboard.html | div#view-<key> with [data-in], [data-run], .mv-results[data-res] | No |
| 8 | dashboard.html | SECTIONS entry | No |
| 9 | dashboard.html | views router map entry | No |
| 10 | dashboard.html | Renderer, and its entry in the dispatch chain | No |
| 11 | js/mv-guide-rules.js | Recommendation rules | Yes |
| 12 | tests/i18n-extract.mjs | JS_FILES entry | Only if you added a js/ file |
| 13 | tests/smoke.mjs | One CASES entry | No |
| 14 | docs/kb/ | Article plus manifest entry | No |
Troubleshooting#
| Symptom | Likely cause | Fix |
|---|---|---|
| The sidebar item does nothing | No entry in the views router map | Add <key>: document.getElementById("view-<key>") |
Invalid tool type: <type> | The type is not on the /api/tools whitelist | Add it, above the charging step |
Invalid analysis type: <type> | No prompts entry for the type on /api/premium-ai | Add the prompt |
Failed to parse AI response | The prompt allowed markdown or code fences | Restate "return ONLY valid JSON (no markdown, no code fences)" |
| The result is a generic JSON dump | No renderer is registered for the type | Add it to the dispatch chain |
No legacy renderer for <type> | The chain fell all the way through | Same fix, and check the spelling of the type |
| The tool charges 1 credit and you expected 0 | The type is missing from TOOL_CREDIT_COST | Add it. getToolCost defaults to 1 |
It appears under Other in the admin Tools and Cache screen | Missing from TOOL_SECTION | Add it |
| Every run bills the provider again | The type is not in MV_CACHE_TTL_MS | Add it with a realistic TTL |
| Two different runs return each other's results | An input that changes the result is not in mvToolParamsKey | Fold it into query or reuse an existing field |
| The UI stays English in other languages | The strings never reached the dictionary | Run the i18n pipeline; add a new js/ file to JS_FILES |
| The page stops working below your edit | A broken inline <script> block | node tests/chkblk.mjs dashboard.html |
| Your search-and-replace matched nothing | dashboard.html is CRLF | Use \r?\n |
| Everything works locally but not after deploy | You edited the repository root and tested dist/, or the reverse | See Local development |
FAQs#
Do I have to add a renderer, or can I ship on the generic one? You can ship on mvRenderAny, and it will look like a debug view. Treat it as a scaffold for your own testing, not as a release.
Can a tool be free and still require sign-in? Yes. Cost 0 means the quota gate allows an anonymous call, but a handler can still require user_email for its own reasons. Anonymous /api/tools calls are capped hourly by IP address regardless.
Where do I put a tool that reads the customer's saved results rather than running a fresh query? Give it a dedicated path before the generic pipeline, the way Growth Actions does. It charges nothing when it finds no source data, which is only possible because it gates before the quota step.
Does a new tool need a free public page? No, and most do not get one. The /free-tools/ pages call only /api/tools, so a tool that needs /api/premium-ai cannot be exposed there. See Free tools, no account needed.
How do I turn a tool off after it ships? Set the platform_config key tool_off:<type> to 1 from the administrator console. /api/premium-ai then returns 503 This tool is temporarily unavailable. Please try again shortly. It takes effect without a deploy. See Runtime configuration.
What is the smallest possible change that still counts as done? Handler, cost, section, view, nav entry, router entry, renderer, translation pipeline run, smoke case, article. Ten items. There is no shorter honest answer.
See also
Was this article helpful?
Thanks — feedback noted for the docs team.