iconic
Platform
Platform
Explore Custom domains →

Your own .com, connected in a sentence. Every record it needs arrives in one answer, and the certificate is handled for you.

Bring your ownSSLInstant setup
Solutions
Sign InGet Started
Reference

Iconic for agents

You talk to a person. Iconic makes what you build for them real: hosted, live, and permanent.

What Iconic is

Iconic turns a chat into a real hosted website or app. Connect over MCP and you can create a project, write actual code (Vike + Hono + React, an InstantDB database, R2 file storage, hosted on Vercel), watch it render in a live preview, wire forms with automatic confirmation emails, track analytics with conversions, manage content through a built-in CMS, take payments through Stripe Connect (the owner's own account holds the money), and publish to a real domain, all through tool calls, with no human dashboard required. The user describes; you build; the thing exists at a URL and stays up after the conversation ends.

How to connect

  • Endpoint: https://mcp.withiconic.ai/mcp (streamable HTTP). Sign-in is OAuth: the user authenticates with Google in their browser; you never handle credentials.
  • Claude (claude.ai, the Claude app, Cowork): one plugin install (Customize → Plugins) delivers the Iconic connector and its skills together. On Claude's Free plan, add Iconic as a custom connector instead: Settings → Connectors → Add custom connector, paste the endpoint URL.
  • Claude Code / any MCP client: add the endpoint as a remote MCP server; the OAuth flow opens in the browser on first use.
  • ChatGPT: connects through developer mode (Settings → Security and login), then Plugins → Create app with the endpoint above. Developer mode carries the whole tool surface, reads and writes alike, so building works there. ChatGPT asks the person to approve a write before it runs, and lets them keep that answer for the rest of the conversation, so a first build pauses on a human once per tool: that pause is not a failure and calling again will not clear it. A plugin-directory listing is pending.
  • Checking the server before you have signed in: GET <server>/health (https://mcp.withiconic.ai/health) is a plain unauthenticated web request that answers {"ok": true, "time": "…", "build": "…"}, where build is the seven-character commit this deployment was cut from (null when nothing stamped one). The product's web host answers the same shape at https://withiconic.ai/health, and the two deploy separately, so comparing those two values is how you tell which commit each half is actually running. Nothing else on the server answers a caller with no token: /mcp returns 401 to every unauthenticated request, initialize included, because that 401 is what makes an MCP client start the OAuth flow. So a 200 from /health alongside a 401 from /mcp means the server is up and the user needs to sign in, and no answer from /health means the server itself. The ping TOOL is not that check: a tool call needs a connection already made. ping is the one public tool, so it answers signed out on the lazy endpoint (https://mcp.withiconic.ai/lazy/mcp); on /mcp the token is required before any tool runs. Every other tool requires the signed-in user.
  • This guide is also an MCP resource. Read iconic://docs/agents-guide over resources/read whenever you need detail the server instructions leave out. It needs no sign-in.
  • A newly shipped tool will not appear mid-session. Clients snapshot the tool list, so a tool added to Iconic after your session started is invisible until that snapshot is refreshed, and the fix differs by client. On claude.ai the list is cached server-side for roughly 10 to 15 minutes: wait it out, and do not tell the user to reconnect. In Claude Code the list is captured once at startup, so waiting never helps and neither does reloading plugins; the session needs a full /mcp reconnect. Either way, a tool you expected but cannot see is a staleness problem, not a missing capability, so do not build a workaround around its absence.
  • You can check whether your tool list is current, rather than guess. ping and whoami both return tool_surface_version, an identifier for the set of tools the server is serving at the moment it answers, plus build_commit when the host names a build. It changes whenever a tool is added, removed, renamed, or given different parameters. It deliberately does not change when wording is edited, a tool's own description or the explanation written on any one of its parameters, so a release that only rewrites copy will not send you looking for a change that cannot affect a call. Read it when the session connects and read it again when something looks wrong: two different values are proof your snapshot was captured before a change, and the fix is the one for your client above. Nothing your client can search will tell you this, because the search reads the same cached snapshot as everything else, which is how a stale list ends up confirming itself. Two symptoms are worth checking it against before you report a bug: a tool that looks missing, and a tool that refuses a parameter you are sure you sent. The version covers every tool the server registers, so it can move for a change you would never have seen; reconnecting costs seconds, and acting on a stale list costs a session.

The build path

  1. create_project with a name (and a lane: app or marketing). Returns project_id, slug, and job_id immediately; setup runs in the background, usually 2–4 minutes.
  2. Poll check_progress with the job_id every 30–60 seconds until status is done. The done result carries the app's preview link plus preview_answering: true means the link was fetched and the app was seen live at it, false means the app is ready but its link is not, so call start_preview before telling anyone to open it.
  3. Build. The fresh project's pages are throwaway template placeholders. Replace every one of them in full for what the user actually asked for; never ship or lightly edit example content. Work like a developer: read_file, edit_file, write_file, glob, grep, exec. Edits are live in the preview immediately. Use preview_page_map / preview_inspect to see what the user is actually looking at before changing it.
  4. Instrument. Flag every meaningful interaction with data-iconic-event="<short name>" so the owner's analytics count it, and add data-iconic-convert to the actions the site exists to drive (book, buy, sign up, contact). Those become the conversions that fill the owner's dashboards. An unflagged interaction is invisible.
  5. Forms get confirmation emails. Every signup/contact form (create_form) should be paired with a confirmation email designed in the site's own visual style and wired with save_email_design (form_id). Don't leave a form without one.
  6. save_version after every completed set of changes, with a short name the user will recognize, ~10 suggested next steps, and brief change comments. The name, the suggestions and the comments are all required: a save without them is refused before anything is saved. Versions are the history rollback returns to.
  7. publish only when the user has said to. Publishing is the owner's call, never a way to check your own work: it puts a page in front of the public, and on a project imported from GitHub it merges a pull request into their own repository. Ask before the first publish, and ask at the same time whether they want a publish after each change from then on. Record their answer with publish_preference so the next session and any other agent inherit it instead of asking again; publish's own result carries it back as standing_preference, so the ordinary path costs no extra call. Then: returns a job_id; poll check_progress; the done result carries site_url, the site's own address at https://<slug>.oniconic.app, or the project's own custom domain once one is connected (set_custom_domain, or the Iconic web app). Publishing saves a version first, so pass name, comments and suggestions exactly as you would to save_version. comments and suggestions are required whenever the publish actually writes a version, which is any time the files have changed since the last save; when nothing has changed the existing version is reused and they are not asked for, so a save followed straight by a publish never makes you write the same notes twice. Never resend an older save's notes to fill the gap: they would describe the wrong version. Without a name the history fills with unnamed automatic entries nobody can tell apart. On a project you did not just build, call publish_status first: it says whether the site is already live, which saved version is live, what has not gone out yet, and which domains it answers on.
  8. Always end by giving the user the live link. If time has passed since the last check, call start_preview first. It fetches the link itself and only returns it after seeing the running app answer on it (waking the workspace if it slept). When the link is not answering it returns no link and says so; the app and its work are fine, so keep working and call start_preview again shortly. Never guess a URL, and never pass on a link a tool declined to give you.

Features that need AI while the app is running

Some features only work if the finished app can call a model while a visitor is using it: summarizing a long page, answering questions about the owner's own content, making a picture from a description, restyling a photo somebody uploads.

Iconic runs the models. Never ask anyone for an AI key, for any model. There is nothing to sign up for and nothing to paste. Every project is already wired to the Iconic Router, which reaches every model the platform offers: language models, embeddings, and picture models alike. The provider keys are the PLATFORM's and never reach a project. What the models cost comes out of the site owner's own prepaid AI balance, never out of Iconic's pocket: a new account starts with $1 to try it with, and gets $5 more the first time it becomes paid. Both are ONE-TIME for the life of the account, not monthly. When the balance runs out the Router stops sending requests and says so, so nothing is ever billed by surprise and nothing keeps running on somebody else's money. A request already in flight finishes even if it crosses zero; the next one is refused. The Router also paces bursts so a runaway loop cannot drain a whole balance in seconds: too many AI requests in a minute get a 429 with a Retry-After naming the exact wait. The per-minute ceiling rises with the site's plan and the top plan has none; ordinary use and even a busy batch stay well under it. This is a pace limit, not a spend limit, and a built app should treat a 429 the way the AI SDK documents, by backing off and retrying after the named wait. A second, much rarer 429 (ai_unpriced_limit) covers the handful of models the Router cannot get a price for: because those calls cannot be billed to the balance, an account may make up to 1,000 of them a day, counted across all of its sites together the same way the balance is, and past that the Router asks the app to use a model that does report its price. It names the model and the exact number, and ordinary apps never see it, because nearly every model is priced. Add more with top_up_ai_balance, which returns a secure checkout link for a chosen dollar amount; the balance is ready within a minute or two of payment. On a site somebody else owns, pass that site's project_id so the money lands on the balance that site's AI actually draws on. get_usage reports the balance, and the owner sees it (and can top up) in the workspace under Analytics, in Usage.

Use the AI SDK exactly as its own documentation describes. A project is stamped with the Router's address and its own key to it (ICONIC_AI_BASE_URL and ICONIC_AI_SECRET), and its server startup already points the AI SDK at the Router. So a call site is ordinary:

import { generateText } from "ai"
const { text } = await generateText({ model: "anthropic/claude-opus-5", prompt })

Naming a model as a plain string is all it takes. Streaming, tool calling, structured output, and embeddings work the way the AI SDK documents them, because the Router forwards to the gateway unchanged and hands the answer back untouched.

  • A project scaffolded before this shipped needs one line. Its server entry does not import the wiring yet, so a plain model string there reaches nobody and the call fails. Put import "@/lib/ai" at the top of +server.ts once, and every call site in that project is ordinary from then on. New projects already have it.
  • An API endpoint is its own server. Files under endpoints/api/ deploy as separate functions and do not run through +server.ts, so one that calls a model starts with import "@/lib/ai" of its own.
  • Call it from the server, never the browser. The key is a server credential, and a browser holding it would be handing it to everyone.
  • Any model, by name. anthropic/…, openai/…, google/… and the rest all work through the same door, so switching models is a string change and nothing else.
  • Usage shows up under Analytics, per project, priced from what each call actually cost. Nothing is capped: an app that needs a lot of model calls is allowed to make them.
  • Pictures are model calls too. They come back the way the model returns them; if a picture needs to live at a permanent address, put it in the project's file storage with upload_file, passing the picture's bytes straight to content_base64 when you are holding them (or upload_local_file when the picture is a large one on the person's own machine), and never write it into the workspace (a workspace sleeps and can be rebuilt, so a file written there is a link that dies).
  • Stock photography is still the right tool for photography. search_media finds real photographs, which is what most sites actually want; a picture model is for the made-to-order case.

Tool reference

Every tool returns a human-readable text payload; tools with structured output also return structuredContent with the fields listed.

Paging works the same way on every list. A list takes limit and cursor, returns 20 rows when you name no limit, and honors any limit you name up to 100. The 20 is about your context window, not about what is stored: a list read early in a conversation should not crowd out the work. The 100 bounds one reply's size. Neither number is a ceiling on what exists, because every capped list hands back a next_cursor while rows remain: pass it back and keep going until none comes back, and never report a total from a single page. Ask for more than 100 and you get 100, and the result says so in its text and sets limit_clamped: true with requested_limit and limit_used alongside, so a short page is never mistaken for the end of the list. Two lists carry their own numbers for stated reasons: stored files page 50 at a time up to 200 (a file row is small), and a project can hold at most 20 forms, so its form list stops where the product does.

One tool draws a visual card today: publish. On hosts that render embedded views, a publish shows a live progress card in the chat while the job runs, ending in a picture of the site and a button that opens it. The connector used to attach embedded views to about two dozen results (the workspace screen, the live preview, the version just saved); those are all switched off, and they come back one at a time as each earns it. Nothing depends on the card: it is a picture of what the result already says, so every tool still returns its full text and its full structured payload, and every result that changed something still says where the owner goes to look at what changed.

That link is app_url (the matching screen in the full web app, no expiry), or a named twin of it (responses_url, blog_url, settings_url, account_url, site_url). Relay it. The companion field embed_url, a short-lived read-only view a card draws its picture with, rides only the one carded result (publish), and only publish takes the show_card input that declines a card: everywhere else there is nothing to grant and nothing to decline. Every tool refuses a parameter it does not declare. The parameter list in each entry below is exact: send a name that is not on it, in any spelling, and the call is refused before it runs, with a message naming what you sent and, when it is close to a real one, which one you meant. Nothing is silently ignored, and nothing partial happens. Case and separators are not corrected either, so projectId is refused exactly like projct_id: use the spelling the tool declares. A refusal for a MISSING required parameter names one more possibility, because it is a symptom a stale tool list produces: if you are sure you sent it, your client may be holding this tool's parameters from before the latest deploy, and ping reports the version that settles it. Errors come back in-band (isError) with a short message that says what to do next. A non-zero exit from exec is a normal result, not an error. A tool that needs the project's workspace never hangs forever: every such call is given a time limit sized for what it is doing (a file read is not a build, and a call that has to wake a sleeping workspace is allowed the extra time that takes). When the limit passes, the call comes back as an error saying the workspace is not answering, how long it waited, that calling again will not clear it, and to use restart_workspace followed by start_preview. Treat that message as final for that workspace: every other workspace-backed call will do the same thing until it is restarted. Unknown and foreign IDs are deliberately indistinguishable ("Unknown project id"). Conditional tools (noted per group) are simply absent from tools/list on deployments without the backing service. The list is always truthful.

Connectivity and identity

  • ping: connectivity check over a connection that already exists. No params. Text: server time, whether you're signed in, and the tool surface version with the rule for reading it. Structured: server_time, signed_in, tool_surface_version, plus build_commit when the host names a build. Reach for it when a call has just failed: an answer means the server is reachable, the answer says whether the user is signed in, and the version says whether the tool list your client is holding is still the current one (see How to connect). It is the one public tool, so on the lazy endpoint (/lazy/mcp) it answers signed out; on /mcp every request needs the bearer token, so signed out there is a 401 before any tool runs. With no connection at all, the signed-out check is GET <server>/health, described under How to connect.
  • whoami: who is signed in. Identity only. No params. Text: name and email (relay those, not the internal ID), the plan by name, a pointer to get_usage for the numbers, and the tool surface version. Structured: name, email, user_id, plan (the effective workspace plan, when the billing read succeeds), trial_ends_at (present only while the account's own team is on its 14-day Pro trial; plan already reads "pro" for the same window, and the text names the end date), account_created_at, last_login_at, tool_surface_version, and nothing else (the schema is closed). Every allowance, ceiling and usage figure lives in get_usage, which is where a number and the limit it counts against are read side by side; a plan NAME is identity, what the plan ALLOWS is a usage question. tool_surface_version is the one field here that is not identity: it describes the connection this answer came over, it is the same value ping returns, and it is here because orienting yourself on a connection includes knowing whether your view of it is current.
  • update_profile: sets the display name and photo shown for the signed-in account (the account's own profile, not a project's mark; that is set_project_icon). Params: name (1–120 characters; surrounding and repeated spaces are tidied), picture_url (must be the https address of a Google account photo, because it is rendered as an image in the user's own workspace). Pass only what is changing; the other field is left alone. Structured: name, picture_url, updated[], account_url (the account page, where the name and photo are shown). Same validation as the web app's profile screen, so a value one accepts the other accepts.

Telling us when Iconic is the problem (conditional: only where the deployment keeps a feedback inbox)

You are the one who finds out where this platform is wrong, and until recently there was no way for you to say so: the sharp edges that got fixed were the ones somebody happened to relay by hand, and everything else died at the end of a session. That is what this tool is for. Send one the moment something here gets in your way, then carry on with what you were doing.

  • send_feedback: sends a bug report or feedback about Iconic itself to the people who build it. Params: comment (required, and the only required one: what happened, in your own words, however briefly); kind (optional: bug, confusing, missing, idea, and a report without one is filed exactly the same); and any of eight independent pointers, all optional, all safe to leave out: project_id, tool_name (the tool that misbehaved, which is how reports about the same tool get read together), checkpoint_id, collection, form_id, email_design_id, domain, job_id. Who you are, which client you are calling from, and the time are taken from your sign-in, so there is nothing to supply. What is worth a report: a tool that failed or refused for a reason you could not act on, a capability you expected and could not find, wording that misled you, a result that was true but useless, or an idea. Say what happened, what you expected instead, and how to reproduce it if you know. Do not paste secrets, keys, or a customer's private data into the comment: describe the shape of the problem, not the content of somebody's site. There is no ticket and no reply inside this session, so nothing waits on you: the report goes to an inbox the team reads, and the result hands you a reference to quote to the user so they can refer to it later. When the thing is solved, one email goes to the address the account signs in with, saying what the problem was and that it is fixed, so tell the user that is where the answer arrives. Comments longer than 4,000 characters (about 600 words) keep their start and their end and lose the middle, which the result tells you about, so paste the part of an error that matters rather than all of it. A pointer longer than 300 characters is refused, naming the field, because a truncated reference is a wrong reference. One account can send 60 reports an hour, which no genuine session approaches; past it the tool refuses out loud, names the minute it reopens, and asks you to gather the rest into one report. An unknown or foreign project_id is refused in the usual words and nothing is filed, so a report never carries a reference nobody can follow. The site is the only pointer that is checked; the other seven are recorded exactly as you write them and are never verified, which is deliberate, because a report saying an id does not resolve must not be refused for not resolving. A kind we do not recognize never refuses either: the report is filed without one and the word you used is kept alongside it, so a word agents keep reaching for is something we can see. If the ceiling refuses a report, that report was not recorded, so do not tell anyone it went in. Structured: feedback_id, recorded_at, body_truncated, body_characters_submitted, body_characters_stored, plus kind and any pointers you passed, plus kind_not_recognised when the label was not one of ours, plus adjustments[] whenever something was changed on the way in.

When Iconic tells you something (conditional: only where the deployment keeps a notice ledger)

Two different things arrive the same way, folded into the result of a call you were already making, at most one per result. A capability notice is a changelog entry: something new that a project built earlier could have. A directed notice is a message somebody addressed at this particular site, such as that a bug reported from it is fixed. They are told apart by their opening line, they are paced separately so neither can starve the other, and both are answered with the same tool.

Improvements to what a new project starts with do not reach a project that already exists. The platform will not patch your code to fix that: it has never read a project for meaning and it is not going to start, because every project diverges and only you know what this one's differences are for. So it announces instead. A capability that arrived after this project was built is folded into the result of a call you were already making, at most one per result, and only on calls that change the workspace. Read it, decide whether it belongs in this codebase, apply it if it does, and answer it either way.

Three rules about acting on one. Add only the change described, and never replace one of this project's files with a version from the platform: a file copied wholesale into a long-diverged project has already killed two production routes while working perfectly in the preview. Search this project's own source, not its build output, which holds older code in minified form. And leave the running dev server alone, because restarting it or starting a second one takes the preview down for whoever is watching.

Answering is expected. An unanswered notice comes back about once a day until you answer it, which is deliberate: one delivery can land in a session that never acts, and a notice nobody ever sees is the same as no notice at all. Answering stops it for good, and a not_applicable reason is how we find out we wrote a notice that does not fit real projects.

If you do not have acknowledge_notice, you will not be sent one either, wherever we can tell: nothing is announced on a connection this server is not serving that tool to. The case we cannot tell is a stale tool list, which every client keeps its own copy of and no server can read: a session that connected before these tools shipped can still receive a notice and has no way to answer it. The notice says so itself, at the end. Believe it rather than checking: the tool really is absent, looking again cannot change that, it is not a bug, and a session started later will have it. What you can still do is the work, and where a notice is offered on what your files show, making the change is what stops it.

  • list_notices: the platform notices open for a project, oldest change first. Params: project_id. Text: every open notice in full, exactly as it was announced, so you can act straight from here. Structured: open (each with notice_id, title, shipped_at, the whole notice text, and applies), answered (each with notice_id, outcome, and the note and acknowledged_at when there are any), open_count, does_not_apply, and, where the deployment carries directed notices, messages (the unanswered ones addressed at this site, each with its full text), answered_messages, withdrawn_messages (ones Iconic recalled before anybody answered them, listed by title rather than dropped in silence, with the body deliberately not repeated) and message_count. What is listed are notices that MAY apply. This tool reads the project's record and never its files, so it can tell that a notice does not reach a site (it was built after that capability shipped, so it already had it) and it can never tell that one does: that is applies: "unknown", and judging it is yours. The notices the record rules out are named in does_not_apply rather than dropped in silence, and you are never asked to answer for one. Reach for it when a notice arrived in an earlier result and you no longer have it, or when picking a project back up and you want to see what a previous session decided. Reading is member level: anybody who can open the site can read them.
  • acknowledge_notice: records what you decided about one notice, so it stops being offered for that project. It answers both kinds. Params: project_id, notice_id (from the notice itself or from list_notices), outcome (applied, noted, or not_applicable; noted means you read a directed message and there was nothing to do, and it is refused on a capability notice, where deciding is the whole job), and note (a short reason, required on not_applicable, welcome on applied to say what you did). Text: that the notice is retired, plus how many are still open. Structured: notice_id, outcome, note and acknowledged_at when set, and open_count. It records a decision and changes nothing in the project, so call it after you have done the work, not instead of doing it. A reason longer than 500 characters is refused rather than shortened, an id nothing ever announced is refused with the ids that do exist, and answering needs manage rights on the site while reading does not. Answering one notice does not release the next one early: the announcements stay at about one a day, and list_notices is how you pull the rest when you are in the middle of it.

Messages addressed at one site

A directed notice is not a changelog entry. Somebody wrote it to this site, and what you answer is the only way they find out it reached you.

It opens by saying so, and by saying nothing about this project has been changed. There is no "how to apply it" and no method block, because it is a message rather than a capability. Iconic is the only sender: this is the platform's own channel to the agents building on it, not a way for one person to write to another, and "sent by the Iconic team" is all you are ever told about who wrote it. No account addresses travel on this rail.

It arrives on any successful result naming the project, reads included, not only on calls that change the workspace: a person is waiting, so there is no reason to make their message wait for you to be mid-edit. An unanswered one comes back at most every four hours, and at most one every fifteen minutes when several are waiting.

The one thing this rail cannot do: a message reaches a site only when somebody works in that site. There is no push. A message sent to a site nobody opens sits undelivered indefinitely, so the one in front of you may have been written days before you picked this project up: you are being told now because you are the first to work here since it was written, not because it was written now.

Nothing here is yours to send. This rail runs one way, from Iconic to whoever is working in a site, and answering it is the whole of your side. To tell Iconic something about a project, call send_feedback, which is the channel that runs the other way.

Projects and lifecycle

  • create_project: creates a project and starts background setup. Params: name (required), lane ("app" default, or "marketing"), team_id (optional, from list_teams: the team the site belongs to). Leave team_id off and the site lands in the only team the account is in when there is just one, and in the person's own workspace when there are several. For nearly everybody the only team IS their own workspace, so nothing changes; the case this covers is an account whose single team is a shared one, where a site would otherwise land somewhere they do not work. Pass it when they say which team the site is for, or when they are working in a team and expect it to land there. A team they hold no seat on and a team that does not exist give the same refusal, so a refusal never tells you whether a team is real, and nothing is created when one is refused. Text: creation confirmation, the future web address (<slug>.oniconic.app), polling guidance, and the project's own page in the web app, where the user watches setup run. Structured: project_id, slug, job_id, status: "setting_up", name, progress: 0, step, app_url (that page, live from the moment the project exists, unlike the preview address, which does not exist until setup finishes, so no preview URL appears here), included_basic (present and true when this site took the team's included Basic). The slug is derived from the name automatically; there is no slug input. A paid team includes ONE Basic site, and a new site takes it automatically when it is free (see included_site), so the result says so and the site starts on the bigger allowances at no extra charge.
  • import_github_project: imports an existing GitHub repository as a project (available when the deployment has GitHub wiring). Params: repo (required: owner/name or the repo's github.com URL), dev_branch (default "dev": the branch edits made here save to, created from the default branch if missing), deploy_branch (the branch releases land on; defaults to the repository's default branch; must differ from dev_branch), name (defaults to the repo name). Requires Iconic's import app installed on the repository (it is listed as Iconic Connector, and installs from https://github.com/apps/iconic-connector/installations/new). This is a separate, minimal app from the one Iconic uses for its own managed projects: it only asks for contents, metadata and pull requests on the repositories a user picks, and it cannot create repositories anywhere. The repo also needs a root dev script serving port 3000 for the live preview. When the app isn't installed there, the refusal names it and carries that install link, so relay the link and ask the user to pick the repository. The user's GitHub account must also be connected to their Iconic account (installing the app is not enough on its own): a one-time sign-in with GitHub, and if it hasn't happened yet the refusal carries the connect link, so relay the link, have the user open it and approve, then run the import again. Iconic records only which GitHub user the account is (never a GitHub token), and every import then checks that user's access to the named repository live, automatically: write access or better imports, anything less refuses and says so. Revoking someone's access on GitHub revokes their imports the same way. A repository already imported by a different account or team is refused as unavailable; if the user believes that is wrong, they should contact support. The import clones the repo into the project's workspace in the background (usually 2–6 minutes; poll check_progress). The repository stays the source of truth: saves land on dev_branch as commits, and publish releases by opening a pull request from dev_branch into deploy_branch and merging it, never a direct push. Text: confirmation, the working model, and the project's workspace page, which answers from this moment and is where the preview shows up once the import lands. Structured: project_id, slug, job_id, status: "importing", name, repo, dev_branch, deploy_branch, progress: 0, step, app_url (that page).
  • duplicate_project: creates a NEW project whose code starts as a copy of an existing project's workspace as it stands, unsaved work included (the copy forks the original's workspace; when there is no workspace to fork it is built from the latest saved version instead, and the result's note says so). Available when the deployment has GitHub wiring. Params: project_id (required: the project to copy), new_name (defaults to the original's name with "copy" on the end; a second copy of the same project keeps the name and quietly takes the next free web address). A copy counts toward the plan's project allowance exactly like a new project, and a copy that fails to build counts for nothing, exactly like a first build that never lands. Setup runs in the background the same way (usually 2–4 minutes; poll check_progress). What a copy carries: the code, and nothing else. Everything a project accumulates is keyed by its id, and the copy's id is new, so it starts with no visitor stats, no form responses, no sent emails, no connected domain, and no saved-version history: the copy's history begins with its own first saved version, labelled after the original. Database: when the original has one, the copy gets a BRAND-NEW database of its own carrying the same tables and NONE of the rows. Its permission rules are not copied either, so it is born denying every read and write, exactly like any new database: open it with setup_database's permissions parameter. Collections and forms live in that database, so the copy starts with none of those too. When the original has no database the copy has none either, and setup_database adds one whenever it is wanted. If the database could not be set up, the copy still lands and check_progress reports the caveat on the finished job. Stored secret keys are deliberately NOT copied, because a key is often issued for one site: the result names every key that was left behind so it can be added back with add_secret. Work done on the original since its last save is not included, so save it first if the newest changes matter. Refuses when the project has never been saved (nothing to copy) and when its code lives in a GitHub repository the platform doesn't own (copy it on GitHub, then bring the copy in with import_github_project). Text: what was copied into what, the copy's future web address, what did and did not come across, the keys left behind by name, polling guidance, and the copy's own page in the web app. Structured: project_id (the COPY), slug, job_id, status: "setting_up", name, source_project_id, source_name, not_copied, secrets_not_copied, database_copied, progress: 0, step, app_url.
  • project_status: the project manifest, observed without waking anything. Params: project_id. Text: name/slug, setup state, workspace state, latest background activity, never a preview URL (call start_preview for that). The workspace is reported as TWO separate facts, and they are not interchangeable. sandbox is the platform's own RECORD of the workspace, which is cheap and always available and can go on saying running while the workspace answers nothing at all. workspace_observed is what a live check saw just now AT THE PREVIEW ADDRESS: live, snapshot (the saved copy is being served, not the live workspace), starting, asleep, not_found, failure or unreachable, with workspace_observed_at (when) and workspace_probe_timeout_ms (how long the check waited before reporting what it saw). workspace_exec_observed is the other half, and it is the one the tools run on: answering or not_answering, with workspace_exec_probe_timeout_ms. It is a real command run in the workspace just now, and it is a SEPARATE fact from the address, because the address is served by the site's dev server while exec, read_file, write_file, glob, grep, saving and publishing all go through the workspace's command channel. Those two fail apart from each other: a workspace can serve a perfectly good preview page while every tool call against it hangs. The checks never wake anything, which is why the command check is ABSENT on a workspace the record says is asleep: running a command there would start it up. Every one of these fields is ABSENT when no check could be made, which means "not known" and must never be read as "it is fine". When the record says the workspace is up and the check says it is not the thing answering, workspace_not_answering: true is present and the text leads with it. That combination means every workspace-backed call (exec, read_file, glob, grep, start_preview) will hang, and calling them again will not clear it: call restart_workspace, then start_preview. It fires on EITHER channel: a wedged command channel counts even when the preview address is answering normally, and the text names which channel failed. The field is present only when it holds, so its absence is never a promise it was checked. workspace is the one derived reading of all of the above, and it is the field to read first. It is an object: state (healthy | asleep | not_answering | absent | unchecked), next_step (what to do about it, in plain words), checked_at and checked_within_ms when a check was made, and evidence carrying the raw facts it was read from (record, observed, exec_observed). It exists because the raw facts are not readable alone: snapshot is the normal, healthy answer for a sleeping project AND the symptom of a wedged one, and only the record tells those apart. Unlike every other field here it is NEVER absent: when nothing could be checked it says unchecked, because a verdict that vanished when nobody looked would be read as the absence of a problem. Structured: project_id, name, slug, scaffolded (bool), sandbox (state string or "none"), workspace (the derived reading, always present), workspace_observed/workspace_observed_at/workspace_probe_timeout_ms/workspace_exec_observed/workspace_exec_probe_timeout_ms/workspace_not_answering as above, workspace_expired (bool), workspace_resumed_at (ISO time of the workspace's last restart from a stop, read from the platform's own wake record over the last 30 days; compare it with your own last change to tell whether the workspace restarted under recent work, and verify that work is still in place when it did; ABSENT when no restart is on record in that window or the record could not be read, which must be read as "not known", never "never restarted"), workspace_behind_saved_versions (a restart can bring the workspace back on an OLDER copy of the files, which is what happens when a stuck workspace was too stuck to save its state before it stopped; the platform works out which copy it came back on and compares it against the project's saved versions, and this field is present ONLY when that copy is older than saved versions, and then it is how many are newer; only versions saved BEFORE the workspace came back are counted, because a version saved after that is a copy of the files as they already stand rather than work missing from them, alongside workspace_restored_from_saved_at (when that copy was written), workspace_behind_newest_version_id and workspace_behind_newest_version_label (the version to bring the files forward to) and workspace_restore_timeout_ms (how long the check waited); when it is present the text leads with it and the fix is one call, rollback with that checkpoint_id and scope: "workspace", and nothing is lost either way because saved versions live in the project's own repository; the whole group is ABSENT both when nothing could be measured and when nothing is behind, deliberately, so an absent count is never a promise that the files are current), latest_job ({job_id, kind, status, progress} or null), preview_rebuild_failing (present and true ONLY when the most recent attempt to rebuild the preview copy failed and none has succeeded since; absent means the last attempt landed or none was made, and like every field here absent is "not known to be failing", never a promise that it is fine), with preview_rebuild_failures (how many attempts in a row have failed; attempts the platform stood down from, because the workspace was asleep or already building, are not counted either way), preview_rebuild_failing_since (when the first failure in the current run of them was recorded) and preview_rebuild_fault, which is the field to read before you act: site means the project's own build command exited with an error and the fix is in this project's files, and then preview_rebuild_error carries the build's own output verbatim so you can fix it; platform means the failure was the platform's own and the project's files are not the cause, so do not go looking for a fault in them; unknown means the recorded reason names no cause at all, which is not the same as site. On a platform or unknown fault there is nothing to fix in the project: make the next change as normal, the rebuild is attempted again, and if the preview stays behind report it with send_feedback. Plus screenshot_url when the project has a stored screenshot: a picture of its home page, viewable by anyone holding the link for 7 days (structured only, and the one link this tool returns; it is a picture, never a preview address). It also reports every time the site has moved between teams, oldest first, from the platform's append-only transfer ledger: transfers[] of {from_team_id, to_team_id, moved_at}, and a line in the text naming the teams and the dates. That matters because a site's sales, contacts, usage and bills are filed under the team that held it AT THE TIME, while the project row remembers only where the site is now, so this is the only way to put a past number back with the team that earned it. The field is ABSENT (not empty) when the deployment cannot read its ledger: an empty list means the site has never moved, and the two must not be read as the same answer. WHO moved it is deliberately not reported: the ledger records the acting person (a team admin can move a site they do not own), but naming an individual to everybody seated on a team is a different disclosure from naming the teams a site has been in, so neither this tool nor the web app's own history read exposes it. If the workspace expired after long inactivity, the text points at rollback recovery.
  • list_projects: every project the user can reach, newest first, one page at a time: their own sites AND those of every team they hold a seat on, in one list, each row saying which team holds it. Params: limit (1–100, default 20), cursor (from a previous next_cursor), name (optional: find projects whose name or web address contains this text, case-insensitive, searched across the same full scope), team_id (optional, from list_teams: narrow to that one team's sites; a filter, never a requirement). Text: one line per project, with the holding team named on each line when the person is in more than one team, then how to reach the rest. A row with a stored screenshot carries screenshot_url: a small picture of that project's home page, viewable by anyone holding the link for 7 days; rows without one carry no field, never a dead link. Structured: projects[] of {project_id, name, slug, updated_at, team_id?, team_name?, screenshot_url?}, count, total, next_cursor?, plus team_id on the result itself when the list was narrowed to one team and name_filter when it was narrowed by name. Projects live across teams, and this list covers all of them, so a user asking about any project they can access finds it here: when they name a specific project, pass name instead of paging, and never conclude a project does not exist until a name search comes back empty (the empty answer says how wide it looked). Narrowing by team obeys the same rule as creating: a team the person holds no seat on and a team that does not exist give the same refusal, and a team the person is not in contributes nothing to the default list. The list counts and pages ACTIVE sites; an empty team says so and points at creating the first one. Paid plans have no project cap, so a single page is not the whole list: keep passing next_cursor back until none comes back, and never report a total from one page.
  • start_preview: re-verifies the preview is live right now, waking the cloud workspace if needed, and returns the URL only after fetching that URL and seeing the running app answer on it. Two separate facts are checked: the app is up, and the address people receive answers with it. Params: project_id. Text: two lines. First Preview is live: <url>, then the next step, sharing that link with the user. Structured: preview_url, status, name (when known), app_url (the project's Preview screen). When the address is not answering, the platform repairs its routing and looks again; if it still is not answering, the tool returns an error with no URL at all, saying the app is awake and the work is safe but the link is not ready to share. That is a temporary state: keep working and call start_preview again in a moment. A workspace that is not answering at all is a different refusal and needs the opposite response. When the platform's record says the workspace is up but a live check finds nothing answering at its address, this tool refuses immediately rather than waiting on a wake that cannot complete, and it says so: there is no preview to start, calling again will not clear it, and the way out is restart_workspace followed by start_preview. Tell the two apart by what the refusal says, and never retry the second one in a loop.
  • restart_workspace: restarts a project's cloud workspace when it has stopped answering (stuck, hung, frozen, timing out on every read, command and preview). Params: project_id. It works through the platform's own controls and never needs the workspace to answer, which is why it is the one workspace tool that still works while exec, read_file, glob, grep and start_preview all hang: waking a workspace means asking it to run a command, and a wedged one never replies. Reach for it as soon as project_status reports workspace_not_answering (or workspace.state: "not_answering"), or as soon as any workspace-backed tool refuses with the not-answering message, instead of retrying the calls that are timing out. It only STOPS the workspace; the next call that needs it starts it up again, so follow it with start_preview. Read state_saved before you tell anyone their work is safe. Stopping a workspace normally saves its state first, so it comes back exactly as it was. A workspace too wedged to answer can also be too wedged to save, and then state_saved is false, work_may_be_behind: true is present, and the workspace comes back missing changes made since the last saved version: say so to the user, then use list_checkpoints and rollback to bring the work forward. Nothing published is affected either way. A workspace that was already stopped is a polite no-op, and a project with no workspace at all is told to rebuild from a saved version instead. Text: what happened, the caveat when there is one, the next call, and the project's own page. Structured: project_id, status_before (running | starting | stopped | null), stopped (true when this call is what stopped it), confirmed_stopped, state_saved (present only when this call stopped it), work_may_be_behind (present and true only when the state could not be saved), app_url.
  • setup_database: provisions the project's own InstantDB app when it doesn't have one yet (new projects get theirs at setup automatically; this is the backfill for older projects, safe to call twice) and applies the database's permission rules. Params: project_id, plus optional permissions, the database's rules in Instant rules format (the same shape as instant.perms.ts: entity names or $default/attrs mapping to {"allow": {...}} objects, ≤32 KB of JSON). The platform applies passed rules to the project's own database on the owner's behalf; new databases start deny-by-default (every read and write refused), so define rules before real users arrive. Passed rules replace the database's current rules wholesale, and the result says what that meant: for a database that already existed it names the rule set it replaced (or says the previous set could not be read), and after the push the rules are read back from the database itself, so the result reports what is actually in force rather than echoing the input. A read-back that disagrees with what was sent is called out; a read-back that fails leaves the push accepted but says plainly that accepted is not verified and points at get_database_permissions. The env it stamps: VITE_INSTANT_APP_ID (the public app id) and INSTANT_APP_ADMIN_TOKEN (the database's server key: server-only, never available to client code). Server code reaches it through the project's own lib/db-admin.ts, which is already written and whose SDK is already installed, so there is never an Instant package to add. Databases adopted before server-key custody stamp the id alone, and the result says so. While a project's setup job is actively running, the env write is skipped, because the setup itself stamps both keys. Text: confirmation that the database is wired (the dev server picks it up automatically), the perms picture (locked-down note on a fresh database / review note on an existing one / when permissions were passed: the read-back verdict plus, on an existing database, what the wholesale replace replaced), and the auth picture: "Sign-in comes ready in two flavors: Google (recommended: works immediately, no setup) and email code. Both are live now; build with Google unless the user asks otherwise." The auth picture also names the sign-in NAME the database SDK needs, because that name is the one thing a site's own code cannot work out for itself: db.auth.createAuthorizationURL({ clientName: "google-web", redirectURL: window.location.href }), and the same clientName keys db.auth.signInWithIdToken when you draw Google's own button instead. It is google-web on every project, always (the platform writes that name when it provisions the database and reads it back by name ever after), so there is nothing to look up, nothing to query, and no reason to fall back to email codes because Google looked like it needed setup. The name is not a credential: the secret behind it is held by the database provider. The result text is deliberately vendor-free and never carries the server key. Structured: project_id, instant_app_id, env_written (false when the workspace was unreachable or setup is mid-flight; everything is saved and applies on the next workspace setup), admin_token_available (false only for pre-custody databases), permissions_applied (present when permissions were passed; true means the push was ACCEPTED, which is not the same as verified), permissions_verified (present with it; true when the rules were read back from the database after the push; verified means a read HAPPENED, not that it agreed), permissions_match (present when the read-back succeeded; true when the rules read back say the same thing as the rules sent, key order ignored; false means the database is enforcing something other than what was sent, and the read-back set is the one to trust), permissions_in_force (present when the read-back succeeded: the rules the database is actually enforcing, read from the database, never an echo of the input), permissions_replaced (present when an existing database's previous rules could be read before the push: the set the push replaced wholesale; {} means the database had no rules before, meaning everything was allowed), auth_methods (["google", "magic_code"]), google_client_name ("google-web").
  • get_database_permissions: reads the permission rules currently in force on the project's built-in database, straight from the database itself, never a cached or echoed copy: what it returns is what is enforced on every read and write right now. Params: project_id. Use it to verify rules after setup_database applies them, to review who may see or change each table before real users arrive, and to diagnose access problems (a signed-in visitor who cannot read rows, or data that is unexpectedly public). Rules come back in the same format setup_database accepts, so a read can be edited and pushed back. Danger states are loud rather than left to interpretation, and the permissive default is per ACTION, not per table: a database with no rules at all is called out as fully permissive (no rules means everything is allowed); a rule set with no top-level $default warns that any table it does not name is fully open AND that on a named table any action its rules do not set (view, create, update, or delete) is open too unless that table sets its own $default, so a table that rules only view accepts writes from strangers while reading as ruled; and tables whose rules set no catch-all of their own are named outright, in the text and in the payload. A project with no database says so and points at setup_database. Text: the live rules picture with the ruled tables named (phrased as "rules are defined for", never as covered, because naming a table is not governing it), the $default verdict, the per-table gap list, then the way to change them. Structured: project_id, has_database, instant_app_id, permissions (the rules in force), has_default_rule (presence of a top-level $default entry, and presence only: the entry can itself allow everything, so read it in permissions to know what falls back to it actually gets; false means anything no rule governs is open, per action), tables_without_catch_all (tables whose rules name some actions but set no per-table $default: their unnamed actions ride the top-level $default at best, and nothing at all without one). Testing rules end to end, and the false pass that catches everyone: reading rules says what they claim; proving them means querying as a real user, and the obvious way gives a FALSE GREEN. The admin SDK's asUser({ email }) for an address that has NEVER signed in fails with a record-not-found error ("Record not found: app-user"), which a naive test cannot tell apart from the permission rule correctly denying access, so a test against a made-up address passes whether or not the gate holds. Materialize the test user first: the admin SDK has no createUser method, but auth.createToken({ email }) creates the user when none exists (its documented behavior), and auth.getUser({ email }) resolves to null rather than throwing for a missing user, so it can tell you whether an address exists at all. Then an asUser denial genuinely means the rule held. Clean up with auth.deleteUser afterward, and remember it deletes only the user, not the rows they wrote.
  • query_database: runs a READ-ONLY query against the project's built-in database and returns the matching rows. Params: project_id, query. query is a JSON object in the database's own query format (InstaQL, the same shape the project's lib/db.ts passes to db.useQuery), never SQL: top-level keys are table names, each mapping to {} for every row or to clauses under "$" (where, limit, offset, order, fields), e.g. {"todos": {"$": {"where": {"done": false}, "limit": 25}}}. Nest a linked table's name inside another to fetch related rows ({"posts": {"comments": {}}}), and $users is queryable like any other table, so "who has signed up" is one call. The one seat exception: a view-only seat's query naming $users (at any depth) is refused loudly, because those rows are the people who signed up to the site's own app, who are not party to the workspace's seats; every other table answers a viewer as usual, and a member seat queries $users freely. The read goes through the platform with the project's own database credential, so it answers even while the project's workspace is asleep, and it is the cheapest way to look at live data: prefer it over exec-ing a script for any plain read (inspecting what a form or app actually wrote, looking up records, sampling or counting rows while debugging). Writes are deliberately not accepted here: changing data rides the workspace code path (exec + the project's lib/db-admin.ts), where the schema in instant.schema.ts and the permission rules in instant.perms.ts sit in front of whoever writes; a bare write rail would bypass that context. Results are capped at 20,000 characters of JSON: an oversize result is cut in the middle with a loud inline marker ([... N chars truncated ...]), the structured result is withheld (a silently clipped object would read as real data), and the text says how to narrow (filter with where, trim with fields, page with limit/offset). A result too large to even read back (multiple MB) is refused outright with the same narrowing guidance. A refused query (status 400) usually means the query shape is wrong; the table and link names live in the project's instant.schema.ts (read_file). A project with no database says so and points at setup_database. Text: the per-table row counts, then the result JSON (with the cap notice when cut). Structured: project_id, has_database, row_counts (rows returned per top-level row array; nested rows are not counted separately; 0 means the query ran and nothing matched), truncated, result (the full result object, present only when it fit under the cap).
  • site_google_signin: reads whether a site signs people in with its OWN Google client, and starts the switch to one. Params: project_id, action ("attach" for the setup instructions and where to enter the credentials, "detach" to go back). Detach is refused while a custom domain is connected, because a live domain must not fall back to shared credentials: disconnect the domain first with remove_custom_domain, or attach a different client instead. Every site is provisioned on the database provider's SHARED Google credentials, which that provider marks development-only: they are for getting started, not production traffic, so a site expecting real users needs its own client before launch. The client SECRET never passes through chat or through this platform's storage: it is entered on the site's settings and written straight through to the database provider, who has to present it to Google. Only the client id (public, it rides in every sign-in address) and the time of attachment are kept. In Google's console the owner creates a Web application client with the authorized redirect URI https://api.instantdb.com/runtime/oauth/callback (the provider's callback, not the site's) and the site's own address as an authorized JavaScript origin. Tell them to set the Google app to In production, not Testing: a Testing app signs in only its listed test users and expires sessions after seven days, so it works for the owner and fails silently for everybody else. Existing sign-ins should carry over when the client changes, because people are recognized by email address, but relay that as an expectation rather than a guarantee. Every read of this tool hands back client_name, and that, not client_id, is what the site's own code passes to the database SDK: db.auth.createAuthorizationURL({ clientName: "google-web", redirectURL: window.location.href }), with the same clientName keying db.auth.signInWithIdToken for Google's own button. It is google-web on every project and never changes, including after a site attaches its own Google client, because attaching swaps the credentials behind that name rather than the name. The client id, the redirect address and the JavaScript origin are all for the OWNER to paste into Google's console; none of them is what the SDK asks for, and an agent that reaches for one of those instead will not get a working sign-in. Sign-in has to be told which addresses a site answers on, and this tool keeps that list true. A site's database refuses a sign-in that starts on any address it has not been told about, so the list has to name the preview address, the published address, and any domain the site has connected. sign_in_origins is that list; sign_in_origins_not_working names any of them the database is still refusing, and reading this tool registers whatever is missing (a manage seat is needed to change it, and a view-only reader is told what is wrong rather than being left to guess). The result is that Google sign-in works on the site's preview address as well as its published one, so it can be tried before anything is published, and it keeps working when the site takes its own domain. preview_sign_in reports the preview address specifically. Matching is by address, not by page, so registering an address covers every page and every link on it. A site with nothing to report says WHICH kind of nothing, and the difference matters. A project with no database is told it has no database. A project whose database is there and working but was never wired for Google sign-in (every project made before sign-in was built into new ones) is told exactly that: the database is fine, only the wiring is missing, and setup_database adds it to the database already there rather than making another one. Never create a second database on the strength of this tool: it never asks for one. When the platform cannot read sign-in at all it says so and claims nothing about the site, and trying again in a few minutes is the whole of the answer. Structured: project_id, uses_own_client, sign_in_absent (no_database or not_wired, present only when there is no sign-in to report), sign_in_tier (own, preview or shared), client_name ("google-web"), client_id, attached_at, preview_sign_in, sign_in_origins, sign_in_origins_not_working (absent when the list could not be read, which is different from empty), signups and signups_guidance when the count can be read, redirect_uri, javascript_origin, plus app_url (the site's settings). A site on Iconic Preview credentials that people sign in on is cautioned before it attaches a custom domain (see set_custom_domain; a site with no database or no signed-up users is not asked), and the caution says why: those credentials are for preview addresses, and every site on them shares one Google project, so the sign-in screen a visitor sees never carries the site's own name, and a sign-in screen that does not match the site people think they are signing in to is what gets that project flagged and takes sign-in down for all of them at once. The sign-up figure is COUNTED, NOT ENFORCED: sign-ups happen straight between a visitor and the database, so nothing refuses one. Relay it as a warning that gets louder, never as a limit somebody will be stopped at, and treat a missing count as unknown rather than as zero.
  • set_project_icon: sets the mark shown for the project in the workspace. Params: project_id, plus either icon (any lucide icon name, e.g. "croissant") with color (hex), or image_url, one shape per call. Text: confirmation, the project's page in the workspace where the mark shows above its name, and the next step (another call replaces it if it doesn't suit the site). Structured: project_id, icon_kind ("lucide" or "image"), and icon/color or image_url. Set one whenever you create a site or work on its look.
  • project_branches: the branch pair of a project imported from GitHub, readable and settable. Params: project_id, plus optional deploy_branch. Omit deploy_branch entirely and this only reads; pass a branch name to change where releases land, or null to reset it to the repository's own default branch (resolved fresh at publish time). Absent and null mean opposite things, so never send null when you meant to read. The branch edits save to is fixed at import time and cannot be changed here. Refusals, matching the web app word for word: a branch name outside letters, digits, dots, underscores, hyphens and slashes, and a deploy branch equal to the branch edits save to (publishing needs somewhere to merge from). Projects that were not imported from a repository have no branches at all and say so. Text: the repository, the branch edits save to, where releases land, the project's publish settings (the same pair, editable there), and the next step: changing the deploy branch on a read, calling publish on a write. Structured: project_id, repo, checkpoint_branch, deploy_branch.

Background jobs

  • check_progress: status for background work (project setup, GitHub imports, publishing). Params: job_id. Text: progress line while running; on done, the outcome, the link, and one next step: a finished publish closes on sharing the site's address, or, while that address is still connecting, on checking it again in a few minutes; a job kind with no dedicated view closes on the project's own page in the web app, or on list_projects when the job names no project. Structured: job_id, kind, status (queued | running | done | failed | canceled), progress (0–100), step (user-facing stage label); on a done setup additionally result, preview_url, and preview_answering (false means the link was not answering when setup finished: call start_preview before sharing it); on a done publish additionally result, site_url (mirrored into preview_url), screenshot_url when a screenshot of the site exists (viewable by anyone holding the link for 7 days), and fallback_url when the address is still connecting; on failure/cancel, error (chat-safe summary). Stalled jobs (silent past their staleness window) are flipped to failed with retry guidance, and retrying is safe. A first build, copy or import that fails costs the user nothing: the project it had already created is not counted against the plan's project allowance, so retrying never walks somebody into a cap, and the failure text says so. It says so only when it is true, read from the project itself: a failed REBUILD of a project that already exists (the expired-workspace recovery rollback starts) hands nothing back, says nothing about the allowance, and names rollback as the retry rather than create_project.
  • check_progress_widget: the poll endpoint the publish card reads while a publish runs (and the progress card read before it was switched off). Do not call it; use check_progress. It reads the same job store and returns the same status, progress, step and finished result, so calling it yourself only repeats what check_progress already told you.

Files, search, and commands

All of these operate on the project's cloud workspace, not the local machine, and take project_id. .env files are redacted by read_file and grep (secret keys are managed with add_secret; list_secrets shows names).

  • read_file: reads a file as numbered lines ( 42→text). Params: path (relative to app root, or absolute like /tmp/dev.log), offset (1-based line), limit (default 2000 lines). Caps: 2,000-char lines, 50,000 chars total, 5 MB files refused (use exec head/tail/grep instead); binary content refused. Text only.
  • edit_file: exact-string replacement. Params: path, old_string (must match exactly, including whitespace, and be unique), new_string, replace_all (default false). Text: Edited <path> (N replacements) plus ~5 numbered lines of context around the change. Text only.
  • write_file: creates or overwrites a whole file; parent directories are created automatically. Params: path, and exactly one of content (the file as text) or content_base64 (the file's raw bytes, encoded). Text: Wrote <path> (N bytes). Prefer edit_file for small changes. Text only. content_base64 is how a picture, icon, font, PDF, or any other file that is not text gets into the workspace in ONE call. Never encode a file, write the encoding as a text file, and decode it again with exec: that is two extra calls, it doubles what the file costs you, and the intermediate is easy to leave behind. Whitespace, missing padding and the URL-safe alphabet are all accepted, and the bytes land exactly as given. Anything that is not valid base64 is refused outright rather than decoded as far as it goes, because a file cut short is a broken file that still reports success. Files up to 256 KB come this way, which covers icons, logos, fonts, small charts and short PDFs; a bigger one is refused with its real size and that limit, and nothing is written, because the encoded bytes travel through your own context and a photo belongs on upload_local_file, which does not carry them at all. Remember what the workspace is: it holds the project's code, so put a file here when the app itself reads it from disk, and put a logo or a photo the site links to in file storage with upload_file instead.
  • glob: finds files by glob pattern, newest-modified first, up to 200 paths. Params: pattern (brace alternation supported: **/*.{ts,tsx}; no nested braces or brace sequences), path (directory scope, default app root). node_modules, .git, dist always excluded. Text: path list. Text only.
  • grep: searches file contents with extended regex (grep -E). Params: pattern, path, glob (file filter relative to path; basename patterns, path-bearing patterns such as components/*.tsx, and brace alternation such as {Nav,Footer}.tsx are supported), output_mode (files_with_matches default, or content for matching lines with numbers), case_insensitive, context (0–10 lines, content mode). Caps: 100 files / 100 lines. A malformed regex or unsupported glob is an actionable error, distinct from a valid search with no matches. Text only.
  • exec: runs a shell command (bash -lc, cwd defaults to app root). Params: command, cwd, timeout_s, background. Text: exit code N plus stdout/stderr, capped at 20,000 chars keeping head and tail. A non-zero exit is a normal result: read the output and fix the cause. Text only. Use background: true for anything that may run longer than about 45 seconds (installs, full builds, test suites, dev servers, anything that watches). This is the one hard rule on this tool, and it is not about this server: how long a single tool call may take is decided by your client, and some clients abandon one after about 60 seconds and hand you a timeout error. Nothing this server does can extend that. (The platform itself is measured returning a 90 second command without complaint, so a timeout you see is the wait ending, not the work.) What happens when you cross the limit is worth knowing exactly, because it is not what the error suggests: the command is not canceled. It keeps running in the workspace and finishes normally. Only its output is lost, so you are left unable to tell a slow success from a failure, and running it again just starts a second copy. With background: true the command detaches, has no timeout at all, and its combined output goes to a /tmp/exec-*.log path returned to you immediately; read it with read_file while it fills, as often as you like. That is how you watch a long build. The log's last line tells you when it is done: [exec finished, exit code N] is appended the moment the command exits, so a log without that line is still running, and one with it gives you the exit code. timeout_s (default 120, max 600) answers a different question: it is how long the COMMAND may run in the workspace before it is stopped, not how long your client will wait for an answer, which your client decides for itself. Raising it past about 45 does not buy you the output of a longer command, it only lets the command run on without you. Ask for more than 600 and the result says it ran at 600 rather than silently shortening it. glob and grep are given 60 seconds in the workspace for the same reason any scan is bounded; both answer in a second or two on any project this platform hosts. What the workspace has: Node, Bun, git, curl, Python 3 and ffmpeg, plus sudo for anything else you need.

Live-preview introspection (the three bridge tools are conditional: only on deployments with a branded preview address)

These read the page the user is actually looking at (the live DOM in their open browser tab, including draft state and computed styles) via the dev server's bridge. They need the preview open in a tab (start_preview returns the link). All take project_id; the three bridge tools are text-only.

  • preview_page_map: maps the currently open page: every rendered element with its source location (file:line:col), tag, CSS classes, and text. Use it to find which source file renders what the user describes before editing. The result leads with the site's page list, the same list the workspace's page grid shows, resolved from the running preview's own route report when a browser tab has the preview open, from the site's sitemap.xml otherwise (it answers even while the workspace sleeps), and never coming back empty. Capped at 20,000 chars.
  • preview_inspect: one element in depth: rendered HTML, key computed styles, position/size, route, DOM path, and exact source location. Target by selector (CSS) or source (a file:line from preview_page_map). If the preview is showing a build-error overlay, the response leads with that error (message, file, duration). Capped at 20,000 chars.
  • preview_styles: the full computed-style dump for one element (or specific properties[]). Same targeting. Prefer preview_inspect first. Capped at 20,000 chars.

Versions, publishing, and rollback

  • save_version: saves a named snapshot of all project files into its history. Params, all four required: project_id, name (max 200 chars, user-recognizable like "Added a pricing page"), suggestions[] (3–12 items of {title (≤60 chars), prompt, category, costFlag}, suggested next steps that feed the product's "what to do next" surfaces), comments[] (up to a handful of {title (2–3 words), body, elementRef?, route?}, user-facing change notes shown as pins on the owner's preview). The two lists are not optional and an empty list is not a pass: a save that records nothing about what changed leaves the owner a history of names and leaves the next session nothing to read back, so a call missing either one is refused before anything is saved, and the refusal says what to write. Inside a comment, pinning to an element is optional: elementRef and route are worth filling in when you have them, and a comment that names neither is perfectly valid. Once the save is under way, a bad suggestions/comments payload never fails it. Read the result before you tell the user their work is safe. outcome says what actually happened: saved (a new version exists), unchanged (nothing had changed, so the fields describe the version the project was already at), or ignored_only (nothing was saved AND the project's ignore list is holding files out of every version, and skipped_paths names them). On ignored_only, any work that went into one of those files was not captured and saving again will not capture it: say so to the user, and move the work into a file the ignore list does not cover or drop the matching line from .gitignore. Every saved version is a commit attributed to the person whose account ran the save, which on a team's site is whoever actually saved and not the owner of the site: their display name plus a per-user no-reply address. That holds in every repository, Iconic's own and any imported from GitHub, so a sign-in address is never written into a history that other people can read. When the account can't be identified the commit stays platform-authored, never attributed to somebody else. Text: on saved, confirmation with counts, then the project's publish panel (where its saved versions are listed) and the next step, offering to publish so the live site carries the save; on unchanged and ignored_only, a plain statement that nothing was saved (naming the skipped files when there are any) and deliberately NO publish offer, because those outcomes captured nothing to publish, and offering it would put back the false reassurance they exist to prevent. Structured: project_id, checkpoint_id, name, created_at, saved, outcome, skipped_paths/skipped_count (on ignored_only), suggestions_saved, comments_saved, app_url (the project's screen, always present).
  • list_change_comments: the change notes previous saves left, newest first (conditional: only where the deployment stores them). Params: project_id, checkpoint_id (narrow to one save), limit (1–100, default 20), cursor (from a previous next_cursor). Read this when picking up a project you did not just build, so you can see what you or an earlier session already explained instead of repeating it. Structured: comments[] of {comment_id, checkpoint_id?, title, body, route?, element_ref?, created_at}, count, next_cursor?.
  • list_suggestions: the suggested next steps the most recent save captured, whole (conditional: only where the deployment stores them). Params: project_id. One save leaves one batch and a new save replaces it, so this returns at most 12 items and never needs paging. Read it before proposing next steps, so what you offer is not the same list again. Structured: suggestions[] of {suggestion_id, title, prompt, category, cost_flag, created_at}, count, checkpoint_id.
  • publish: saves current work, then builds and puts the site live at its own address (the connected custom domain when one is set, else https://<slug>.oniconic.app) as a background job (usually 1–3 minutes). Params: project_id, plus the same three a save takes: name (max 200 chars), comments[] and suggestions[], plus show_card (default true; pass false to skip the visual progress card on hosts that draw one). On card-drawing hosts, this tool shows a live card in the chat: the publish's stages and progress while it runs, then a picture of the site with a button that opens it once it is live. The card is decoration; everything it shows is also in this result and in publish_status. comments and suggestions are required for the same reason they are required on a save, but only when this publish will actually write a version: if the project's files have changed since the last save, a call without them is refused before anything is published, and an empty list is not a pass. If nothing has changed, publish reuses the version the project already sits at, writes no new one, and does not ask for them, which is why saving and then publishing does not mean writing the same notes twice. Write them from the work you just did and never resend an older save's notes, which would attach a description of earlier work to a newer version. Only their ABSENCE is ever excused: notes that are sent but do not have the right shape are refused whether or not the files changed, because a malformed batch is dropped on the way to storage and you would otherwise be told it worked. In the rare case where the files change in the moments between the call and the save, the publish stops and asks for them rather than saving a version with nothing recorded about it. name stays optional and costs you an unnamed entry in the history. PUBLISHING NEEDS THE OWNER'S PERMISSION, every time until they say otherwise. It puts a page in front of the public, and on an imported project it merges a pull request into their own repository. Ask before the first publish, ask at the same time whether they want a publish after each change from then on, and record the answer with publish_preference. Never publish a change on your own because it is a convenient way to check your work; that is exactly the loop this rule exists to stop. Every result carries standing_preference (unset | ask_every_time | publish_freely) so you never need a second call to see what was agreed; both ask_every_time and unset say so in the result text, and publish_freely is deliberately the silent one. This is advice, not a lock: nothing on the server refuses a publish, so the standing answer works only because you honor it. The save publish makes is a real save. name, comments and suggestions land on that version through the same ingest save_version runs, readable afterwards through list_change_comments and list_suggestions. Leave the name out and the version goes into the history as an unnamed "Published" entry, and the result says so; five unnamed entries in a row and it says the history can no longer be told apart. save_outcome says which of three things the save was, and you must read it: saved (a new version exists), unchanged (nothing had changed, so the existing version was REUSED rather than duplicated, and checkpoint_label names it), or ignored_only. ignored_only is the one that costs somebody their work. It means no version was saved AND the project's ignore list is holding files out of every version, and skipped_paths names them. The publish builds from the project's CURRENT FILES, not from the saved version, so whatever is in those files is now LIVE while no saved version contains it: rolling back would silently lose it. Say so to the user, name the files, and either move that work into a file the ignore list does not cover or drop the matching line from .gitignore, then save again. Never relay an ignored_only publish as a clean one. One publish runs at a time per project. GitHub-imported projects release through their repository's own review flow: the job opens a pull request from the development branch into the deploy branch and merges it (merge commit), then builds the deploy branch's state, never a direct push. That merge lands on the owner's own repository: it is a real commit on the deploy branch, it starts whatever checks, actions and deployments the repository runs on that branch, and everyone watching the repository is notified. The start result names the repository, both branches and the checks before any of it fires, so the owner can be asked first. If the repository requires review, the job completes as "waiting for the pull request to be merged" with the PR URL as the payload: relay the link, and once it's merged on GitHub, publish again (that publish detects the merged state and proceeds). The pull request names who published, by name only, and never carries an e-mail address. If Iconic's import app (listed as Iconic Connector) lacks the Pull requests permission on that repository, the refusal names the app, says exactly what to grant, and carries the link where the updated permissions are approved. This tool's result reports that a publish STARTED, never that one finished, and the difference has bitten a real build. The publish then runs unattended and can still fail: most often because the project was edited while it was building, which the job catches at its preflight and again after the build and records as a failure nobody sees unless they look. So never relay status: "publishing" as "your site is live". Confirm with publish_status (or check_progress with the job_id) before you tell anyone anything went live; a failed one reads as "Some changes happened while publishing, so try publishing again", and publishing again is safe and usually all it takes. Text: job confirmation with the future site_url (plus the branch pair for imported projects), the warning above, and the next step, which is the confirmation call. Structured: project_id, job_id, status: "publishing", name, site_url, progress: 0, step, follow_up: "publish_status" (the tool that settles it), standing_preference (plus standing_preference_set_at once answered) and saved_version_name when a name was passed, plus repo, checkpoint_branch, deploy_branch on imported projects. A publish that is already terminal when the tool answers comes back as an error carrying status: "failed" instead, so a start result never dresses up a failure. Poll check_progress for the outcome; the done result adds checkpoint_label, saved_new_version, save_outcome, skipped_paths/skipped_count (on ignored_only) and, when there is something worth saying about the save, save_note; a done publish on an imported project also carries pr_url. A publish is only reported as live once the site's addresses have actually been moved onto the version it just built, which is checked against the host rather than assumed, and EVERY address the site answers on is checked, not just the one the user knows. When an address is left on the PREVIOUS version, the publish FAILS and says so plainly: nothing is lost, and publishing again is the fix. If some addresses moved and others did not, the failure NAMES the ones that did not, because the site really is part-new and part-old until the next publish finishes the job. An address that has not finished connecting yet is not a failure: it is not live yet, so the publish comes back as a partial success with domain_pending, and the site is not recorded as having gone live. Never read a 200 from an address as proof that a publish landed; the previous build answers exactly the same way.
  • publish_preference: the owner's standing answer about publishing, readable and settable, stored on the project so it outlives this conversation. Params: project_id, plus optional preference (ask_every_time | publish_freely). Called with just project_id it reads; pass preference only after the owner has actually answered. unset means they have never been asked, which is NOT the same as having asked you to keep asking: an unset project still owes them the question. Reading is a member's right; recording an answer is manage-level, because it decides whether an agent may put the team's site in front of the public without asking. There is deliberately no way back to unset, with ONE exception: transferring a site to another team clears it, because consent belongs to the team that gave it and does not travel with the asset. An owner who changes their mind sets ask_every_time, which is a stronger statement than silence. Only record an answer the owner gave in their own words. It is stored under the account you are acting as, so nothing downstream can tell your decision from theirs; recording one they did not give grants yourself standing permission to publish their site. set_by and set_at come back on every read and are the only audit trail that exists. Text: what the standing answer is, who it is attributed to and when, the project's publish panel, and the next step (ask the owner, or publish what is ready). Structured: project_id, name, preference, set_at and set_by (once answered).
  • unpublish: takes a published site OFFLINE: every web address it answers on goes offline, so visitors can no longer reach it. Params: project_id. Use it when the user wants to unpublish, take down, pull down, remove, deactivate or hide their live site. A connected custom domain goes offline too, but it stays CONNECTED to the project, so its DNS setup never has to be done again. Nothing is deleted: saved versions, content, forms, the project's workspace and its publish history are all untouched, and publish afterwards puts the site back at the same address in the ordinary way. Links people saved stop working until then, so confirm the user wants that before calling it. The site's PREVIEW link is untouched: the preview exists before a project is ever published and keeps working. What a visitor sees afterwards is a browser error, not a branded page: the address simply stops answering. A project that is not published is refused politely, calling it twice is safe, and it stands down while a publish is running (that publish would put the site straight back online). While a site is offline, rolling back the LIVE site is refused too, because there is nothing live to move. Text: what went offline, which domains stayed connected, that nothing was deleted, and the next step (publish, with the address it returns to). Structured: project_id, name, offline: true, site_url, addresses[], custom_domains[].
  • publish_status: whether the project is live and where, without publishing anything. Params: project_id. Call it before publishing to see what would go out, after publishing to confirm what did, and whenever the user asks for their site's address or which domains are connected. It is the only read that lists a project's domains. Text: the live address and the version it is on, the saved versions not published yet by name, every recorded address with whether it is live, the newest publish job, and the next step. Structured: project_id, name, site_url (the connected custom domain when one is set, else the included address), published_at (when the site FIRST went live and has been live since, not the last publish: the moment the version now live went up is live.ready_at), live_checkpoint_id, live ({deployment_id, checkpoint_id, url, ready_at} or null), unpublished_count (always exact), unpublished[] of {checkpoint_id, label, created_at} (the newest 10, with unpublished_names_truncated: true when there are more, and list_checkpoints pages through every one), checkpoint_count, latest_checkpoint, gate ({allowed, plan, limit}, or null when the plan could not be read), domains[] of {domain, kind: "site" | "custom", live}, job (the newest publish job with a chat-safe error and its result), screenshot_url when the project has a stored screenshot (a picture of its home page, viewable by anyone holding the link for 7 days), plus app_url (the project's publish panel). Called within 15 minutes of a publish finishing, the result also carries the picture itself, as an image content block (an 800px variant of the site's home page), so show the user what just went live without fetching anything; on every later call the screenshot_url link carries the same picture. A publish job silent past its window is reported as failed rather than as still running; retrying is safe. This is also the only read that checks the version it reports against the host. Every other field here comes from the project's own records, which can be right about what was published and wrong about what visitors actually get: address_serves_live_version is that check (true, false, or null when nothing is live or the host could not be asked), and on a disagreement address_mismatch carries {domain, serving}. Two different disagreements, and they read differently on purpose: when serving names another build, the address is showing visitors something other than what these records say, the result says so outright, and it ends on publishing rather than on sharing the address. When serving is null the address is pointed at nothing, which is almost always one that was only just connected and has not finished; the site usually stays live on its other addresses, publishing cannot hurry it along, and the result neither raises an alarm nor tells you to publish. Relay the first one: it is the difference between a user seeing their new site and a user seeing last week's.
  • list_checkpoints: the project's saved versions, newest first, one page at a time, with the one currently live on the site marked. This is how you find the checkpoint_id that rollback needs, so call it before any rollback. Params: project_id, limit (1–100, default 20), cursor (from a previous next_cursor). Structured: checkpoints[] of {checkpoint_id, label, created_at, live}, live_checkpoint_id, next_cursor?. Saved versions are never removed, so a long-lived project has more of them than one page shows: keep passing next_cursor back to reach older versions. The live marker is what the project's own records say is live; publish_status is the read that checks that against the host, so reach for it when what the site shows looks out of date.
  • set_custom_domain: connects a domain the user owns to the project, so publishes land on it instead of the included platform address (paid-plan feature; conditional: only on deployments with the domain rail). Params: project_id, domain (e.g. yoursite.com or app.yoursite.com; a pasted URL is forgiven down to its hostname). Returns EVERY DNS record the domain will need, in one answer, so the user makes ONE trip to their registrar: the TXT ownership check, the pointing record (CNAME for a subdomain, A for an apex), and, on deployments that send email, the two email records that let the site's mail send from the domain (a signing TXT and a return-path CNAME, minted with the mail service the moment the domain is offered). Relay the whole set together, grouped as the result groups them (website, email); when the TXT record is already in place it attaches the domain and future publishes target it. The email rows are fail-soft: if the mail service cannot be reached the connect still answers, email_records_pending is true, and email_domain serves the two records once the domain is connected. When they are present, email carries their verification state (ready true only when BOTH check out, which is when the site's mail actually sends from the domain; until then mail sends from the platform address under the site's name, so nothing is broken while DNS propagates). Disconnecting the domain (remove_custom_domain) cleans the mail-service registration up with it. Connected is not fully live: the certificate is issued in the background afterwards, so confirm with domain_status before telling the user their domain is done. Text: the records as copyable lines and the next step; the awaiting-DNS and verified-but-not-yet-published answers also name the project's publish panel, where the same records and this domain's state live (the connected answer names the live address instead). Structured: project_id, domain, verified, status (awaiting_dns | verified_not_published | connected), records[] of {type, host, value, purpose, note?} (purposes: verification, pointing, email-signing, email-return-path), app_url (the project's publish panel, always present), email ({ready, signing_verified, return_path_verified}) or email_records_pending when the mail rail exists, plus site_url and workspace_address_pending once connected. The pending answer also says where the domain's DNS is hosted, when that can be detected. Detection reads the domain's nameservers, with the public registration record as the fallback, so it names the actual DNS host even when the domain was bought somewhere else; it runs alongside the ownership check, and failing changes nothing (the generic instructions stand, and the call never fails or waits on it). When the host is recognized the result carries dns_provider ({name, console_url, steps[]}): the provider's name, its DNS console address, and numbered steps for adding the records in that specific console, written to be relayed as they are. When only the registrar could be identified, registrar carries its name instead, and the records are usually added in that registrar's own console. If you can operate a browser for the user (Claude in Chrome, for example), offer to open the console and add the records with them; they sign in themselves, and their credentials never pass through you. Otherwise give them the steps together with the records, so they can do the whole thing in one sitting. Connecting a domain MOVES the address the site advertises to search engines, not just the one publishes land on: the site's canonical link, social tags, sitemap and robots file are all built from the workspace's own copy of the address, and connecting re-stamps that copy with the domain. A site whose canonical link still named the included address was telling search engines the real page lived on a host its owner never wanted ranked. The workspace is never woken for it, exactly as with set_site_address: a sleeping one is reported by workspace_address_pending, and a publish wakes it and reconciles the copy before it builds anything, so publishing from here is safe either way. Wake it yourself with start_preview when you want the preview to show the new address first. A domain that has not passed its TXT ownership check never moves any of this, so a domain somebody merely typed can never become the site's address. The pointing record is a different matter, and worth saying to the user. Connecting succeeds on the ownership record alone, so if they have added the TXT record but not yet the CNAME or A record, the site starts advertising a domain that does not answer yet, and a publish in that window ships pages naming it. That resolves itself the moment the pointing record takes effect, which is usually minutes, so the thing to do is tell them to add BOTH records together rather than stopping after the first one. Connecting a domain also tells the site's database to accept sign-ins from it, which is what makes Google sign-in keep working once visitors arrive on the new address rather than the platform one. It happens on connect and on every re-verify, so a domain connected before this existed is fixed by running verify_custom_domain on it again. If the site has not brought its own Google sign-in and shows signs of sign-in use, this pauses once with a caution rather than a refusal. Google sign-in will not work on the custom domain until the site connects its own Google client, though it keeps working on the site's platform address and everything else about the domain works normally. The caution is evidence-gated: a site with no database, or one whose own user count reads zero, is connected without being asked, because nobody there has a sign-in to lose (a count that cannot be read still cautions rather than guesses, since losing real people's sign-in is the worse mistake). When it does pause, the way through is site_google_signin with action "attach", which hands back the secure setup link and the exact settings Google needs; the same setup lives on the site's settings page, whose link the caution carries. If the user would rather connect the domain now and sort sign-in out later, call again with acknowledge_signin_limit true. That parameter records THEIR decision, so never set it on your own initiative. A domain held by another team answers an opaque "isn't available". Relay it as-is. Within one team it is the team that holds a domain name, not the person who connected it, so two of a team's sites can take names under the same domain (shop.acme.com on one, blog.acme.com on another) whoever set each of them up. A site answers on ONE address at a time, and the www form of an apex is a different address. Connect example.com and www.example.com does not answer at all: it is not attached, it holds no certificate, and a visitor who types it gets a connection error rather than the site. Every result for an apex says so and carries www_companion with the exact name; connect that one too with a second set_custom_domain call if the user wants both, which is usually what they expect. A wildcard is refused as a wildcard, not as a malformed domain: *.example.com is a real thing to ask for that this platform does not do, so ask the user which specific address they want and connect that. A domain the host already holds is permanent, never a "try again". When it belongs to another of the same account's sites the refusal names that site and says to disconnect it there first; when it belongs to somebody else it is the same opaque "isn't available" a claim conflict gets, and no retry will change either answer.
  • verify_custom_domain: re-checks the domain's TXT ownership record after the user adds it. Params: project_id, domain, acknowledge_signin_limit (the same one set_custom_domain takes, and for the same reason: this tool runs the same sign-in caution, so its refusal asks for this parameter and it accepts it). On success it attaches the domain (safe to repeat) and returns the live address, with the email records and their verification state inline, because the user is likely still in their registrar tab; while pending it restates the FULL record set to add (email records included) and names the project's publish panel. Same text endings and same structured shape as set_custom_domain, app_url, workspace_address_pending, email / email_records_pending included, and the same registrar-aware guidance while pending (dns_provider / registrar, with the same offer-the-browser rule). Because it runs the same flow, verifying a domain that is already connected also re-stamps the workspace's copy of the site address, which is the one call that puts an older site's canonical link, sitemap and robots file onto its domain without waiting for anything else. Success means connected, not fully live: confirm with domain_status before telling the user their domain is done.
  • domain_status: whether the project's custom domain is FULLY live, checked against the live internet rather than stored records (conditional: only on deployments with the domain rail). Params: project_id, plus optional domain to check one hostname (a connected domain, or the www form of a connected apex, whose apex is then checked with it). Three facts per hostname: what public DNS answers, whether the hostname is connected to the site at the host, and what certificate a real TLS handshake to the hostname finds. Connecting a domain is not the end of the story: the SSL certificate is issued in the background afterwards and usually takes a few minutes, and until it serves, visitors get a browser security error rather than the site. So after set_custom_domain or verify_custom_domain reports connected, poll this about once a minute and only tell the user their domain is done when the verdict is fully_live; a verdict still in_progress after fifteen minutes or so is worth a fresh verify_custom_domain. The www form of a connected apex is checked too, because DNS-pointed at the platform without being connected it serves a certificate for the wrong name, which is exactly the state that looks finished from everywhere else; when found, the result says so with the one fix (connect it too, or remove its DNS record). Text: the verdict line first (it is the message), one detail line per hostname, then the one next step. A probe that cannot run answers "could not be checked just now" and never claims the domain is broken. Structured: project_id, verdict (fully_live | in_progress | action_needed | nothing_connected), hostnames[] of {hostname, role: "domain" | "www_companion", attached (true/false, or null when the host could not be asked), verdict, reason, dns: {state, a_values, cname_values}, certificate: {state, covers? (capped at a dozen, with covers_truncated when there were more), valid_to?}, action?}, site_url (only when fully live), app_url (the project's publish panel).
  • email_domain: where a site's own domain stands as a SENDING domain, and the re-check button. Params: project_id, domain, recheck (ask the mail service to look at the records again right now, for after the person says they have added them). The two records themselves already ride set_custom_domain and verify_custom_domain's answers (the connect flow mints them with the website records, so everything goes to the registrar in one trip); this tool restates them and reports how far they have verified, whatever state the domain's website half is in. The domain does have to be connected to the site before this tool can answer for it. BOTH records have to check out before mail goes out as the site's own address: one alone is not a working sender and would land in spam folders with the customer's name on it. Until then mail keeps sending from the platform address under the site's own name, so nothing is broken while somebody waits on DNS. Text: which record is still outstanding, the records to add, and what to call next. Structured: project_id, domain, ready (true only when both pass), signing_verified, return_path_verified, records[] of {type, host, value, purpose, note?}, checked_at, plus app_url (the site's domain settings). On a deployment with no account-level mail credential it says email domains are not available there, which is different from the setup having failed.
  • remove_custom_domain: disconnects a custom domain from the project. Params: project_id, plus optional domain. Pass domain to disconnect just that one (a pasted URL is forgiven down to its hostname); omit it and every custom domain on the project is disconnected, which is what to do when the user wants off their own domain entirely. A domain that is not connected is refused, and the refusal lists what is. Publishes fall back to the included <slug>.oniconic.app address once no custom domain is left; detaching a domain that was not the publish target leaves the target alone. Call publish_status first to see what is connected. Never plan-gated (a downgraded account can always disconnect) and safe to call when nothing is connected. Disconnecting the publish target also moves the site's canonical link, sitemap and robots file back to the included address, so a disconnected site never keeps advertising a domain it no longer answers on; the workspace is not woken for it, and workspace_address_pending says so when it was asleep. Disconnecting also releases the domain's registration at the mail service (the one the connect flow minted for the email records), so a detached domain leaves nothing behind; a mail-service hiccup there never blocks the disconnect. Disconnecting also retires the sign-in trust the domain carried. Connecting a domain tells the site's database to accept sign-ins that start on it, and that has to be taken back when the site stops answering there: otherwise the next person to register the domain could serve a page on it and sign real people into the database that let it go. sign_in_origins_released[] names the addresses whose trust is gone, confirmed by reading the database's own list back rather than by trusting the call. sign_in_origins_still_trusted[] names any it could not take back: the disconnect itself still succeeded, and the way to finish it is to call remove_custom_domain again with that domain, which retires the leftover trust even though the domain is no longer connected. Addresses the site STILL answers on are never touched, so disconnecting one domain never breaks sign-in on another. A site with no database has neither list. Structured: project_id, removed_domains[], remaining_domains[], site_url, workspace_address_pending, sign_in_origins_released[], sign_in_origins_still_trusted[].
  • rollback: returns the project to a saved version. Params: project_id, checkpoint_id, scope ("site": instant repoint of the live site, and the version must have been published before; "workspace": restore the project files for editing; "both" default). Any scope that touches the workspace SAVES THE CURRENT WORK FIRST, as its own version named Before rolling back to "<version>", so a rollback is itself undoable and shows up in history; if that save cannot be made, nothing is touched and the refusal says so. A rollback stands down while a publish, setup, import or preview build holds the workspace. One rollback runs at a time per project and the workspace itself is claimed while one is in flight, so two cannot interleave. A workspace restore also rebuilds the preview's permanent copy, so the preview link keeps showing the restored version after the project sleeps. The project's environment files are never rolled back: they are lifted out of the way and restored afterwards, so newer API keys and database settings survive, including tracked .env files in GitHub imports. If the workspace expired after long inactivity, the tool rebuilds it and restores the version as a background job. Rollback changes design and code only, never stored data (database rows, uploads or submissions): the code and data can now disagree, so the result warns to check collections, forms and database rules; collections and forms added after the saved version remain and keep counting against allowances, hidden forms can still receive entries, and newer database rules stay in force. A site rollback is reported as done only after the host confirms that EVERY production address is on the requested deployment. The host's answer, not the project row, decides whether the version was already live, so rolling back repairs stale control records instead of incorrectly doing nothing. If none of the addresses moves, the rollback fails without changing control rows; if only some move, it fails and names the stranded addresses because the site is genuinely split; retrying is safe and resumes the work. An address still connecting is not live yet and is reported as pending rather than failed. The rollback also restores and reads back the host's automatic production-domain assignment, even when no flip was needed; site_auto_assign_restored: false means the rollback landed but that standing publish behavior stayed off, and the result explains that the next publish will move the address. A release the host no longer retains, or refuses as a production target, says to publish that version again rather than pretending a retry will help. Text: what happened for each scope, the version saved first, the data-divergence warning, then one next step: share the site's address when the live site moved, otherwise publish to put that version there (a rebuild in flight closes on polling check_progress). Structured: project_id, checkpoint_id, scope, site_rolled_back, site_auto_assign_restored, workspace_restored, site_url (always the site's current own address, resolved at answer time; its presence is not a claim that the site is live), deployment_url (the restored host release's own URL when one was resolved), presave_checkpoint_id + presave_checkpoint_label (the undo version when the workspace was touched), plus preview_url when the restored preview verified, recovery_job_id while an expired workspace is rebuilding, and site_note: "never_published" when the site half was skipped.

Forms (create_form conditional on the deployment's public submit endpoint)

  • create_form: saves a form definition and returns the exact code snippet to drop into a page (a SiteForm import plus JSX element with the endpoint and site ID stamped in). Params: project_id, name, fields[] (1–100 of {name, label, type: text|email|textarea|select|checkbox, required?, placeholder?, options? (select, up to 500 choices, so a country, state or currency list fits with room to spare)}). Text: the snippet, the project's Data screen where submissions land, and the next step (put the snippet on a page, then publish), including the reminder to design a confirmation email when the form has an email field. Every submission emails the people who hold the site, and on a site owned by a team that is everyone on it who is an owner or an admin, one message each rather than one message with several recipients; a member is not written to. None of those copies counts against the site's email allowance. The notification is Iconic writing to the people who hold the site rather than the site sending mail, so it is never refused by an allowance and never spends one, and adding admins costs nothing. Only mail the app itself sends, through the site's own transactional or marketing rails, is metered. The notices still appear on the site's Emails screen and in list_email_sends, where they carry platform_mail: "form_submission_notice". Structured: form_id, project_id, name, fields, snippet, responses_url (that Data screen), plus app_url (the form's page in Data).
  • list_forms: the forms defined on a project, newest first. Params: project_id, limit (1–20, default 20), cursor (from a previous next_cursor). This is how you find a form_id on a project you did not just build, which is what save_email_design needs to wire a confirmation email and what list_form_responses takes to narrow to one form. A project holds at most 20 forms, so one call normally returns all of them. Structured: forms[] of {form_id, name, fields, created_at, response_count, response_capacity, at_capacity, confirmation_email_design_id?}, count, total, next_cursor?, plus truncated/total_fetched past the 90 KB structured-output ceiling, plus app_url (the project's Data screen). response_count is counted up to the form's ceiling. The text calls out any form with no confirmation email wired.
  • list_contacts: the people this project has actually met, most recent first. Params: project_id, limit (1-100, default 20), cursor (from a previous next_cursor). A contact is created by one of four things and nothing else: somebody fills in a form and gives an email address, somebody buys something, somebody replies to one of the site's emails, or somebody makes an account on the site. Nobody is inferred from analytics or enriched from anywhere. Seeing the same address again updates that one person (a last-seen stamp and a count) rather than adding a second row, and the FIRST way they arrived is the one kept. Contacts belong to ONE project and are never shared between projects, so the same address on two of an owner's sites is two separate people. These are real people's addresses: read them when the owner asks about their audience, and do not repeat them back otherwise. A site only holds so many contacts (the number comes from its plan: the free site plan keeps 100 contacts, Basic 1,000, Pro has no limit); at the limit NEW people stop being saved (a form submission, purchase, reply or signup from someone new is not recorded), while everyone already saved stays and returning people still update. Structured: contacts[] of {contact_id, email, name?, source, came_through?, first_seen_at, last_seen_at, times_seen}, count, next_cursor?, contacts_limit + contacts_used + at_capacity (present only when the plan's limit is finite; contacts_used is counted up to the limit), app_url (the Contacts screen, where the owner can export or delete anybody). When at_capacity is true the text carries that warning with the exact limit: relay it, because new leads are being dropped until the owner deletes people they no longer need or moves the site to a bigger plan.
  • list_form_responses: what visitors submitted, newest first, every entry complete. Params: project_id, form_id (optional narrow), limit (1–100, default 20), cursor (from a previous next_cursor). Text: readable submissions with values capped at 200 chars each, and a warning first when a form is full. Structured: responses[] of {response_id, form_id, form_name?, submitted_at, status, values}, count, next_cursor?, app_url (the submissions view), plus truncated: true and total_fetched when the 90 KB structured-output ceiling cut the page short, plus forms_at_capacity[] of {form_id, form_name?, stored, capacity} when any form has hit its ceiling. A full form stops saving new submissions and emails its owner (once a day per form), so relay that warning: the owner should read or export what is stored, or move to a plan with a bigger ceiling.

Payments (conditional: only on deployments where the payments rail is switched on; create_product also needs the public checkout endpoint)

A customer's site taking money from ITS visitors, over Stripe Connect. This is not the account's own Iconic plan, which is Billing below. The site owner gets their own Stripe account, the money from every sale lands in THEIR balance, and Iconic never holds it. Iconic's cut of each sale depends on the site's plan: 3% on Free, 2% on Basic, 1% on Pro, so a higher plan lowers it and every plan pays something. get_usage reports the exact percent for the site's tier. Stripe's own card fee always applies on top, and every result says both plainly. The site's buyers never see this cut. Card details are typed on Stripe's own page and never reach Iconic at all, so no card data is stored anywhere.

The flow is always the same four steps: setup_payments, then the owner finishes the Stripe form, then create_product for each thing the site sells, then put the buy button on a page and publish. A product can be one-time or a SUBSCRIPTION (pass recurring_interval to create_product): the same button then starts a recurring purchase, Stripe hosts the checkout, the renewals and the buyer's own manage/cancel page, and every charge (the first and each renewal) appears in list_payments. Iconic's cut rides every subscription charge at the site's plan rate, fixed at the rate the subscription was sold at: a plan change applies to new sales and new subscriptions, while the subscriptions already running keep the rate they started on.

  • setup_payments: creates the owner's Stripe account for this project and returns a secure Stripe link where they say who they are and which bank account to be paid into. Params: project_id, country (two-letter code, defaults to US, and it CANNOT be changed later, so pass it if you know it). Safe to call repeatedly: it never makes a second account, and because Stripe's links expire within minutes you should always call it again for a fresh one rather than reusing an old one. Text: where setup has got to, the link, the fee, and the next step. Structured: project_id, setup_url (absent once setup is finished), ready, details_submitted, payouts_enabled, still_needed[] (what Stripe is waiting for, in plain language), country, app_url (the Payments screen).
  • payments_status: whether a project can take payments yet, what Stripe is still waiting for, and how many things the site has to sell. Asks Stripe directly, so it is always current. Read it before telling somebody their shop is live. Params: project_id. Structured: project_id, started, ready, details_submitted, payouts_enabled, still_needed[], product_count, app_url.
  • create_product: creates something the site sells at a fixed price and returns the exact buy-button snippet to drop onto a page (a BuyButton import plus JSX element with the endpoint, site id and price stamped in). Params: project_id, name, amount_cents (whole numbers, at least 50 because card networks refuse less), currency (usd, eur, gbp, cad or aud; defaults to usd), description, and for a subscription recurring_interval (day, week, month or year) with optional recurring_interval_count (default 1, so month + 3 bills every 3 months; Stripe allows at most three years between charges). The price lives on the owner's own Stripe account, so nothing in a visitor's browser can change what something costs. You can add products while Stripe is still checking the owner's details; only the button itself waits. A recurring product's button says Subscribe, its checkout runs in Stripe's subscription mode, and after paying the buyer's return page receives a session_id it can trade at the public /payments/portal endpoint, for one day, for Stripe's hosted page where they manage or cancel the subscription; after that day the buyer manages it from the link on their Stripe receipt, so never store the session_id for a later "manage subscription" button. Text: the price (with its rhythm when recurring), who gets what, the snippet, and the next step. Structured: product_id, price_id, name, amount_cents, currency, recurring_interval?, recurring_interval_count?, snippet, app_url.
  • The billing portal (POST /payments/portal), a web route rather than a tool. Where a subscription's buyer manages or cancels what they bought, on Stripe's own hosted page. It lives beside the checkout endpoint on the same host, and both addresses are built from the deployment's own configured base address rather than written down anywhere, so take the origin from the endpoint already stamped into the site's buy-button snippet and swap /payments/checkout for /payments/portal; on the public deployment that reads https://mcp.withiconic.ai/payments/portal. It is not on the tool surface because the caller is the buyer's browser on the site's thank-you page and a visitor has nothing to sign in with. Send JSON with three fields: siteId (the project id the site carries as VITE_SITE_ID), sessionId (the session_id Stripe put on the return address after checkout), and returnUrl (an absolute https address to come back to, up to 2000 characters). A valid request answers 200 with {"url": "…"}, the one-time Stripe address to send the buyer to. The route is open to any origin and takes no credentials, exactly like the checkout endpoint, so the page can call it directly: a browser's preflight OPTIONS answers 204, and any other method that is not POST answers 405 with {"error": "method_not_allowed"}. A body that is empty, unreadable, missing any of the three fields, or carrying a returnUrl that is not an absolute https address within the length above answers 400 with {"error": "invalid_payload"}. An unknown site, a site whose owner never set payments up, an unknown session, a session belonging to a different site, a session that bought once rather than subscribing, and a session more than a day old all read the same 404 with {"error": "not_found"}, on purpose, so the route cannot be used to find out which sites or sessions exist. The one-day bound is deliberate: the session id proves a checkout that just happened, and the exchange exists for the thank-you page, not as a stored credential. Do not persist the session_id anywhere on the site; a buyer managing the subscription later uses the link on their Stripe receipt, and the seller can end it on request with cancel_subscription. One address may ask 15 times a minute, and asking faster answers 429 with {"error": "rate_limited"}. A Stripe call that fails answers 502 with {"error": "portal_failed"}, and a deployment with the payments rail switched off answers 503 with {"error": "unavailable"}. What proves the buyer is the checkout session id itself: it is unguessable, it is re-checked against Stripe's own record before any portal address is minted, and it is honored only within that one day of the checkout. Card details and cancellations are handled entirely at Stripe.
  • list_payments: the money a project has taken, newest first, subscription charges included (each paid invoice is one row; a failed renewal shows as failed until Stripe's retries land it). Each sale carries a status: paid, refunded (the money went back, whether the seller refunded it or the buyer's bank returned it), failed, disputed (the buyer's bank is challenging the charge and the money is withheld while that runs), or dispute_lost (the bank sided with the buyer and the money is gone). Params: project_id, limit (1-100, default 20), cursor (from a previous next_cursor). Structured: payments[] of {payment_id, product, amount_cents, currency, status, customer_email?, paid_at}, count, next_cursor?, total_paid_cents, app_url. total_paid_cents is THIS PAGE's total counting only paid sales, never a lifetime figure: never report a total from one page, and keep passing next_cursor back until none comes.
  • refund_payment: returns one sale's money to the buyer, in full, from the owner's balance. Iconic's fee on the sale is returned to the owner as well, so refunding costs the owner nothing beyond the sale itself; Stripe's own card fee is not returned (Stripe's rule). Params: project_id, payment_id (from list_payments). Only a paid sale can be refunded, once, and it cannot be undone, so confirm with the owner first. Partial refunds are not available: it is the whole sale or nothing. A disputed sale cannot be refunded (the money is already withheld while the dispute runs), and the refusal says so. The result's money sentence follows Stripe's own answer: when Stripe reports the refund as succeeded or pending, the money is on its way back and the sale reads refunded; anything else and the sale still reads paid, and the result says the money has not moved. Structured: payment_id, product, amount_cents, currency, refund_status, status, app_url.
  • list_payouts: where the project's money stands with Stripe: how much is ready to be paid out, how much from recent sales is still settling, and the payouts already sent to the owner's bank, newest first, with each one's status and arrival date. Read live from Stripe. Amounts in different currencies are reported separately and never added together. Params: project_id, limit (1-100, default 20), cursor. Structured: available[] and pending[] (each {amount_cents, currency} per currency), payouts[] of {payout_id, amount_cents, currency, status, arrival_date?, created_at?}, count, next_cursor?, app_url. Payouts are sent by Stripe automatically once money settles and the bank account from setup is verified; there is no payout button to press.
  • list_subscriptions: the subscriptions a project's visitors hold, newest first: who subscribed, to what, for how much and how often, and whether each is active, in trial, overdue, or ending. Params: project_id, limit (1-100, default 20), cursor. Structured: subscriptions[] of {subscription_id, product, amount_cents?, currency?, interval?, interval_count?, status, cancel_at_period_end, current_period_end?, customer_email?, started_at}, count, running_count (whole-site count of subscriptions still charging), recurring_summary? (whole-site totals per billing rhythm, e.g. "$45.00 every month plus $200.00 every year"; deliberately never one collapsed number), next_cursor?, app_url.
  • cancel_subscription: ends one of a project's subscriptions on the owner's behalf. By default it ends when the paid period runs out and the buyer keeps what they already paid for; pass immediately: true to end it right now (Stripe does not refund the remainder by itself, so only when the owner means it). The text says what the cancel means for the money: normally that nothing more is charged; when the subscription had an unpaid balance already billed (it was past due, or retries had run out and left it unpaid), that the balance still stands, because canceling does not void an invoice already issued. Params: project_id, subscription_id (from list_subscriptions), immediately. Structured: subscription_id, status, cancel_at_period_end, current_period_end?, unpaid_balance (present, true, only when the already-billed balance is still unpaid and can still be collected), app_url. The result states when the subscription actually ends and what happens to the money; relay it.
  • top_up_ai_balance: adds prepaid AI balance (the balance every model call runs on) by returning a secure checkout link. Params: amount_usd (whole dollars; presets $5, $10, $25, or any amount within the platform bounds), project_id (optional: the site this AI is for). ASK THE USER WHICH AMOUNT FIRST. The link is minted for that exact amount and the hosted page has no way to change it, so a wrong guess means starting over. WHOSE BALANCE: with a project_id the credit goes to the balance that site's AI draws on, which on a shared site is the site's and not the caller's; without one it is the caller's own balance. Whoever pays keeps their own card and receipt either way. Payment happens on the hosted page, never in chat; the balance updates within a minute or two of payment. Structured: checkout_url, amount_usd, and project_id + project_name when a site was named. Reach for it when get_usage shows the balance low or the Router refused a call for an empty balance.

Email designs and sending

  • save_email_design: saves a designed email (e.g. a form's confirmation email). Design it in the site's own visual style as a React Email component, render it, and pass both. Params: project_id, name, subject, html (all required), text (plain-text fallback), react_email_source (TSX source, kept for future edits), kind (transactional default, or campaign-member), purpose, preview_text, form_id (wire it as that form's automatic confirmation email, so every submission with an email answer then sends it, with {{field_name}} placeholders filled from the visitor's answers), email_design_id (update an existing design). purpose is the legal classification and it decides what the platform adds at send time. transactional means the email completes or confirms something the person already did (a receipt, an order confirmation, a password reset, a reply to their inquiry); marketing means its main purpose is to promote something (a newsletter, an offer, an announcement). Ask the law's own question: what is the PRIMARY purpose of this message? A receipt with a small offer at the bottom is still a receipt; an offer dressed as a receipt is an offer. Marketing email must by law carry an unsubscribe link and the sender's real postal address, and the platform adds both automatically. When the site has no address saved the send still GOES, and comes back carrying a warning that says US law requires one and that sending without it is the sender's call and the sender's risk (fix it with set_site_mailing_address). Relay that warning rather than dropping it: the sender is the one exposed, and it is the only notice they get. When genuinely torn, choose marketing for a standalone email: that mistake only adds a footer, the other one sends promotional mail with no opt-out at all. The exception is a design you are wiring to a form with form_id: a confirmation answers something the visitor just did, so it is transactional, and a marketing design is REFUSED at the link because a confirmation cannot carry the unsubscribe link marketing needs. Choosing marketing there would otherwise brick every confirmation that form ever sends. Defaults to marketing for a campaign-member and transactional otherwise. preview_text is the preheader, the gray snippet an inbox shows next to the subject; without one the inbox grabs whatever text comes first, which is usually useless. Write a short second line that earns the open rather than repeating the subject; 40 to 90 characters lands well on phones. Merge tags: a form's own fields are bare ({{field_name}}), and the qualified {{form_name.field_name}} spelling is also accepted. Names beginning iconic. are RESERVED for the platform: {{iconic.unsubscribe_url}} and {{iconic.postal_address}} let you place the opt-out link and the postal address exactly where you want them inside your own design, and when you use the unsubscribe tag the automatic footer stands down so there is only ever one opt-out link. Defining a form field named iconic is refused. A bare tag that matches nothing is left in the email verbatim rather than silently emptied, so a typo is visible. Every email leaves from Iconic's own sending address and carries a reply-to, so a visitor who replies reaches a real person rather than an unattended mailbox: the site owner on a confirmation, and the VISITOR themselves on the submission notification the owner receives. Text: confirmation, wiring status, the project's Emails screen where the design now sits, and the next step (send it to the owner with send_test_email before any visitor receives it). Structured: email_design_id, project_id, name, kind, purpose, subject, preview_text (when set), has_source, has_text, created_at, updated_at, embed_url + app_url (the Emails screen, opened on the design you just saved), plus linked_form_id when linked. A marketing design that has already gone out to somebody cannot be edited, and the refusal says so: people have that exact message in their inboxes, and changing the record would stop it matching what they received. Copy it with duplicate_email_design and edit the copy, which is what the refusal tells you to do and names the ids for. A test send to the owner never locks anything, and a transactional design is never locked at all.
  • list_email_designs: the project's designs, newest first; bodies withheld. Params: project_id. Structured: designs[] (same summary shape as above, purpose included), count, embed_url + app_url (the Emails gallery). Every design reports a purpose, including ones saved before the field existed, which resolve to the same value the send rail would apply (marketing for a campaign-member, transactional otherwise). The owner's Emails screen shows that value as a badge on each email, so a design whose purpose is wrong is visibly wrong to them: check it before assuming a promotional email is set up correctly.
  • get_email_design: one design in full, including the React Email source it was written as. Read it before editing: the source is what you change and re-render, so editing without it means rewriting the email from memory. Params: project_id, email_design_id, parts (any of react_email_source, html, text; default all three). Structured: the summary shape (purpose included), plus the requested bodies, sizes of {react_email_source, html, text} in bytes (reported whether or not the body was returned), parts_returned, plus embed_url + app_url. The written answer names the purpose alongside the kind, because the two spell one value the same way and only one of them decides what a send must carry. A design saved without a source returns react_email_source: null and says so.
  • unlink_email_design: stops a form sending a design as its confirmation email. The design is kept and the form keeps collecting submissions: only the automatic reply stops. Params: project_id, email_design_id, form_id. Refuses when that form is sending a DIFFERENT design, so an unlink can never cancel the wrong reply. Text: what stopped, the project's Emails screen where the design is still kept, and the next step (wire a different design if the user wants replies to start again). Re-wire at any time with save_email_design (form_id).
  • duplicate_email_design: copies a saved design into a new one under the same project, keeping the subject, preview text, rendered HTML, component source, kind and purpose. Params: project_id, email_design_id (both required), name (optional; defaults to the original's name with "copy" on the end). The copy starts with no sends against it, so it is fully editable however locked its original is: this is the documented way through the marketing lock, not a workaround. Also the quickest way to start a new email from one that already works. The original is untouched. Text: what was copied into what, the project's Emails screen, and the next step (edit the copy with save_email_design passing the new email_design_id). Structured: email_design_id (the NEW one), copied_from_email_design_id, project_id, name, kind, purpose, subject, has_source, has_text, created_at, updated_at, plus app_url.
  • list_email_sends: outbound mail, newest first: form confirmation emails to visitors and test emails to the owner alike. Unnarrowed it is the account's, covering the sites the account still holds: a site transferred to another team takes its email history with it (the receiving team reads it by narrowing to the site), while a deleted site's sends stay in the trail. Narrowed with project_id it is that SITE's whole history, keyed on the site rather than on whoever owned it at the time, so a site that has been moved to another team keeps everything it ever sent. Params: project_id (optional narrow), limit (1–100, default 20), cursor (from a previous next_cursor). Structured: sends[] of {send_id, project_id, to_domain, subject, status, failure?, sent_at, reply_count?, latest_reply_at?, email_design_id?, platform_mail?, is_test, opens?, opens_measured?, clicks?, clicks_measured?} (the reply pair is present only on emails somebody answered, and absent entirely on a deployment that cannot receive replies, so nothing ever reports a zero it cannot stand behind) (status is one of sending, sent, failed. A send the mail service refuses, or that cannot be reached at all, is marked failed on the spot and failure says which: HTTP <status> for a refusal, timed out for a send nothing answered, transport error for one that never got there. Those are classes rather than the underlying error text, deliberately, so nothing relays a host name or a request address into a chat. Only a send abandoned partway, by a job that stopped running, waits 15 minutes to be marked failed rather than sitting in sending for good), count, next_cursor?, open_count_caveat?, email_allowance of {month, plan, emails_sent, emails_included, emails_sent_is_at_least?, emails_per_day}, plus embed_url + app_url (the Emails screen) when narrowed to one project. Recipients are domains only on this surface, the part after the @. That narrowing is deliberate rather than a limit of what is stored: the full address is kept on the send and is shown in the owner's own workspace, and it is narrowed here so an agent transcript never carries somebody else's address. An answer that IS an address is narrowed the same way. Opens and clicks are reported ONLY where they were actually counted. A send carries opens (0 or 1, because each person counts once) and clicks when counting applied to it; otherwise it carries opens_measured: false or clicks_measured: false, which means nobody counted and is NOT zero opens. Never turn "not measured" into a zero when you relay it: an email that could not be counted is not an email nobody read. That includes an email that asked to be counted while the counting rail has reported nothing. Counting has two halves, the request made at send time and the reports coming back, and only the second is evidence: when nothing has ever come back, every send reads opens_measured: false however its own settings were configured, because "nobody opened it" and "nobody is telling us" are different facts. open_count_caveat appears whenever an open number does, and belongs beside the number when you relay it. is_test marks the emails that went to the account owner from send_test_email; they are excluded from every engagement figure. platform_mail appears on mail one of Iconic's own rails sent rather than the app: Iconic's own transactional mail (team_invite, allowance_notice, form_capacity_notice, access_request) and the notification a form submission raises (form_submission_notice). It has no saved email behind it and never will. The first group is filed under Iconic's own project, so it is somebody else's site's mail only if you are looking at that project; the submission notice is filed under the site it is about, which is where its owner reads it. None of it counts against any email allowance and none of it is ever refused by one. Relay it as what it is rather than as an email nobody designed. status: "sent" means the mail service accepted it, which is not the same as an inbox receiving it. Read this when a send refuses: the allowance block says whether the month's allowance is spent. The history is not forever: a send and its engagement are kept for 24 months and then deleted, because the people in it are the site's recipients rather than the account holder, and they never agreed to be held indefinitely. Deleting a site, or a contact, erases the mail belonging to them straight away.
  • get_email_send: one email, reproduced. Params: send_id (required, from list_email_sends), project_id (only when the email belongs to a team site somebody else owns). Without project_id the read is account-keyed and covers the sites the account still holds: a send belonging to a site since transferred to another team answers as an unknown id (read it through the site with project_id if you hold a seat there), while a deleted site's sends still open. Text: who it went to, whether it was opened and when, what was clicked, the answers it was filled in with, and whether what you are looking at is still what was received. Structured: send_id, project_id, to_domain, subject, status, failure?, sent_at, is_test, purpose?, email_design_id?, opened? + opened_at? + open_client? + open_platform? + open_country? + open_automated? (or opens_measured: false), clicks?[] of {url, clicked_at} (or clicks_measured: false), merge_values?, merge_values_truncated?, merge_values_narrowed?, reproduction of {exact, reason}, open_count_caveat?, app_url. reproduction.exact is true only when the design is byte for byte what it was when this went out. When it is false the reason says the design has been edited since, so what is shown is the current design with that person's answers rather than the message they received: relay that rather than presenting it as the original. On Iconic's own transactional mail the reason names the message instead (a team invitation, written by the platform), because there is no design behind it to have changed. Location is narrowed to the country ON THIS SURFACE by choice; the region and city the mail service reported are kept on the event and are shown in the owner's own workspace. No address, IP, postcode or coordinates are kept anywhere. opens_measured: false also covers the case where counting was asked for and the counting rail has reported nothing since the email went out: report that as not measured, never as "not opened". An answer that is itself an email address comes back as its domain, and merge_values_narrowed names which; the full values are on the site's Emails screen.
  • get_email_engagement: how one email design performed, across every real send of it. Params: project_id, email_design_id (both required). Test emails to the owner and sends that never left are excluded, and the result says how many of each it set aside. Structured: project_id, email_design_id, sends, test_sends, failed_sends, tracked_for_opens, untracked_for_opens, tracked_for_clicks, untracked_for_clicks, opens? + automated_opens? (or opens_measured: false), open_rate, clicked? + clicks? + links[] of {url, clicks} (or clicks_measured: false), click_rate, measurement of {rail_proven_opens_at?, rail_proven_clicks_at?}, open_count_caveat?, plus app_url. open_rate and click_rate are null, never 0, when nothing was counted, and the denominator is the sends that were actually counted rather than every send: dividing by sends that could never report an open would manufacture a bad number out of a settings choice. automated_opens counts only the opens with documented evidence of being a machine fetching the image (a named image proxy), so it is a floor too. links[] is complete and never capped; the text spells out the first ten and says where the rest are. measurement says whether the counting rail has ever reported anything to the platform at all: each field carries when the newest open or click it accepted was reported, and a field is ABSENT when it has never accepted one of that kind. With a field absent, every send here is unmeasured whatever its own settings asked for, tracked_for_opens is 0, the rate is null, and the text says in words that the rail has reported nothing. Relay that as "nothing is being counted", never as no opens and never as a rate of zero, and say that whoever runs the deployment needs to check the mail service's webhook.
  • set_email_tracking: turns open and click counting on or off for the email a site sends. Params: project_id, enabled (both required). Turning it on means the platform measures the PEOPLE THIS SITE EMAILS, not the owner: a small image is added to each message so an open is recorded when their mail app loads it, and every link is rewritten through the mail service so a click is recorded when they follow one. Those are somebody else's recipients, so say what it means before switching it on rather than turning it on for them. Turning it off stops both for everything sent afterwards; the figures already recorded stay readable, and the emails sent while it was off keep reading as not measured rather than becoming emails nobody opened. Counting is ON unless somebody has turned it off. Text: what changed, the site's Emails screen where the same switch lives, and a next step. Structured: project_id, email_tracking, app_url. Read the figures themselves with get_email_engagement.
  • send_test_email: sends to the signed-in user's own address only (there is no recipient parameter, of any name). Two shapes, never both at once: a typed message (subject ≤200 chars + message ≤10,000 chars, plain text) or a saved design (project_id + email_design_id), which sends that design's own rendered HTML with its {{field_name}} placeholders filled with obvious stand-in values (an email-typed field stands in with the owner's own address). The design shape is only offered where the design rail exists. project_id is accepted on BOTH shapes, and on a typed message it files the send under that project: it then appears on that project's Emails screen and in list_email_sends narrowed to it. Filing is not the allowance: the send spends the signed-in account's own email allowance either way and is never moved onto the named project's. You need read access to the project, and one you cannot read is refused exactly like one that does not exist, with nothing sent. Structured: send_id, to, subject, is_test (always true), tracked (whether opens and clicks will be counted for it), plus project_id and app_url (the Emails screen it is filed on) whenever a project was named, and email_design_id and placeholders_filled[] on a design send. placeholders_filled lists only the placeholders a real visitor's answers fill: the platform's own reserved iconic. tags are filled by the platform and are never listed there. A test follows the SITE's own counting setting AND whether the deployment can receive counts at all (a deployment with no counting rail wired asks the mail service to count nothing, so no counting image is inserted and no link is rewritten), and the result says which; it is stamped as a test, so it stays out of every engagement figure and never locks a marketing design. A typed message that names no project is filed nowhere and carries no screen. A typed message is never counted for opens or clicks whatever the site's setting says: it is plain text, an open is counted by an image inside an HTML body, and this shape never asks for click counting. The design send goes through the SAME compliance pass a real send does, so the test shows the preview text as the hidden preheader and, for a marketing design, the unsubscribe footer and the site's postal address exactly as a recipient would see them. A marketing design on a site with no postal address SENDS here too and returns the same warning, in the result text and as compliance_warnings[] of {code, message} in the structured payload, which is the cheapest possible moment to find that out. Relay it. This is how you put a confirmation email in front of the owner before any visitor receives one. Counts against the account's daily and monthly email allowances.

Analytics

  • Where counting works, and when a zero is not a zero. Every site Iconic writes carries the visit counter from its first page, and it counts the same on the included <slug>.oniconic.app address and on a connected custom domain. Connecting a domain of the user's own changes nothing about what is counted, and no republish is needed for that to be true. The preview does not count, on purpose: somebody clicking around a preview is the person building the site, not a visitor, so never tell a user to open their preview to make a number appear. This holds for the preview under either of its addresses, the branded <slug>.oniconic.dev and the raw workspace host behind it. The one site shape with no counter is a project imported from an existing repository (import_github_project): the import brings the owner's own pages, and none of them ask to be counted. So an empty answer has two meanings, and all three analytics tools say which one it is: measured: true with zeroes means nobody visited, and measured: false (with not_measured_reason: "imported") means nothing is being counted at all, so the zeroes say nothing about traffic. measured: false is a durable claim, never a guess from a quiet window: it is only ever returned for an imported project that has counted nothing since the day it was created, read past whatever retention window the site's plan puts on the report itself, so an imported site whose owner wired counting by hand reads as measured for good. Never relay an empty report as "your site had no visitors" without reading measured first. Adding counting to an imported site is ordinary work: the project's .env.local already holds VITE_SITE_ID (the site's id) and VITE_STATS_URL (where a count is sent).
  • get_analytics: the site's visitor stats. Params: project_id, period ("7d" default, "30d", "90d", or "all" for every day the site has recorded), include (extra sections; today only ["journeys"]). Text: a readable summary of views, visitors, average time on site, by-day counts, and the top five pages, referrers and tracked actions, plus, when relevant, a retention note (the free site plan reads the last 7 days; a longer request is trimmed to the plan's window), a gentle over-cap note (events above the plan's monthly allowance are still recorded, and the note states whether the amount above the allowance is currently charged), and a grouping note when a day had more distinct pages, referrers, events or journey steps than the site tracks separately. Structured: project_id, period_days (the days actually served), from_day, to_day, totals {views, visitors, avg_seconds_on_site}, days[] of {date, views, visitors} (oldest first, chart-ready), top_pages[] of {path, views}, top_referrers[] of {source, views}, top_events[] of {name, count, is_conversion}, conversions_by_importance[] of {name, count, ranked, label?} (the site's goals in the OWNER's importance order, most important first, with anything they promoted or demoted already applied; the first one is the site's main goal and is the figure its card shows in the workspace, ranked is true only for goals the owner has explicitly placed, the rest following in most-taken order, which is NOT an importance claim, and label is the goal's plain-language wording where one has been set with rank_conversions. The owner's screens print the label, and a goal without one falls back to a mechanical rewording of its raw name, so keep labels set for the goals you define), measured (false when the site's pages carry no visit counter, with not_measured_reason), retention_days (null = unlimited), month_usage {month, events, billable_events, included_per_month, overage_events, overage_bills_now} on billing-aware deployments (billable_events is the conservative subset that counts against the allowance, overage_events measures against it, and overage_bills_now says whether the amount above the allowance is currently charged), plus app_url (the project's Analytics screen). The three top lists carry the FULL series (the text is only a top-five summary), bounded by a 90 KB output ceiling that sets truncated: true and total_rows when it trips. grouped_as_other reports, per list, whether some entries were folded into (other) that day, alongside tracked_per_day (how many distinct pages, referrers, events and journey steps a day tracks separately). On long periods from_day is the first day with any visits, not the requested start, so an all-time report begins when the site did. Visitor counts are privacy-friendly estimates. With include: ["journeys"] the result also carries journeys[] of {from, from_step, to, to_kind, count} (a null from means the visit arrived from outside the site, from_step 0 is the visit's first page, and to_kind is either page or conversion). Those are the steps visitors took between pages, as counts and never as one person's path, and the text summary gains a short journey section. Journeys are opt-in because they are the biggest section of the report (an entry per page pair), and they share the same 90 KB ceiling and truncated/total_rows honesty as the other lists. They are also advanced analytics, Pro site plan only, enforced at the serving layer: on any other plan the report still answers in full without them and carries journeys_withheld instead ("site_plan" = the plan does not include them, upgrade_site_plan to Pro lifts it; "plan_unread" = the plan could not be read just now, so they were withheld rather than guessed), with a note in the text saying the same thing.
  • rank_conversions: says which of the site's goals matters most, which tracked actions count as goals at all, and what each goal is CALLED on the owner's screens. Params: project_id, order (the conversion names, most important first), goals (an action's name set to true to count it as a goal, false to stop it counting, or null to put it back to the site's own markup), labels (plain-language wording per action name, the words the owner's screens print instead of the raw name, so a goal tracked as user_booked reads "Booked a visit"; short, sentence case, written for the owner; null clears one back to the mechanical fallback). Any of the three may be used on its own. ALWAYS supply a label for every goal you define or rank: an unlabeled goal falls back to a mechanical rewording of its raw name (underscores become spaces, the first letter rises, all-caps tokens are kept as written), which is never as good as wording you chose. What someone sets here beats the site's own markup, and an action nobody has set is left to the markup, so clearing a name (null) is the only way back. Only the names you pass in goals change; the rest are left alone. The FIRST name becomes the site's main goal, which is the figure its card shows in the workspace, so this is what makes a card say "Book a call" instead of whichever goal simply gets clicked most. Read the current order from get_analytics (conversions_by_importance) first and pass names exactly as they appear there. A partial list is fine: names you leave out keep working and sort after the ones you name. A name the site does not report yet is accepted, so you can rank a goal you are about to build. Text: what each goals change did, then which goal is now the main one and its recent count, then the rest in order. Structured: project_id, main_goal (null when the site reports no conversions yet), conversions_by_importance[] (each entry carrying its label where one is set), goals_set_here (the actions set by hand, name to true or false), labels_set_here (every action carrying a label, name to label), plus app_url (the site's Analytics screen). When the site reports no conversions the result also carries measured: false means its pages carry no visit counter, so no goal can ever be reported and the empty ranking is a wiring fact rather than an unused-goals one. Ask the person which goal matters rather than guessing: most-clicked is rarely most important.
  • get_live_visitors: who is on the site at this moment, from the short-lived presence rail (a separate store from the day rollups above; rows expire about 90 seconds after a page last checked in). Params: project_id. Text: how many people are here, the pages they are reading, and a line per person. Structured: project_id, at, total, pages[] of {path, count}, sessions[] of {page, country, country_name, browser, os, started_at} (up to 50 people; total still counts everyone, and truncated: true says the list stopped), sessions_listed, live_window_seconds, measured (false when the site's pages carry no visit counter, with not_measured_reason, in which case a total of zero says nothing about who is on the site), plus app_url (the Analytics screen). Nothing identifies a person: no name, no address, no location finer than the country, and the per-tab key the rail uses internally is never returned. This tool answers only for a site on the Pro plan. Live visitors are advanced analytics, enforced at the serving layer: on a Free or Basic site the tool refuses with a message that names what lifts it, and upgrade_site_plan is what puts a site on Pro. The refusal is worded for your host. Where the Billing tools below are visible to you, it names upgrade_site_plan and carries the upgrade page deep-linked to that very site. Where they are hidden, it states the plan the feature belongs to and carries one link to the plans page (/plans), which describes what each plan includes and sells nothing; there, relay it as written and do not offer to start a purchase. A plan that cannot be read refuses with its own distinct message rather than guessing. get_analytics still answers on any plan, so relay the refusal honestly and offer the day report instead.
  • get_activity: what has HAPPENED to the site, as opposed to who visited it. Params: project_id, limit (1 to 200, default 50), cursor (the previous answer's cursor, to read further back). Answers with the site's own story, newest first: it going live and at which address, publishes that failed and why, versions saved, pages and posts published, forms filled in, and emails sent or not delivered. Nothing here is captured a second time, it is read from the records the platform already keeps, so it can never disagree with the Versions, Emails or Forms screens. Text: a dated line per entry (the twelve newest are read aloud; the rest are in the payload), then, when they apply, the line to page further back, a line naming any part of the story that could not be read, and the workspace link. Structured: project_id, events[] of {event_id, at, kind, title, detail, failed, facts[]}, truncated, total_fetched, cursor, partial[], incomplete[], plus app_url (the project's Analytics screen, where this list is drawn). kind is one of published, publish_failed, build_failed, version_saved, rolled_back, rollback_failed, setup_finished, setup_failed, imported, import_failed, form_submissions, email_sent, email_failed, content_published. Read failed rather than the kind's wording, and read facts[] as label/value pairs the platform actually verified: a fact it could not resolve is left OUT rather than guessed at, so a missing one means unknown and never zero or false (a failed publish carries a Live now fact only when a live version genuinely resolved). A day's submissions to one form arrive as ONE entry carrying the count ("Contact form, 3 new submissions"), because a busy form would otherwise bury everything else; every individual submission is still in list_form_responses. event_id is stable across reads, so it is safe to key on. This is not plan-gated and not trimmed by retention: the 7-day free window above is a VISITOR-STATISTICS gate, and every plan reads its whole site history here, as far back as the records go. partial[] names parts of the story that could not be read this time (publishes, builds, versions, submissions, emails, content) and incomplete[] names parts that answered with their newest entries rather than their whole history; either one present means entries are missing, so relay that rather than reporting a short list as a quiet week.
  • Site speed, and why no tool reports it. A project created from the current Iconic template also reports Core Web Vitals from real visits: largest contentful paint (LCP), cumulative layout shift (CLS), and interaction to next paint (INP), each measured in the visitor's own browser and sent once per page when the page is hidden. The measured numbers do leave the page. The SERVER is what classifies each one against Google's published thresholds as good, needs improvement, or poor, and only those bucket counts are written to the site's day row, so no timing value is ever stored and nothing in the rail can be tied back to a person. The counts surface on the Performance card of the site's Analytics screen, as the share of measurements in the good range plus a rating per metric. No MCP tool returns them today: get_analytics reports visits, and its include accepts ["journeys"] and nothing else, so site speed is read on the screen rather than through a tool. A site whose pages were written before this rail shipped, and a project imported from an existing repository, report nothing here until their code sends the report, and their Performance card stays in its measuring state rather than claiming the site is slow. Adding the report to one of those is ordinary work: send one more beacon to the VITE_STATS_URL the project's own tracker (lib/visits.ts) already uses, kind: "vitals" alongside the site id and the path, carrying whichever of lcp (ms), cls and inp (ms) the browser measured. Add that to the tracker the project already has rather than replacing its file, since a project's copy may have moved on from the template's, and nothing needs switching on first: the collector accepts a vitals beacon from any site.
  • get_usage: what has been used, and what the plan allows. THE one place limits are reported. Params: project_id (optional: leave it out for the whole account, which is the level every allowance is measured at anyway, so a brand-new account with no projects can still ask what its plan includes), days (1–366, default 30). Name a project and every plan figure in the answer is the plan of the TEAM holding that site, not the caller's own, so a member of a paid team reads the paid team's numbers. The USED figures come from the same team: usage belongs to the workspace that holds the site it happened on, so two people working in one team read one shared set of numbers rather than each other's halves. Usage recorded before this became true still reads as the person's own workspace, which is what it always meant, so nothing changed for anybody at the moment it shipped. Text: the per-kind totals in plain words, the account's project ceiling and whether it may publish to a domain the user owns, the allowance line with what has been used against it, and the AI balance. Structured: scope ("project" or "account"), project_id (only when one was named), from_day, to_day, total_actions, by_kind[] of {kind, label, count}, days[] of {date, counts, total} (gap-filled, oldest first, chart-ready), workspace_plan + workspace_plan_label + plan_limits of {projects (null = no limit; counts archived projects too), custom_domains}, the per-site plan/plan_label + allowances of {emails_included_per_month, email_sends_per_day, seo_lookups_included_per_month, analytics_events_included_per_month, analytics_retention_days, content_entries_per_collection, form_responses_per_form} + this_month of {month, emails_sent, emails_included, emails_sent_is_at_least?, seo_lookups, seo_lookups_included} (pooled across the whole WORKSPACE, and each half present only where its store is wired; seo_lookups is the workspace total, while emails_sent is still counted per account and so can read lower than the workspace's real total on a shared workspace), plus ai_balance_usd and ai_balance_cents (the prepaid balance model calls run on, when the deployment has that rail), or ai_balance_unavailable: true when the balance could not be read, which is never reported as a balance of zero, plus app_url (the Analytics screen's Usage section) on the project-scoped answer, which is the only one with a screen to show; the account-wide answer ends on the next step instead, which is to name a project. On a deployment with no plans at all the numbers are simply absent rather than guessed. Counts are of ACTIONS, one per thing done, so they compare with each other and with the plan's numbers; quantities in mixed units are never summed. Asking for more than 366 days is refused rather than quietly shortened.

Site backups (conditional: only on deployments that keep a backup record)

  • list_site_backups: what has actually been kept of a site's data, newest first, and what the site's plan keeps. Params: project_id. A backup holds the site's own database, its data on Iconic (form responses, content, email designs, stats and addresses) and a record of every uploaded file: its address and fingerprint rather than a second copy of the file, because uploads already live in durable storage. The site's code is not copied because every saved version is already kept forever. Text: the plain-language summary first (honest on every plan), then the "what a backup holds" line, then a line per backup with its date, whether it is a monthly or a daily one, how many rows and files it holds and how big it is, and finally the restore policy. Structured: project_id, plan, enabled, summary, ladders[] of {ladder: "monthly" | "daily", retained, held} (a kind the plan does not take has retained: 0), backups[] of {id, date, ladder, plan, partial, missing[], size_bytes, counts {database_rows, platform_rows, files, file_bytes}}, count, and restore. A site on the free plan is not backed up, and the answer says so plainly with enabled: false. Read that as the real answer, not as a failure or an empty list. An incomplete backup is never hidden: partial: true comes with a missing list naming what that run could not capture, and both appear in the text. Relay them, because a backup the owner wrongly trusts is worse than no backup.
  • discard_backup: throws away ONE backup of a site's data, permanently. Params: project_id, backup_id (from list_site_backups). Read the list first and tell the person what that backup holds before discarding it, and only ever call this when they have asked for that specific backup to go: it cannot be undone and there is no restore tool. It removes a stored copy and nothing else. The site, its live data and every saved version of its code are untouched, and the site's plan keeps taking new backups on the usual schedule. Text: what went, what did not, and a link to the site's files where the remaining backups live. Structured: project_id, backup_id, discarded. Downloading a backup is not a tool: a run is a multi-megabyte archive, so the result points at the backups folder in the site's files, where the owner can save a copy themselves.
  • Putting a backup back is not self serve, from any tool, ever. Restoring writes over the site's live data, so it is run by Iconic support together with the owner against a named backup. Every result closes with that line: relay it, and never offer to do the restore yourself.

Stored files (permanent addresses)

The project workspace is temporary: it sleeps and can expire. A file stored here outlives it and is served from a permanent address, so link a site's logos, photos, and documents from storage, never from the workspace, and never save images into the database.

  • upload_file: stores a file and returns its permanent address. Params: project_id, path (destination, e.g. "photos/team.jpg"; storing to an existing path replaces it), and exactly one of source_path (a file in the workspace), url (an https address to copy in), or content_base64 (the file's raw bytes, encoded, for bytes you are holding right now), plus content_type and visibility ("public" default, which is what site assets need; "members" serves the file only to the site's signed-in visitors, rendered through a membersFileUrl helper as described under the storage bullets below; a "private" file has NO public address at all and is never served to any visitor, signed in or not, so it is for things only the owner should retrieve, such as documents submitted through forms). Text: confirmation with the permanent address and the next step, using that address in the site's code. On a members file it points at the server-side render path instead; on a private file it says there is no address for the site to link to, and to store it again as public or members if the site has to show it. Structured: path, url (null when members or private), size_bytes, content_type, visibility, uploaded_at. content_base64 is the one call for a file you already have in hand: a picture you just generated, an icon, a small font, a file you read off the machine you are running on. One call stores it, so never write an encoded file into the project and decode it there, and never put a local file on the public internet just to get it here. Whitespace, missing padding and the URL-safe alphabet are all accepted; anything that is not valid base64 is refused outright rather than decoded as far as it goes. Files up to 256 KB come this way, and a bigger one is refused with its real size and that limit, nothing stored, because those bytes travel through your own context: for anything larger, and for anything on the person's own computer at all, upload_local_file below moves the file without carrying it. content_type (e.g. "image/png") applies to content_base64 only, and passing it with source_path or url is refused, because a file in the workspace or on the web already says what it is. Give it for a format that cannot be recognized from its bytes, such as an SVG, a font, or a CSV; common picture formats are recognized from the bytes themselves and that recognition wins over anything declared. Left out and unrecognizable, the type comes from the path's extension, and when even that says nothing the file is stored as application/octet-stream and the result says plainly that a browser will download it rather than show it. The content_type in the structured result is always what was really stored.
  • upload_local_file: the rail for a file on the person's OWN computer, and the one to reach for whenever the file is large, for when someone points at a picture, video, or document on their machine and says to use it. It moves no bytes and stores nothing: it hands back a short-lived permission to write that one file, plus the command that spends it. Run that command in your own shell (the agent is usually on the same machine as the file) and the bytes go straight to storage, so nothing passes through the conversation, at any size. Choosing between this and upload_file's content_base64: encoded bytes in an argument cost you roughly 3,000 tokens per kilobyte of file, each way, so use content_base64 for the small things (an icon, a logo, a short font, a chart you just made) and this for everything else. The one case that decides itself: this tool hands back a command somebody has to RUN, so if you cannot run a shell command, content_base64 is your only path and its 256 KB ceiling is a real wall rather than a preference. Never open a tunnel or a temporary public address to move a private file. Params: project_id, path (destination, same rules as upload_file), size_bytes (required, the file's real size, which you can read off the local file), local_path (optional but the shape to reach for: where the file sits on the person's machine, e.g. "/Users/sam/Desktop/Screenshot 2026-08-13 at 10.41.05 AM.png"; give it and the command comes back ready to run with the path quoted for you, so a name with spaces or an apostrophe in it needs nothing from you, and nothing on the platform opens that file, the path is used only to write the command), content_type (optional but give it when you know it: nothing on the platform ever sees these bytes, so it cannot be worked out later; left out, it is guessed from the path's extension; anything that is not two parts separated by a slash is refused, and any parameters after a ; are dropped because storage keeps only the type itself), and visibility. Text: the command to run, how long the permission lasts, and the address the file will answer on once it lands. Structured: upload_command, upload_url, grant, method, headers, max_bytes, expires_at, content_type, content_type_inferred, visibility, url (absent when private), and stored: false, which stays false because the file is not stored until you run the command. The permission covers ONE path in ONE project, up to max_bytes, and expires 15 minutes after minting; if it lapses, call again for a fresh one. The permission is not spent by using it: until it expires it writes that one path as many times as it is presented, so a transfer that fails partway can simply be run again. Given local_path, the command is ready as it stands and must not be edited. Left out, it uses LOCAL_FILE_PATH as a placeholder: replace it with the file's real path, keeping the quotes around it, so a name with spaces (a desktop screenshot, say) works as it is, and write a literal ' inside the path as '\'', which is the reason to pass local_path instead, since a name holding an apostrophe substituted in raw turns the rest of the name into commands your shell runs. Run the command as written otherwise: the permission travels to curl through the heredoc at the end rather than as an argument, which keeps it out of the machine's process list.
  • list_files: stored files, newest first, with storage-allowance usage. Params: project_id, prefix, limit (1–200, default 50), cursor (from a previous next_cursor). Structured: files[] (same shape as upload), total, next_cursor?, used_bytes, limit_bytes.
  • delete_file: removes a stored file for good; its address stops working immediately. Params: project_id, path. Text only: what was removed, plus the next step: grep the project for that address and replace anything still pointing at it.
  • set_file_visibility: moves one stored file between public, members, and private without re-uploading it. Params: project_id, path, visibility, and confirm_breaks_links (required as true only when taking a file OUT of public, because its permanent address stops working the moment the change lands; called without it, the tool changes nothing and reports exactly which address would die, so grep the project for that address first). Moving INTO public, or between members and private, breaks no existing link and needs no flag. Structured: path, url (null when not public), visibility, previous_visibility, changed, and confirmation_required: true on the warning shape. Files already public STAY public until this tool is called on them; nothing migrates on its own.

Members files, and how a page shows one. A site behind its own sign-in stores gated images and downloads with visibility: "members" instead of making them public. The site's SERVER renders each one with a membersFileUrl(path) helper in lib/storage.ts, AFTER checking the viewer against the site's own session and rules; the helper trades the project's server credential for a read permission covering the project's members files, and the address it returns goes into a plain img tag. Newly scaffolded projects include the helper; a project scaffolded before members files existed does not have it, so add it to that project's lib/storage.ts before rendering. One permission covers every members file on the page and stays stable across page views, so the viewer's browser caches members images like ordinary ones. Each permission lasts up to two hours and cannot be revoked early: a visitor who signs out or loses membership can keep opening members files until it expires, and their browser may hold the bytes for as long as the permission had left. The platform never learns who the viewer is; whether somebody is a member is entirely the site's own decision, made in its own code. Members never reaches private: a members permission cannot open a private file, so a bug in a gallery page can never hand out a form submission. The honest limitation: membersFileUrl runs in server code, so every platform template can render members files, but an imported static repo with no server cannot, and there the helper returns null; render a real signed-out or unavailable state for null, never a broken image.

Stock media (conditional: only when a provider key is configured)

  • search_media: searches Pexels for photos or videos. Params: query, type (photo/video), orientation (landscape/portrait/square), size (large/medium/small), color (photos only: a named color or hex), count (1–8, default 5). Content: a text header, then per-result text (dimensions, credited creator, full-resolution URLs) with a small inline preview image each (judge candidates visually, but always place the full-version URLs, never the previews), plus a URL-parameter resize/crop recipe so adjusting a chosen image never needs a new search. Structured: query, type, total_results, results[] (photos: {type, id, alt, photographer, photographer_url, width, height, avg_color, page_url, full_urls {original, large2x, large, medium, small}, preview_inlined}; videos: {type, id, duration_s, creator, creator_url, width, height, page_url, poster_image_url, files[] of {quality, width, height, file_type, url}, preview_inlined}), attribution. Attribution is required: the first time Pexels media is used in a conversation, tell the user "Photos sourced from Pexels" (or Videos, as applicable).

Secret keys

Secret key values never pass through chat: there is deliberately no tool input that accepts one. Never ask the user to paste a secret key's value into the conversation.

  • add_secret (conditional: only when the deployment has the web form surface): begins adding a secret key (API key, token) by returning a secure browser form link, project pre-selected. Params: project_id, name (optional pre-fill, env-var style). Text: the link plus handling instructions. Structured: form_url, project_id, name?. Present the link, then call list_secrets to confirm it was saved. The form takes values up to 64 KB and accepts multi-line ones, so a service-account JSON file or a PEM private key pastes in whole.
  • list_secrets: the project's secret key names and timestamps only; never values. Params: project_id. Structured: secrets[] of {name, created_at, updated_at}.
  • delete_secret: permanently removes a secret key; the stored value is destroyed and cannot be recovered, so check nothing in the app still reads that name first. Params: project_id, name (exactly as list_secrets reports it). Structured: project_id, name, deleted. An unknown name and a name on someone else's project answer identically. The running workspace keeps its .env.local copy until the next publish, so publish when the live app should stop seeing the value.

Stored secret keys are injected into the project's .env.local for the running app; the read/search tools redact .env files so a value is never accidentally echoed back into chat.

Anyone who can manage the project (its owner, or an owner or admin of its team) can do all of this from the workspace too: the Secret keys panel in the title bar's overflow menu lists the same names and times, adds a value, and deletes one.

SEO (conditional: only when the deployment's SEO vendors are configured)

Seven lookups sharing one per-account daily budget (resets midnight UTC) plus one pooled monthly allowance per site plan (Free 5, Basic 50, Pro 250 lookups/month); each call uses one lookup. On the free plan the monthly allowance is a hard limit; on paid plans lookups past it simply bill per lookup. All are read-only, all return chart-ready structured output with a truncated flag (items are dropped past a 50 KB byte ceiling).

  • seo_keyword_research: keyword ideas for a topic with monthly searches, cost per click, and ad competition. Params: topic, country (plain English, default United States), limit (default 20, max 50). Structured: topic, country, keywords[] of {keyword, monthly_searches, cost_per_click_usd, competition}, truncated.
  • seo_analyze_serp: the top pages a search returns today. Params: query, country, limit (default 10, max 20). Structured: query, country, results[] of {position, title, url, domain, description}, total_results, truncated.
  • seo_site_map: the page addresses a website exposes (yours or a competitor's). Params: url, limit (default 100, max 200). Structured: site, pages[] (URL strings), total_found, truncated, source (sitemap when the pages came from the list the site publishes for search engines, vendor when a crawler found them), more_pages_possible (true when the walk stopped with pages still reachable, so ask for a higher limit). On the sitemap source a site that publishes no page list comes back empty, and the text says so: that is a fact about the site, not a failed lookup.
  • seo_competitor_analysis: domains competing in search with a site. Params: domain, country, limit (default 10, max 20). Structured: domain, country, competitors[] of {domain, shared_keywords, average_position, estimated_traffic_value_usd}, truncated.
  • seo_ai_visibility: whether AI assistants mention a site in their answers. Params: domain, topic (optional filter). Structured: domain, topic, mentions, ai_asks_per_month, platforms[] of {platform, mentions, ai_asks_per_month}, truncated.
  • seo_ai_top_sites: which sites AI assistants mention most for a topic. Params: topic, limit (default 10, max 20). Structured: topic, sites[] of {domain, mentions, ai_asks_per_month}, truncated.
  • seo_ai_keyword_volume: how often people put keywords to AI assistants, with monthly history. Params: keywords[] (max 20), country. Structured: country, keywords[] of {keyword, ai_asks_per_month, history[] of {year, month, ai_asks_per_month}}, truncated.

Billing (host-dependent: hidden on hosts whose terms prohibit commerce link-outs)

Purchases never happen through chat: these tools return browser links only. The purchase tools lead with the first-party upgrade page (/upgrade on the product's web domain), deep-linked to what is being bought: show that link first, with the direct checkout link as the one-step alternative in the same result. The page shows both plan levels next to the account's current state, and it works for a signed-in user in any browser, including one whose chat host hides these tools. Where these tools are hidden, the upgrade page is not yours to hand out either: plan refusals there carry /plans instead, a page that describes the plans and begins no purchase.

  • upgrade_plan: starts a plan upgrade by returning the upgrade page link plus a direct checkout link. Params: plan (from the sellable catalog; today that is "pro", $25 a month flat with no per-seat charge at any team size), team_id (optional: buy the plan FOR a hand-created team; only that team's owner may pass it, and the plan then covers the team's sites and seats rather than the buyer's own). Text: the upgrade page first, then the checkout link with price, or a redirect to manage_plan when the account (or team) already has a paid plan. Structured: upgrade_page_url, checkout_url, plan, team_id (present on team purchases; all absent on the already-on-plan text-only answers). A team_id naming the caller's own personal team behaves exactly like omitting it; an admin or member passing one is refused with the way through (team_status names the owner).
  • upgrade_site_plan: starts a plan upgrade for ONE site (Basic or Pro) by returning the upgrade page link, deep-linked to that site's row, plus a direct checkout link. Params: project_id, plan ("basic" or "pro"). A site plan raises that site's own allowances (email, SEO lookups, content entries, contacts, backups, storage) and lets it publish to a custom domain, whatever plan the team is on, and takes the "Made with Iconic" badge off its published pages at the next publish: it is the second level of the two-level model, and it belongs to the site, not to a person. The purchase runs through the billing of the team HOLDING the site (the team's own billing for a hand-created team, the site owner's for a personal one), so only that team's owner (or the personal team's person) may buy it. A paid team includes ONE Basic site, and this tool SPENDS that inclusion rather than selling over it. Asking for "basic" while the team's included Basic is free puts the site on Basic at no charge and returns NO checkout link at all (included: true, charged: false). Asking while another site holds the inclusion returns the real price and names the site holding it, so both ways forward are visible: pay, or move the inclusion for free with included_site. Text: the no-charge confirmation, or the upgrade page first and the checkout link with price, or a redirect to manage_plan when the site already has a plan of its own. Structured: upgrade_page_url and checkout_url (both absent when the inclusion covered it), plan, project_id, project_name, included, charged, included_basic_held_by (present when a held inclusion is why this one is charged). After payment the site's plan takes effect within a minute or two; get_usage with the project_id shows it.
  • manage_plan: a secure link to view, switch, or cancel plans, the team's and its sites' alike (every plan bought through a team's billing shows on the same page). Params: team_id (optional: manage a hand-created team's billing; owner only, because the page shows the card and invoices). Text: the link, or a note that a free account (or team) with no billing history has nothing to manage. Structured: manage_url (only when a link exists), team_id (present on team plans). The link signs whoever opens it into the billing account, so show it to the user in the chat and nowhere else: never write it into a file, a page, a saved note, or an email.

included_site is registered outside this host gate (it moves an allowance the plan already includes; it sells nothing). A paid team's included Basic site is a slot: HELD by one site, or FREE and waiting. Call it with no params to read which, with project_id to put the inclusion on a site (moving it off whichever site has it), or with release true to hand it back so the slot sits free. release takes a project_id too, and that is not a conflict: it names WHICH team's inclusion to free (any site of that team's will do), which is the only way to reach a team the person made. A bare release frees their own team's, and the result names whose was freed either way. Only the team's owner may move or release the inclusion, the same seat upgrade_site_plan requires to buy a site its plan: the slot is the allowance the team is billed for, so an admin, a member, and the person who created the site are all refused unless they hold the owner's seat. That refusal is honest about the seat rather than pretending the site does not exist, because anybody in the team can see the site; somebody with no seat at all still gets the ordinary unknown-project answer. A team owner can move it on ANY of the team's sites, including sites a teammate created and sites that arrived by transfer. A FREE slot is a real state, not an accident: the next site the team creates takes it automatically, a site moved into the team takes it, and upgrade_site_plan asking for Basic spends it instead of charging. Nothing derives the holder from a team's oldest site, so an unspent inclusion stays unspent until somebody spends it. A site with a purchased plan of its own gains nothing from the inclusion, so pointing it there is refused rather than wasted, and a holder that later buys its own plan frees the slot on its own. Archiving the holder does NOT free it (archiving is a shelf, and restoring gives the site back what it had); the read names an archived holder out loud. Structured: state ("held" or "free"), project_id (absent when free), site_plan, released_at (absent when it has never been freed), app_url (absent when free).

Teams (conditional: only on deployments with team rows; the invitation pair also needs the invitation rail)

A team is the tenant: it holds people (with roles), sites, and one plan. Every account has its own team, made on first sign-in; the rest are teams somebody invited them to. Roles are owner (runs the team), admin (the same powers, one step down) and member (sees everything, changes nothing). Owner and admin may rename the team, manage its people and its plan, and move its sites. A team the signed-in person holds no seat on and a team that does not exist answer identically ("Unknown team id"), so IDs can't be probed. The acting person is always the signed-in account: no tool here takes an actor as a parameter. Every team tool hands back a settings_url that opens THAT team's pane in the web app (/account?section=team:<team id>:team, or the plan pane when the plan is the way through a refusal), so a link you relay lands where the sentence said it would.

Membership reaches the team's sites. Every project tool in this guide works on a team's site for the people seated in that team, by role. A member can OPEN and READ everything: status, files, saved versions, analytics, forms, contacts, content, publish state. Changing anything (editing files, saving versions, publishing, waking the workspace, renaming, addresses, domains, secrets, archiving, deleting) needs an owner or admin seat. A member calling a changing tool gets a refusal that says exactly that (they can view the team's sites; managing them needs an owner or admin), so relay it rather than retrying. Someone with no seat on the site's team gets the same "Unknown project id" an unknown id gets, on reads and writes alike. Whoever acts, the work always lands in the site's own account: its workspace, history, plan and usage stay the site's, never the acting teammate's.

  • list_teams: the teams the account is in, own team first, then by name. No params. Text: one line per team with your role in it, its plan, and how many of the plan's seats are taken. Structured: teams[] of {team_id, name, personal, role, plan?, seats {used, limit, remaining, members, pending_invites}?, icon_kind?, icon?, color?, image_url?, settings_url}, count, plus invitations_waiting_on_you[] of {invite_id, team_id, team_name, role, accept_url} (absent when invitations are not wired on the deployment). That last list is how an invited person's agent learns an invitation is waiting at all: signing in never joins a team, no tool accepts an invitation, and a team someone was invited to does NOT appear in teams[] until they accept, so when it is non-empty, give the user each accept_url (the invitation's own page, where Accept and Decline live) rather than telling them they are in the team. Every row carries its OWN settings link, because one closing line cannot carry several. Deliberately not paged: nothing generates teams, so the list is human-sized.
  • team_status: one team in full. Params: team_id. Text: your role, the plan and seats taken (a team on its 14-day Pro trial reads "Pro plan (trial, ends …)"), the roster, the invitations still waiting, and what to call next. Structured: team_id, name, personal, your_role, plan?, seats?, icon fields, members[] of {user_id, role, joined_at, you, email?, name?}, pending_invites[] of {invite_id, email, role, state, created_at, expires_at}, settings_url (a deep link that opens THAT team's pane, not the account home). Each roster line carries the sign-in address beside the account ID where the deployment can read it, which is what lets you connect a member to the invitation that seated them: team_members takes the ID, invite_to_team takes the address. email/name are absent, never null, when an account could not be read. Addresses are looked up for the first 200 seats on a team (a real ceiling on one call's fan-out: the largest seat count anybody can buy is 25, so no ordinary team comes near it), and past that the roster still lists everybody, just without addresses. Nothing about that is silent: the text says how many were unreadable and how many were never looked up, and addresses carries the same counts as {shown, unreadable, not_looked_up?, lookup_cap?}. Only invitations still WAITING appear here (they are the ones holding a seat). team_invites lists every one, including spent and expired. Three reads here are best effort and each says so when it fails rather than degrading to a zero: plan_unreadable, seats_unreadable and pending_invites_unreadable are present (and the text says so) when the plan, the seat count or the invitation list could not be read. Never relay a missing plan or an empty invitation list as a fact when the matching flag is set: that is a silence, not a zero.
  • create_team: makes a new team and puts the caller in it as its owner. Params: name (up to 60 characters). Everybody already has their OWN team from first sign-in, so this is for an additional one: a client's work, a side project, or people who should not see everything else. Read list_teams first rather than making somebody a second home. The new team starts on the free plan (a team with no billing row reads as free everywhere, so there is no row to write) and holds nobody but its founder: invite people with invite_to_team, and move a site into it with transfer_site. Its owner can put it on a paid plan later with upgrade_plan (pass the team_id) where that tool exists: like everything in the Billing section above, it is hidden on hosts whose terms prohibit commerce link-outs, so never promise it without seeing it in your tool list. It owns no sites until one is moved in. Structured: team_id, name, plan (always "free"), role (always "owner"), settings_url (a deep link to the new team's own pane).
  • manage_team: renames a team, gives it a mark, or both in one call. A team with no chosen mark is never blank: the workspace draws its monogram (its initials on a color of its own), so setting a mark is a nicety, not a chore. Params: team_id, plus name and/or either icon (any lucide icon name) with color (hex) or image_url, one icon shape per call. Owner/admin only. Text: what changed, plus the settings link. Structured: team_id, name, renamed, icon_kind/icon/color/image_url, settings_url (a deep link to that team's pane). The name is checked before anything is written, so a call carrying a bad name and a good icon changes neither.
  • team_members: the roster, and the one call that changes it. Params: team_id, plus user_id with either role ("owner", "admin", "member") or remove: true. With only team_id it lists. Changing or removing somebody needs an owner or an admin, except that anybody may give up their own seat, and any change that touches an OWNER seat needs an owner: making somebody an owner, demoting an owner, and removing one are all refused for an admin. Removing somebody never deletes their work: the team keeps its sites and everything they made. A team always keeps at least one owner, and the refusal says to promote somebody first. Roles: owner runs the team AND buys its plan; admin runs the team but never buys its plan (plan management is owner-only on every rail); member sees everything and changes nothing about the team. Making somebody an owner ADDS an owner: whoever was already an owner stays one. There is no handover step, so never tell somebody ownership was transferred. invite_to_team can also invite somebody straight in as an owner, on the same owner-only bar. Structured: team_id, name, members[] (with the sign-in address beside each ID where it can be read), addresses (how many were shown, unreadable, or past the lookup cap), count, settings_url (a deep link to that team's pane), plus changed_user_id + changed_role, or removed: true, on a write. The text names people by address rather than by an ID nobody can answer.
  • invite_to_team: invites an email address to a team. Params: team_id, email, role ("member" default, "admin", or "owner"). Owner/admin only, and an "owner" invitation may only be sent by an owner: an admin asking for one is refused, with the reason. Accepting an owner invitation ADDS an owner rather than replacing the existing one, so never call it a handover. The same bar governs team_members: only an owner can change somebody's role to owner. The person needs no account: they get an email, and once they sign in with that address they can ACCEPT the invitation on its landing page, or decline it. Joining always takes their explicit accept; signing in alone never joins anybody, so never tell a user their invitee is already in the team. The invitation runs out after 14 days. The invitation's email is attributed to the INVITER for the audit trail, but it is the platform's own mail: it rides no email allowance, spends none, and can never be refused by one (email allowances are a site-plan matter and this mail is no site's). Text: confirmation, the expiry date, the seats now taken, and, when the email did not go out, WHY and what to do about it (the invitation still stands either way, and once they sign in with that address they can accept it). Structured: invite_id, team_id, email, role, expires_at, emailed, email_refusal?, seats, settings_url (a deep link to the team's own pane, where its people and invitations are). email_refusal is present only when emailed is false, and names which of two things happened: no_transport (the deployment sends no mail) or provider_error (the mail service refused it just now). Relay the reason: they are not interchangeable, and only one of them is worth retrying. An emailed: false always carries a sentence about the mail, cause or no cause: never read a missing email_refusal as a successful send. Seats count people who have joined PLUS invitations still waiting, so a full team refuses with a message that names the plan, names the seat number, and gives the three ways past it (free a seat, withdraw an invitation, change the plan). Relay it, don't improvise pricing. Two abuse brakes, both loud: there is no resend (an address whose invitation is still waiting cannot be invited to that team again while it waits, and after an invitation ends, however it ends, the same (team, address) pair takes at most one fresh invitation per day), and one account can create at most 200 invitations per rolling day across all its teams. Each refusal is an honest sentence naming the state or the number and, for the timed ones, the exact moment the next invitation will be accepted; relay it as given and never call a refused invitation sent. Withdrawing and re-inviting is not a resend path: the daily pair limit runs from the last invitation's creation, so revoking changes nothing about it.
  • team_invites: the invitations on a team, and the call that withdraws one. Params: team_id, invite_id (withdraw that one), limit (1–100, default 20), cursor (from a previous next_cursor). Any member may read the list; only an owner or an admin may withdraw. An invitation the invitee DECLINES leaves the list the same way a withdrawn one does (the list does not say which of the two ended it); accepted and expired ones stay listed, so a long-standing team has more than one page: keep passing next_cursor back until none comes back. Withdrawing frees the seat that invitation was holding, and so does a decline. Structured: team_id, invites[] of {invite_id, email, role, state, created_at, expires_at, accepted_at?} (state is waiting, accepted or expired), count, total, next_cursor?, seats, settings_url (a deep link to that team's pane), plus withdrawn_invite_id on a withdrawal. After a withdrawal the list is re-read, and if THAT read fails the answer carries invites_unreadable: true with an empty invites and a zero count/total: the withdrawal still happened, but the empty list is a silence rather than a zero, so never relay it as "no invitations left". Call team_invites again.
  • delete_team: deletes a team and everything it holds, all at once. Params: team_id, confirm (the team's EXACT name, typed back). The cascade takes every site (published pages and previews go offline, web addresses and custom domains are freed), every seat and waiting invitation, and the team's plan: every live subscription is canceled right away, team plan and purchased site plans alike, and the rest of the period already paid for is not refunded (the cascade deletes every site, so there is nothing left for that period to serve). One exception: a subscription paying for a site that moved to another team is left alone, because the moved site keeps what was paid for; it charges nothing more and runs out on its own. The text says what that means for the money: normally that nothing more is charged; when a canceled subscription had an unpaid balance already billed (it was past due), that the balance still stands, because canceling does not void an invoice already issued. Everything the sites stored (files, contacts, submissions, analytics, their databases) is permanently deleted 30 days later; those 30 days are the only window support could still recover anything in, and after them nothing can. This cannot be undone, so ask the person and get their clear yes before calling it, and pass confirm only with a name they typed or clearly said; anything but the exact name refuses and deletes nothing. Owner only, and nobody's own team can ever be deleted: that one is where their own sites live. To keep a site, move it out first with transfer_site. Refusals name what is in the way: not the owner (team_status names who is), a personal team, a wrong confirm, a deletion already running, or a cascade that stopped partway (run it again with the same confirm to finish; what went is gone). Text: what was deleted, with the counts, what happened to the plan (that it stopped at once, that the rest of the paid period is not refunded, and whether an unpaid balance still stands) and the date the stored data is permanently deleted, then the next step. Structured: team_id, name, deleted, sites_deleted, seats_removed, invites_withdrawn, plan (disposition "none" or "canceled_now", plus unpaid_balance present, true, only when a canceled subscription's already-billed balance is still unpaid and can still be collected; absent when the deployment carries no billing rail), scrub_after (ISO date the stored data is permanently deleted; absent when the deployment carries no scrub rail), settings_url (where the teams that are left are listed). A refusal changes nothing at all, except the partial case, which says so.
  • rename_site: renames a project's DISPLAY NAME: what the owner sees in their list and on the site's own screen. Params: project_id, name. The site's web address (slug) is untouched, so every published link, every shared preview link and every connected domain keeps working. Use this for "call it something else"; use set_site_address when the ADDRESS is what should change. Text: confirmation, the renamed project's page in the web app, the live site's address when the site has been published, and the next step: publish, so anywhere the site prints its own name picks the new one up (or publish it for the first time when it was never live). Structured: project_id, name, slug, site_url (the site's included address in full, unchanged by a rename), plus embed_url + app_url (the project's Preview screen).
  • set_site_address: changes the site's included web address, the <address>.oniconic.app it publishes to. Params: project_id, new_address. This is the address twin of rename_site: that one changes the name and leaves the address alone, this one changes the address and leaves the name alone. What the user types is folded into a web address first, so "My Shop" and "my-shop" ask for the same one, and the folded result is what gets claimed. An address is only free when no other project holds it (archived projects still hold theirs) and the platform does not use it itself. The old address is RELEASED, not redirected: it stops working the moment this succeeds, anybody else can then take it, and every link, QR code and bookmark pointing at it is dead. There is no grace period, so say that to the user before calling it. The site's preview link changes too, because it is built from the same address. A site that was already live stays live: the new address is put in front of the release the site was already on, so the move does not take it dark. It still has to be published again, because the built site prints the OLD address in its canonical link, sitemap and robots file until it is rebuilt. Connected custom domains are untouched. One thing follows the move on its own: the site's own database is told the new address may sign people in with Google (a site with no database skips this). The other one is a BUILD SAFETY step the platform handles. The workspace holds its own copy of the address, and that copy is what the site's canonical link, social tags, sitemap and robots file are built from. A sleeping workspace picks up the new address on its next wake and is never woken for the move, which is what workspace_address_pending reports. While that flag is true the workspace's copy is stale, and anything built from it would name the address that was just released. Publishing handles that for you: a publish wakes the workspace and reconciles its copy from the project's own record before it builds anything, whichever door the publish came through. You can still wake it yourself first with start_preview, and the result text suggests it, which is worth doing when you want to see the preview on the new address before going live. Refusals, each restating where the site still is: the address is already taken, the address is reserved by the platform, the address cannot be one at all (empty after folding, or over 63 characters, which is refused rather than quietly shortened), and a hosting failure, which changes nothing and leaves the site live on its old address. Structured: project_id, name, address, site_url, previous_address, previous_url, changed (false when the site already had that address), old_address_released (false means the old address is still answering for now; it is no longer this site's either way), needs_publish, workspace_address_pending (true when the workspace was asleep, so the address inside it updates on its next wake; publishing wakes and reconciles it before building), plus embed_url + app_url (the project's publish panel).
  • set_site_mailing_address: saves the physical postal address that appears at the bottom of MARKETING email a site sends, next to the unsubscribe link. Params: project_id, mailing_address. US law requires every commercial message to carry the sender's real postal address. The platform does NOT block a send that lacks one: marketing goes out either way and the send result carries a warning saying the address is missing and that sending without it is the sender's call and the sender's risk. Transactional email (receipts, confirmations, replies to something the visitor did) never needs one. A business address, a registered office or a PO box are all fine; it has to be somewhere post actually reaches. Pass an empty address to remove it, which brings the warning back: that is the right move when someone stops sending marketing mail and wants their address off the internet. Text: what was saved (or removed), the site's Emails screen where the setting lives, and a next step. Structured: project_id, mailing_address (absent when removed), mailing_address_set (whether one is on file; NOT a gate, since marketing sends either way), plus embed_url + app_url (the Emails screen).
  • set_site_sender: sets who a site's email comes from. Params: project_id, sender_name, sender_address. The two halves behave differently and it matters. sender_name is the name recipients read next to the subject line, works on every site immediately, needs no setup, and is the single cheapest improvement to a site's email: mail from "Fairmount Barber Co." reads like the business, mail carrying the platform's own name does not. Offer it whenever you build a site that sends anything. sender_address is the part before the @ (like hello) and only takes effect once the site has its own sending domain connected and verified; it is saved and waiting until then, and the result says which of those is happening rather than letting you set something that silently does nothing. Empty strings clear either one. Text: what the site now sends as, whether the address part is live or waiting, the site's Emails screen, and a next step. Structured: project_id, sender_name, sender_address, sends_as (the address email actually leaves from today), using_own_domain, plus embed_url + app_url (the Emails screen).
  • archive_site: puts a site away, or brings it back. Params: project_id, restore (true restores an archived site). Archiving takes the site out of list_projects and out of the owner's list; nothing is deleted, its saved versions and stored files stay exactly where they are, it still holds its web address, its preview link stops serving when it is archived (restoring brings the same preview address back, live again after the site's next wake or build), and a plan bought for it keeps billing (the text says so when one exists; deleting is what stops the billing). Archiving one that is already archived is not an error. Text: what changed, then the team's Projects pane (an archived site stays listed there, and that is where it is brought back from), and the next step, restoring it or deleting it for good; a restore points at the site's own project page and start_preview instead. Structured: project_id, name, status ("archived" | "active"), restored, plus embed_url + app_url on a restore.
  • delete_site: removes a site for good. Params: project_id. Refuses unless the site has been archived first, and the refusal says exactly that: the two steps are deliberate, because deleting cannot be undone. Ask the person before calling it. It takes the site off their list and frees its web address at once, and everything the site stored (files, contacts, submissions, analytics, its database, its saved versions and its workspace) is permanently deleted 30 days later; those 30 days are the only window support could still recover anything in, and after them nothing can. Nothing irreversible happens at delete time beyond the row itself: the 30-day window is what makes deleting the wrong site survivable. Its preview link is retired for good here too: the archive this requires already stopped it serving, so deletion is usually only confirming that, and no new address is handed back since nothing is left to serve one. A plan bought for the site is canceled with it, right away and before anything is removed, and the rest of the period already paid for is not refunded: a deleted site has nothing left for a plan to serve. Canceling a plan while KEEPING the site is the opposite case and is unchanged, it serves out the period already paid for. The text says what that means for the money: normally that nothing more is charged; when the plan had an unpaid balance already billed (the subscription was past due), that the balance still stands, because canceling the plan does not void an invoice already issued. If the plan's billing cannot be confirmed stopped (the payment provider is unreachable), or the 30-day deletion could not be recorded, the tool refuses and nothing is deleted; try again. Text: what is gone, that its address is free again, and the date the stored data is permanently deleted, then the next step. The site itself has nothing left to link to, so it points at what remains: list_projects, or the team's Projects pane for the sites the team still has. Structured: project_id, name, deleted, plus site_plan_disposition ("canceled_now" when a plan's billing was stopped on the spot, "not_found" when nothing was being billed; absent on deployments without billing), site_plan_unpaid_balance (present, true, only when the canceled plan's already-billed balance is still unpaid and can still be collected) and scrub_after (ISO date the stored data is permanently deleted; absent when the deployment carries no scrub rail).
  • transfer_site: moves a site to another team. Params: project_id, team_id. Everything moves with it (saved versions, forms and submissions, the site's contacts, stored files, visitor stats, the site's saved emails and their send history, connected domains, and the site's payments account, so sales recorded from here on and the buyers behind them belong to the receiving team) and the web address does not change. The PREVIEW address does change: the move rotates it, so preview links shared before the move stop working, the team the site left keeps no way into it, and start_preview returns the current one. Everything on the Emails screen keeps working for the receiving team: its saved emails still open, edit, duplicate and wire to forms, and a marketing email that has already gone out stays locked, because all of those are read by SITE rather than by whoever owned it when they were written. The site's live rails re-aim with it, immediately and with nothing to re-save: a new submission emails the RECEIVING team (and counts against nobody's allowance, on either side), the form's confirmation email keeps reaching visitors even when the design was saved by one team and the form written by the other, and the people on the Contacts screen can be read, exported and deleted by the team holding the site (the team it left can no longer reach any of it). The site keeps selling through the same Stripe account it was already connected to; only the team it files under changes. A plan BOUGHT for the site (Basic or Pro, from upgrade_site_plan) stays on the site until the date it is already paid through: a project's plans belong to the team that owns the project, and a subscription cannot be handed to another payer, so the old team's one is scheduled to stop at the end of the period it bought. The result SAYS what the move did to that subscription rather than promising it, because scheduling the stop can fail: read site_plan_disposition and relay it. ends_at_period_end is the ordinary case; unreachable means the old team may still be charged and somebody there should check its billing; not_found means no active billing for the site was found on that team; none means nothing was bought for it. The receiving team can buy the site its own plan at any time, which takes over straight away and leaves the old subscription winding down harmlessly. A team's included Basic site NEVER travels: the team the site leaves gets its inclusion back, and the arriving site takes the RECEIVING team's own included Basic only when that team has not used it (a site carrying a bought plan never spends it). Otherwise it lands on Free, and upgrade_site_plan in the new team is the way up. The result says which of the three happened. The caller must be an owner or an admin in BOTH teams, the one the site leaves and the one it joins. The receiving team holds the site afterwards: everyone seated there can reach it, and list_projects shows it labeled under that team's team_id (the mover keeps seeing it there too, since they hold a seat in both teams). The result points at the site's own project page when the caller still holds the site's row, and at the receiving team's Projects pane otherwise. Either way it ends on the next step: team_members for that team, to see who can reach the site now. Two moves are refused rather than allowed to break later, and both refusals name the way through, so relay them rather than retrying. A site with a custom domain connected cannot move into a team whose plan does not include custom domains: the site would stay live on the domain (a move touches neither the host attachment nor the DNS records) and fail at the NEXT publish, days later and probably for somebody who did not make the move. The refusal names the domain and the two ways on: put the destination team on a plan that includes custom domains, or disconnect the domain first (remove_custom_domain) and move a site that no longer needs one. A site cannot move while a publish of it is still running either, because a publish reads the project's row across minutes of work; wait for check_progress to finish and move it then. An archived site CAN move and stays archived: it will not appear in list_projects under the new team until somebody restores it, and the result says so rather than leaving you hunting for it. Structured: project_id, name, team_id, team_name, previous_team_id, still_yours, archived, site_plan (the plan it landed on), site_plan_via ("purchased", "included", "team" or "none"), site_plan_purchased (the plan bought for the site, null when none was), site_plan_disposition (what the move did to the subscription behind it).

Site content (CMS)

Collections are named groups of entries sharing a field list; every entry has a draft copy (what saves write) and a published copy (what the live site shows). The published site reads content at page render, so content changes go live without republishing the site. All tools take project_id.

The live site serves published work, and only the workspace's own preview ever shows a draft. When the site's owner has a collection open in the Iconic app, the preview beside it renders the entry they are writing rather than the last version they published, including entries nobody has published at all, so writing can be looked at before it goes live. That is the ONLY place drafts are ever served: it is switched on by a signed grant the app mints for that one site, it lasts minutes, and it exists only in the running workspace, never in a published build. A visitor to the site sees published work, and so does anything else reading the site's content. Nothing you build needs to do anything for this, and @/lib/content handles it on its own: pages go on reading content the way they always have. What it does mean is worth telling an owner who asks why their unpublished post shows up in their preview and not on their site.

Field kinds, and which one prose goes in. text is one short line, like a title. markdown is the kind every piece of writing belongs in: a body, an answer, a description, anything a reader reads. It edits as blocks in the owner's workspace, previews as plain text in cards and tables, and renders as formatted content on the site through <Prose> in @/lib/prose. number, boolean and image are the rest. An image takes either an https address or a path on this site's own address, starting with a single /, like /blog-art/cover.png, which is the form to prefer for a file the site serves itself (anything in public/). Store the path, never the site's full address: a page resolves it against whoever is serving, so the same entry is right on the preview address and right on a custom domain, and it keeps working after set_site_address moves the site. Writing today's domain into an entry breaks every one of its images on the day the site moves, and nothing repairs content afterwards. Where an output needs an absolute address (the social preview image, a feed, a sitemap image entry, an email body), expand it at that point with absoluteSiteUrl from @/shared/seo and VITE_SITE_URL, and leave the tag out when there is no site address yet. Other relative forms stay refused: cover.png and ../cover.png mean a different file on every page that renders them, and //other-host/x.png is an address on somebody else's host wearing a path's clothes. html is the rare one. It exists for the case markdown cannot express, dropping a component, an iframe or a third-party widget into an entry, and the workspace marks it as an embed wherever it appears. Do not write prose as html: its tags then show up in every card, table cell and search result, which is exactly the bug this split exists to end. long_text is the name markdown carried before markdown was a kind. It is still accepted so nothing that already exists breaks, it is treated as markdown everywhere (same cap, same preview, same editor, same rendering), and it is no longer offered, so nothing new is created under it.

Existing content is never rewritten. Entries written before markdown existed hold whatever they hold, HTML included, and the platform does not convert a single byte of it. What changed is the READING: every preview surface (tool results, workspace cards, table cells, search) now renders a value as plain text, so markdown marks and HTML tags alike stop printing themselves at the reader. On the published site, <Prose> renders markdown; a value that is really HTML should be rendered with <Embed> from the same module, or converted once by opening the entry in the workspace editor and saving it, which stores it back as markdown. Opening an old HTML value in the workspace editor and saving it does convert it, and the conversion goes through a block model: headings, lists, quotes, links, bold and italic survive; attributes and tags the editor has no block for do not. That is the intended one-way trip from markup to writing, but it is a real change to the value, so an entry whose HTML matters should be moved to an html field first.

An embed must come from a complete read. html values are shortened by list reads exactly like prose, and a markup blob cut mid-tag renders as a hole in the page rather than as an embed. Read an embed with cms_get_entry, or on the site with fetchEntry, never from fetchEntries / fetchAllEntries. Note also that sanitizeEmbedHtml in the template is a tripwire over the owner's own markup, not a sanitizer: never hand it anything a visitor submitted.

Sizes. A markdown (or html, or legacy long_text) field holds up to 256 KB (roughly 40,000 words, so any long-form essay fits in one field), a short text field up to 1 KB, and one entry up to 512 KB across all its fields. A project holds up to 20 collections, each with 1 to 30 fields.

What a collection's fields can and cannot do. A field list is not frozen at creation. cms_update_collection does two things to it, and deliberately only two.

  • Add fields. add_fields puts new fields after the ones already there. Nothing existing is touched, no stored value moves, and no field is ever removed. This is the answer when a collection needs to hold something it did not hold before: growing it beats writing the new thing into a page file by hand, where the site's owner can never edit it.
  • Change what a field is labelled. relabel_fields takes {name, label} for each field whose label should read differently. Only the label moves. It is safe on a live site, because nothing on the site reads a label.

What it will not do, and why. A field's name is the key every page reads its value by (values["quote"]), so it is permanent: it cannot be renamed, it cannot be removed, and it cannot be reused for a different field. A field's KIND is permanent for the same reason plus one more: entries already hold values of that kind, and pages already read them as that kind, so there is no retype. Passing add_fields with a name the collection already uses is refused by name, and the refusal points at the two moves that do exist. Both lists accept only their own keys, and anything else is refused naming the key rather than quietly ignored: a field is {name, label, type, required?} and a label change is {name, label}, so there is no spelling of a retype (kind, fieldType, a second name) that gets silently swallowed. To hold a value of a different kind, add a NEW field and move the values across.

Two people changing fields at once. Adding a field never overwrites a list, so two changes that overlap both survive: if you add one field while the site's owner adds another in their workspace, the collection ends up with both. A field NAME, though, is only ever handed out once, and that is settled by the database rather than by whoever asked first. If a name is taken in the moment between your call being checked and it being written, the call is refused, nothing it asked for is added, and the refusal says to read the collection again before retrying. Never assume a refused call partly landed.

Adding a required field: what "required" actually means. A field added with required: true is checked when an entry is saved, from that moment on. That covers new entries AND edits to entries that already exist: the next save of an old entry has to fill it in. It is never retroactive. Entries written before the change keep every value they had, stay published, and stay live on the site exactly as before, with no value for the new field. Publishing one again is not blocked either. So required is editor-time validation, not a guarantee about what is stored, and page code still has to handle the value being absent for everything written before the field existed. Because that gap is invisible otherwise, the result says how big it is: entries_missing_new_fields (every entry that already existed, since a new field's key never existed for any of them to carry) and published_missing_new_fields (how many of those are actually live on the site, which on a collection past its plan's number is fewer than the number published, because the entries held back are not on the site to break). Close the gap by saving each old entry with cms_save_entry carrying just the new field (a save merges by default, so the values already there are kept), and publishing it.

What a collection IS: pages, addresses, names, and the author's ceiling. Eight settings, all of them yours to set at cms_create_collection and to change later with cms_update_collection:

  • has_pages: do entries here get their OWN page on the site? True for a blog: every post is a page with an address. False for an FAQ, a testimonial strip, a three-banner row: those are read in place, on somebody else's page. False turns off every surface that presents an entry as if it had an address (the page glyph beside the collection, the slug column, per-entry links, and the view counts on cards, since an entry with no page can never have page views). The slug still exists and is still the entry's id either way; it just stops being dressed up as a route. Left out, it is guessed from the collection's NAME, and only for page furniture: FAQs, banners, slides, testimonials, logos and stats guess false, and everything else guesses true, including reviews, features, announcements, pricing tiers and any kind of help center or knowledge base, all of which are ordinary pages on real sites. The collection's fields are not consulted, and the guess is never written down: the row keeps no answer at all, so it stays settleable forever and every surface knows it is looking at a guess (has_pages_inferred is true on every result carrying the collection). Always pass it. You are the one building the page routes, so you already know, and it is the only way the owner's workspace can file the collection: its CMS screen splits collections into Elements (read inside somebody else's page) and Pages (an address each), and it refuses to file a guess, so a collection you leave unset sits apart from both under "Other" until somebody says. Everything else about it also stays hedged: the workspace softens every sentence it speaks about that collection's pages. has_pages: true and the sitemap.ts file beside the page folder are the same decision said once each, so make them agree.
  • has_index_page: is there a page that LISTS this collection, a /blog page showing every post, a /faq page printing every answer? A different question from has_pages, and independent of it. That one asks whether ONE entry has an address of its own; this one asks whether the collection itself is served somewhere. All four combinations are real sites: posts with a page each and no index, an FAQ with one page and no page per answer, a blog with both, a banner strip with neither. Nothing guesses this one. A name says nothing about whether somebody built a listing page, and neither does has_pages, so left out it stays null on every result and the owner's workspace shows it as not set. A declared false is taken at its word: the workspace's live preview stops reading a same-named page as this collection's listing page, which is what keeps it from framing somebody else's page as yours. You built the route, so say it here.
  • detail_route: the ADDRESS one entry lives at, written the way your site routes it: /blog/@slug, /insights/@slug. You are building that page, so you are the only party who knows. Left out, the owner's workspace falls back to matching the collection's NAME against the addresses the running site reports, and that fallback is wrong in two entirely ordinary shapes: a collection called Posts served at /insights matches nothing, so the owner is told their blog has no page, and two blog-ish collections both match /blog, so one of them silently previews the other's posts. The fallback is always marked as a guess, never written down, and a collection that declares its address takes that address away from every other collection's guess. Declaring it also says entries have pages, so has_pages does not need repeating, and has_pages: false in the same call is refused as a contradiction rather than silently resolved. null takes a declared address back off the record, which says nothing about whether the page exists. Addresses only: no full web address, no ?, no #.
  • index_route: the ADDRESS of the page that LISTS this collection: /blog, /faq. Same rule and same reason. Declaring it says a listing page exists, so has_index_page does not need repeating and has_index_page: false alongside it is refused. null clears it.
  • appears_on: the pages this collection is printed on, as addresses somebody can visit: ["/"], ["/", "/pricing"]. This is the one that makes an FAQ, a testimonial strip or a banner row visible in the owner's CMS preview at all. Those have no address of their own, so until you say which page they are shown on, the preview can only tell the owner the entry is read inside some other page and show them nothing. Several pages is ordinary and fully supported: a testimonial on the home page and the pricing page is two addresses, and the preview offers both in its header. Nothing is ever guessed here, because a name says nothing about which page somebody printed a collection on, so left out it reads as "not set" everywhere. Addresses with nothing left to fill in (/pricing, never /blog/@slug), at most 20, and an empty list clears it. The preview frames the whole page; it does not scroll to or highlight the entry within it, because that would depend on markup the platform cannot rely on.
  • singular and plural: what one entry and many are called in this site's own words ("Post"/"Posts", "Question"/"Questions"). The workspace reads them everywhere: the button says "New Question", the search box says "Search Questions", the rail names the collection by its plural. Left out, the plural is the collection's name and the singular is derived from it.
  • max_entries: the most entries this collection should ever hold, e.g. 3 for a banner strip. This is the site author's own design decision, not a plan limit: no upgrade lifts it, only changing the setting does. It is enforced on the write path, so cms_save_entry refuses a NEW entry once the collection is full and the refusal names the number and both ways through (delete one, or raise the number). Editing an entry that already exists is never refused. Lowering it below what the collection already holds deletes nothing; it only refuses the next new one. Leave it out for no ceiling.

How many entries. The per-collection entry number comes from the SITE's plan: Free 25, Basic 50, Pro unlimited (with a platform ceiling of 10,000 on any one collection). This is separate from max_entries above and both apply: a new entry has to clear the author's number AND the plan's. Nothing is ever deleted by either. Two things happen when a collection is over its plan's number: creating a further entry refuses (the refusal names the current count, the plan's number, and that upgrading lifts it), and the entries past the number come off the live site. The site keeps its OLDEST entries live and holds the newest ones back, always the same way (creation order), so a downgrade never reshuffles a site's pages. Every held entry stays listed, readable, editable and publishable for the owner: cms_list_entries marks it hidden_from_site and says how many and why, and cms_list_collections reports the plan's number as entries_served_by_plan. Upgrading the site's plan puts them all back immediately, with no re-publish.

Whole values vs previews. Because a body can be 256 KB, every read that can return MANY entries shortens markdown and html values (and the legacy long_text name) to a roughly 300-character preview and names the fields it cut; the reads that return ONE entry carry everything. So cms_list_entries previews (and marks the shortened fields [preview only], listing them in preview_fields) while cms_get_entry is complete. On the published site the same split applies to @/lib/content: fetchEntries / fetchAllEntries (index reads) preview, fetchEntry (one slug, one page) is complete. Never quote or re-save a body taken from a list read.

How many entries a page actually gets, and the trap in it. A published site's read of a collection asks for up to 100 newest-published entries by default, and one read returns at most 100. One hundred is a page size, not a promise about the collection: the order is by publish time, and publish time MOVES. Re-saving an old entry republishes it, which lifts it to the front of the order and pushes whatever was last out of the window. A real site lost an entire category that way, on a page that rendered perfectly and said nothing. So when you write a listing page, reach for fetchAllEntries("Posts"), which is what the snippet cms_create_collection hands you: it pages until the collection runs out, so it stays complete however far it grows. Existing pages that call fetchEntries("Posts") without asking for an intentional cut take that same complete walk, so they benefit without a code change. Use fetchEntryPage instead when a page should show a deliberately short list or wants to react to a cut: it returns { entries, total, totalExact, truncated, nextCursor }. When the count succeeds, the read reports total, the collection's counted number of published entries, with total_exact: false meaning "at least that many" (the count stops at 500, which is a large blog's whole archive). total is ABSENT when the platform could not count it, and absent means unknown: it is never inferred from the page's own size, because a made-up total is the same confident wrong number this whole shape exists to stop. A truncated read always carries next_cursor, so "there are more" is never a dead end: pass it back as cursor until none comes back. The public endpoint refuses offset, including offset=0, and names the replacement: pass the previous page's next_cursor back as cursor. The cursor is a position in the order, not a row number, and that matters because publish time moves: with row numbers, an entry re-published while you were between two pages made page two start one row late, so one entry came back twice and another never came back at all. fetchEntries only returns one page when the caller makes that intent explicit with a slug, a cursor, or { expectTruncation: true }. Raising the limit is not the fix, because one read returns at most 100 either way and a growing collection would go quiet again. A failed read throws ContentReadError rather than returning an empty list: a rate limit or a blip during a page regeneration used to render a confident "No posts yet" on a site with a hundred posts, and an empty collection and an unreachable one must not share a return value. On a 429 the error carries the server's own Retry-After.

  • cms_create_collection: creates a collection. Params: name (pages fetch entries by this name), fields[] (1–30 of {name, label, type: text|markdown|number|boolean|image|html, required?}), and the eight settings above: has_pages, has_index_page, detail_route, index_route, appears_on, singular, plural, max_entries (all optional; left out, the WORDS are derived from the name and stored, has_pages is guessed from the name and deliberately not stored, and has_index_page, detail_route, index_route and appears_on are not guessed at all and stay null or empty). Say what the collection IS while you create it: it is one call now versus a wrongly presented collection later. Leaving has_pages out is the one that keeps costing something afterwards, because the CMS screen cannot file an unset collection under Elements or Pages, and the result text says so. Text: confirmation, one line naming what the collection is (its words for one and many, whether entries are pages, any ceiling), plus the exact +data.ts snippet a page uses to load the collection's published entries (fetchAllEntries from @/lib/content, which reads the whole collection rather than one window). A collection WITHOUT pages gets a different snippet: no fetchEntry, no slug in a route, no per-entry link, because handing a route-shaped helper to an FAQ is how an FAQ ends up pretending to be a set of pages. When the collection's entries DO have pages it also returns the one-line sitemap.ts file that page folder needs, so search engines are told those pages exist (see Search engines and the sitemap below); a collection without pages has no route to declare and gets no such file. Then the owner's CMS screen for the project, and the next step (write the first one). Structured: collection_id, project_id, name, fields, created_at, has_pages, has_pages_inferred (true when nobody said and the name was used instead), has_index_page (null when nobody has said, since nothing guesses it), detail_route and index_route (null when nobody has said, which never means "there is no page"), appears_on (empty when nobody has said), singular, plural, max_entries, entries_served_by_plan, snippet, sitemap_snippet (only when has_pages), blog_url (that CMS screen).
  • cms_update_collection: changes a collection's SETTINGS and its FIELDS. Params: collection_id, plus any of has_pages, has_index_page, detail_route and index_route (null clears an address), appears_on (an empty list clears it), singular, plural, max_entries (null clears the ceiling), add_fields[] (new fields, each {name, label, type, required?}, appended after the ones already there) and relabel_fields[] (each {name, label}). Only what you pass changes. The settings half is the fix when a collection is presented wrongly, e.g. an FAQ showing a slug column and a page glyph as though its answers were pages, and it is how you settle a collection nobody ever described so the CMS screen can file it under Elements or Pages. The fields half is how a collection grows: see What a collection's fields can and cannot do above for the full rules. A collection's name is fixed at creation, and so is every field's own name and kind. Nothing is ever deleted: no field is ever removed, no stored value is rewritten, and lowering max_entries below what the collection already holds keeps every entry and only refuses the next new one. When that happens the result says so out loud and carries entries_held, the number it actually holds, so a lowered ceiling can never read as a deletion that happened; the field is absent otherwise. Calling it with nothing to change is refused rather than reported as a no-op success, and the refusal names every parameter it takes. Text: what the collection is now, which labels moved, which fields were added, the size of the gap a new field opened, the warning that page code must still cope with that value being absent on older entries, and the next step. Structured: the same collection shape as create, plus entries_held where it applies, plus fields_added[], labels_changed[] of {name, from, to}, entries_missing_new_fields and published_missing_new_fields where they apply, and blog_url.
  • cms_list_collections: the project's collections. Structured: collections[] of {collection_id, project_id, name, fields, created_at, has_pages, has_pages_inferred, has_index_page, detail_route, index_route, appears_on, singular, plural, max_entries, entries_served_by_plan} (entries_served_by_plan null = every entry is live; max_entries null = the author set no ceiling), count. The text names the plan's number once when there is one, and says what each collection is. It also NAMES the collections nobody has said are pages or not, and how to settle them: those are the ones the owner's CMS screen cannot file under Elements or Pages, and settling them is worth doing whenever you are already working in the project. It names the ones nobody has answered has_index_page for separately, because that is a separate fact and nothing guesses it. It also names the collections whose entries have pages but whose address nobody has declared, and the in-place collections nobody has said are printed on any page, since that second one is why the owner's preview can show them nothing. Read it before adding entries: a collection's name is FIXED once it is created, and there is no way to delete one, so a near-duplicate second collection is permanent. The settings can be changed with cms_update_collection, and so can the field list, within the limits above: fields can be added and relabelled, never removed, renamed or retyped.
  • cms_save_entry: creates or updates an entry's draft (a new slug creates; an existing slug updates). Params: collection_id, slug (lowercase letters/digits/hyphens, unique in the collection), values (field name → value, only the fields being changed), mode (merge, the default, or replace). Send only the fields you are changing. Under merge every field you leave out keeps the value it already has, so fixing a title or flipping a flag on a 4,000 word post costs one small call and cannot touch the body. Clear a field by sending it as an empty string, which is the only way to empty one in this mode: omission and clearing cannot both mean the same thing. replace writes values as the entry's whole draft and clears every field left out of it, so reach for it only when you hold the complete values and mean to write all of them. On a NEW entry the two modes are identical, since there is no earlier draft to keep anything from. Every save says what it did to the fields you did not name: kept_fields (kept as they were) and cleared_fields (held a value before, hold none now), both in the sentence and in the structured result, because neither shows up in the values that come back. A merge is validated on the MERGED entry, not on the fields you sent, so a partial edit to a post whose fields are all required goes through. When a save IS missing required values, the refusal names every field with no value, not just the first. Refuses a value over its field's size, an entry over 512 KB, or a NEW entry past either ceiling: the collection's own max_entries (the refusal names the number and says to delete one or raise it) or the plan's per-collection number (see above). Updating an entry that already exists is never refused by a ceiling. Nothing changes on the live site until published. Structured: entry_id, collection_id, slug, status, has_unpublished_changes?, draft, updated_at, published_at?, mode, kept_fields?, cleared_fields?, blog_url. has_unpublished_changes says whether the draft has moved past what the live site is serving, so it appears only once the entry has a live copy: a never-published entry has nothing to be ahead of and the field is absent rather than false.
  • cms_list_entries: entries newest first with draft values and live status. Params: collection_id?, limit (1–100, default 20), cursor (from a previous next_cursor). Long text is shortened to a roughly 300-character preview; preview_fields names the shortened fields on each entry and the result text says so. Structured: entries[] (same shape as save, plus preview_fields?), count, next_cursor?, plus truncated/total_fetched past the 200 KB ceiling. This returns ONE page: keep passing next_cursor back until none comes back, and never report a total from a single page.
  • cms_get_entry: one entry in full, values COMPLETE: draft values and, when published, the live copy. This is the read to use before quoting or editing a body. Params: entry_id. Structured: the entry shape plus published (object) when live.
  • cms_publish_entry: copies draft to live, then READS THE ENTRY BACK through the address the site reads its content from. Publishing and being readable are one act here. A publish used to report "is live" the moment a row was written, which is a different fact from the site being able to see it, and the two came apart in production for weeks. So the write only STANDS if the read finds it:
  • Read back: the entry is live. This is the only successful shape, and readable is true.
  • The endpoint answered and the entry is not there (it answered a refusal, or answered correctly without the entry, or the site is built against an address this deployment does not serve). That is the SITE'S own view, so the publish is UNDONE, the entry goes back to being a draft with its words untouched, and the call comes back as an error naming the reason and the move that fixes it. Nothing is ever reported as published while nobody can read it. The most useful one it catches is the built-against-a-moved-address case, which no amount of republishing an entry fixes: that one says to restart the workspace and publish the site again.
  • The check itself could not run (this deployment serves no content endpoint, the platform could not reach its own endpoint, the endpoint throttled this server, or the check ran out of time). Those are all facts about OUR vantage point and say nothing about a visitor's, so nothing is undone, the entry stays published, and the result says plainly that it is not confirmed either way. It never guesses in either direction.

The check is bounded at about 5 seconds in the very worst case and is normally well under one, and its actual cost comes back with it. Params: entry_id. Structured: entry shape plus blog_url, readback (readable | unreadable | unchecked), readback_ms, readable (true only on a successful publish, false on a failed one, ABSENT when the check did not run, which is not the same as false), readback_source (workspace when the site's own stamped address answered, platform when the workspace was asleep and this deployment's address was read instead), readback_fault and publish_undone on a failure (publish_undone: false means the undo failed too and the entry is stranded as published while nothing can read it), and readback_reason when unchecked. No entry-level site link exists: the address an entry reads at is decided by the page you wrote for the collection, so the text points at publish_status for the site's live address instead.

  • cms_unpublish_entry: takes the entry off the live site; the draft is kept. Params: entry_id. Structured: entry shape plus blog_url.
  • cms_delete_entry: deletes draft and live copies entirely; cannot be undone. Params: entry_id. Structured: entry_id, deleted: true, blog_url (the CMS screen, showing the entries that are left).

Search engines and the sitemap. A site's /sitemap.xml is built when it is asked for, not when the site is built, because publishing an entry deliberately does not rebuild the site: a sitemap frozen into the build would be missing every post published since. It lists every fixed page automatically. It CANNOT work out the pages a collection backs on its own: a folder like pages/blog/@slug/ is one folder standing for however many entries there are, and @slug is not a web address. So a folder with a changing part in its name says which collection fills it, in a plain sitemap.ts file beside its +Page.tsx:

// pages/blog/@slug/sitemap.ts
import type { SitemapRoute } from "@/shared/sitemap";
export default { collection: "Posts" } satisfies SitemapRoute;

{ exclude: true } instead keeps a route out on purpose, and works on ordinary pages too (a thank-you page). Write this file whenever you give entries their own pages, which means whenever a collection has has_pages. Without it those pages are missing from the sitemap, which is exactly the bug this replaced: a real site told search engines about seven addresses while six live, working pages went unmentioned, with no warning anywhere. Nothing guesses the collection from the folder name, because a guess breaks the moment a folder is called something else and leaves no way to opt out. A collection WITHOUT pages needs no such file: its entries are read in place on somebody else's page and have no addresses of their own.

Nothing about the sitemap is silent. Everything it could not list says so, in the file's own comments and on the site's server log: a route with no declaration, a collection that could not be read (its addresses are missing, not gone), a collection that could only be read part way, a collection with nothing published yet. A failed read never empties the sitemap: every fixed page is still listed, and the answer is cached for one minute instead of five so it can be whole again quickly. If a failure leaves NOTHING to list (a site whose every page comes from a collection, during a content outage) the site answers 503 rather than serving an empty list, because an empty list tells a search engine the site has no pages at all. A site with more addresses than one sitemap file may hold (50,000, or 50 MB) serves /sitemap.xml as an index pointing at /sitemap-1.xml and its siblings, so nothing is ever dropped for being past a limit.

Preview addresses are kept out of search results, and the live address is not. A project's preview serves the same pages as the published site, so a preview link shared with somebody, or pasted anywhere public, could otherwise be crawled and turn up in a search result as a second, half-built copy of the site competing with the real one. The preview proxy stamps every response on a preview address with a noindex header regardless of what the project's own code does, which is the half that binds even when a crawler ignores robots.txt. A project built from the current template also answers /robots.txt itself according to the address the request arrived on: the site's own address (and any address that cannot be shown to be otherwise) gets the same allow-everything file it always got, with its Sitemap line; a preview address gets a refusal instead, with a comment saying why. Nothing about the published site's answer changed. The test is deliberately a list of addresses known NOT to be public rather than a comparison against the site's own address: the site's address is baked in when the site is built, so a site whose address MOVED would otherwise start refusing search engines on its real, working domain until it happened to be published again, which would take it out of every search index. The sitemap is unchanged on a preview and still answers there, because it lists the site's public addresses either way and an owner checking their own SEO files on their own preview should be able to. An older project's /robots.txt still answers the one file it always served, on every address alike, until it takes on the current template's shared/seo.ts and the routing split in +server.ts; the noindex header covers it either way in the meantime.

Error pages are ordinary pages of the template, and you redesign them like any other page. A project built from the current template ships two: pages/_error/NotFound.tsx is what a visitor sees when an address matches no page, and pages/_error/ServerError.tsx is what they see when something failed while rendering (and for the rarer codes a page can end itself with). Both are plain components written in the site's own Tailwind classes, so edit_file restyles them exactly the way it restyles pages/index/+Page.tsx. The template ships them neutral on purpose. Design them with the rest of the site, because a 404 in somebody else's typeface is the one page a visitor is most likely to screenshot.

Two things in those files are load-bearing and must survive a redesign. The status number and the sentence are DISPLAYED as they are handed in, never written as literals, because the response carries that same number and everything that reads a page believes the response. And each page paints its own background and text color in light and dark, because the site's layout does not wrap error pages, so nothing else paints them. pages/_error/+Page.tsx beside them is wiring rather than design: it works out the status, picks which of the two pages to render, and catches one that throws. shared/error-page.ts holds the sentence for each status, which is where the WORDS change rather than in the pages.

The status is the real one. An address that matches no page answers 404. A genuine fault while rendering answers 500. A page that ends itself with a specific code (throw render(403)) answers that code, with its own words on screen when it passed any, and an unusual code is answered and shown exactly as given. A site's own layout does not wrap its error pages, so the attribution badge does not appear on them and a layout that throws cannot take an error page down with it; the site's stylesheet is imported by the error page itself instead.

An existing project keeps the error pages it was scaffolded with. Template changes reach a project only when it is scaffolded, so a project made before this has whatever its pages/_error folder held on the day it was made, and a project made before there was an error page at all still answers 500 for a mistyped address and tells browsers, search engines and uptime checkers that the site is broken. Copying the current template's pages/_error/NotFound.tsx, ServerError.tsx, +Page.tsx, +Layout.clear.tsx and +title.ts, plus shared/error-page.ts, is what brings a project up to date. Read what is already there first: that copy replaces an error page the owner may have redesigned.

What a project gets

Every project is a real app on a modern stack, with the platform services already wired:

  • Stack: Vike (vike-react) + React 19 + Hono + Tailwind v4, running in a cloud dev workspace with a live preview URL; published builds are hosted on Vercel at https://<slug>.oniconic.app.
  • Database: InstantDB, pre-wired through lib/db.ts in the browser and lib/db-admin.ts on the server, a real realtime database the app's code queries directly. Both database SDKs are already installed in every project; never run an install command for one. Use lib/db.ts for code that runs in the browser (it queries as the signed-in visitor and obeys the permission rules) and lib/db-admin.ts for server code that acts as the site itself, which is already wired to the stamped server key. Sign-in is ready out of the box: Google (recommended) and email-code sign-in both work immediately. Google comes preconfigured on shared dev credentials (zero setup, for getting started rather than production traffic), so build with Google by default rather than asking the user to pick an auth method (mention they can switch). The SDK starts Google sign-in from a client NAME, and that name is google-web on every project: db.auth.createAuthorizationURL({ clientName: "google-web", redirectURL: window.location.href }), with the same clientName on db.auth.signInWithIdToken when you draw Google's own button. There is nothing to look up and nothing to configure first, so never fall back to email codes on the impression that Google needs setting up.
  • Forms: a hosted submission endpoint; the SiteForm component from @/lib/forms renders any create_form definition and delivers submissions to the owner's inbox (list_form_responses), with automatic confirmation emails via save_email_design.
  • Email rails: platform-managed sending (confirmation emails to form submitters and test sends to the owner), each send going straight out on its own with an audit trail behind it, so no send ever waits on another one.
  • Analytics: a built-in privacy-first beacon (lib/visits.ts) counting views, visitors, referrers, time on site, page-to-page journeys, and every element flagged data-iconic-event, with data-iconic-convert marking conversions.
  • File storage: R2-backed permanent storage (upload_file for the workspace, the web, and bytes you are holding, upload_local_file for a large file on the person's own computer, plus the lib/storage.ts helpers scaffolded into new projects for in-app uploads and members-file rendering) served from the platform's file domain, with three visibilities: public, members (the site's signed-in visitors), and private. In-app uploads (a visitor storing a file through the site's own pages) always land inside the project's visitor-uploads/ folder; the platform enforces this so a page can never overwrite the site's own files, and the upload result reports the stored path. The tools above are not confined: they write anywhere in the project.
  • Content: the CMS draft/publish rail, read at render time via @/lib/content. Pages read published entries; the owner's own live preview additionally renders the drafts they are working on, with no page code involved.
  • Payments: Stripe Connect checkout for the site's own visitors, one-time and subscriptions (setup_payments, create_product, plus lib/payments.tsx for the buy button). The owner's Stripe account holds the money; Iconic's cut depends on the site's plan (3% Free, 2% Basic, 1% Pro, reported by get_usage) and rides every subscription charge too, at the rate each subscription was sold at; it never sees a card number.
  • Domains: every plan includes the <slug>.oniconic.app address, and that address can be moved later with set_site_address when the one it wants is unclaimed (the old address is released rather than redirected, so old links die); custom domains are a paid-plan feature of the team that HOLDS the site (not of whoever is asking), connectable right from chat (set_custom_domain returns every DNS record the domain will need in one answer, the TXT ownership check, the pointing record, and the two email records for sending mail from the domain, then verify_custom_domain checks and attaches) or in the Iconic web app's publish panel, whose records table carries the same one-visit set. Both connect tools also detect where the domain's DNS is hosted, nameservers first with the registration record as fallback, and return numbered steps for that provider's own console; an agent that can operate a browser for the user should offer to open the console and add the records with them (the user signs in themselves) rather than only reciting records. Both run the same gates; once connected, the domain becomes the publish target. publish_status lists every domain a project has and whether each is live; remove_custom_domain disconnects one by name, or all of them when no name is given; unpublish takes the site off ALL of its addresses at once while leaving every domain connected, and publish puts it back. Connecting alone points the WEBSITE at Iconic: the site's email keeps sending from Iconic's own address until the two email records verify (email_domain reads and re-checks that), so never tell an owner that connecting by itself changes who their email comes from. Visitor analytics keep counting on a connected domain exactly as they do on the included address, and no republish is needed for that. What a connected domain DOES change is what the site tells search engines: its canonical link, social tags, sitemap and robots file are built from the domain from then on, and they go back to the included address if it is disconnected. A site is connected to ONE address at a time, so an apex and its www form are two connections, not one; connect each with its own set_custom_domain call. Deleting a site releases its addresses at the host and gives up its claim on them, so the name is free to connect somewhere else.
  • AI: every model, through the Iconic Router, with no key to ask anybody for (ICONIC_AI_BASE_URL + ICONIC_AI_SECRET, already in the environment, and the AI SDK already pointed at it). Language models, embeddings and picture models alike; the provider keys are the platform's and never reach a project. Usage is metered per project and shown under Analytics.
  • Introspection: the preview bridge that powers preview_page_map/preview_inspect/preview_styles, plus the dev log at /tmp/dev.log when the preview won't come up.

The workspace sleeps when idle (start_preview wakes it) and can expire after long inactivity. Saved versions survive expiry, and rollback rebuilds the workspace from them. Save early, save often.

Working through the tools IS activity: every read, edit, search and command keeps the workspace awake, so it never goes to sleep under a session that is actively building, and it still goes to sleep on schedule once the session stops. Two signals cover the restart case anyway. If the workspace restarted between your changes (it stopped and came back from its last snapshot), the next tool result that touches it says so, once, with what to do: quietly verify your recent edits are still in place before building on them. When the platform can work out which copy of the files it came back on, that message carries the measurement instead of the suggestion: how old the copy is, how many saved versions are newer than it, and the exact version to bring the files forward to with rollback and scope: "workspace". That is a fact worth telling the user, and nothing is lost when it happens, because saved versions live in the project's own repository. It only appears when it was really measured and really behind, so its absence is never a promise the files are current. And project_status reports workspace_resumed_at, the platform's own record of the last restart, so you can compare it with your own last change at any time, plus workspace_behind_saved_versions when the same comparison finds the files behind. The same one message per result also carries platform notices, described under When Iconic tells you something arrived: a capability that arrived after this project was built, for you to judge against this codebase. A restart notice always wins that slot, because it is about work that may already be lost, and the notice waits for the next call.

On a project the platform builds (anything made with create_project, which is nearly everything), a preview link keeps working while the workspace sleeps, and it keeps up with the work: the platform holds a rendered copy of the project behind that address and refreshes it as you work, whether or not you saved, and always before the workspace goes to sleep, which is the only time anybody sees the copy rather than the running app. So a link you already gave somebody shows recent work rather than the last save, and opening it never wakes the workspace. The copy is a rendering, not the running app, so anything you need to check live (the bridge tools, a fresh install, server behavior you just changed) still wants start_preview. TWO cases leave it behind, and both are now said out loud. If a refresh falls due after the workspace has already stopped, the platform declines to start the workspace up again just to build, so the copy stays where it was: project_status reports that one as preview_copy_behind, nothing is lost, and the next start_preview puts it right. The other is a refresh that RAN and FAILED, reported as preview_rebuild_failing with a preview_rebuild_fault saying whose it is. Read the fault before you act on it. When it is site, the project's own build command exited with an error, preview_rebuild_error carries what the build printed, and fixing that is what clears it. When it is platform the failure was ours and the project's files are not the cause, so do not go hunting through them: make the next change as normal and the rebuild is attempted again. When it is unknown nothing established a cause either way, so treat it as ours until something says otherwise. Nothing published changes in any of these cases. Read that field the way you read the workspace check beside it: present means a skipped refresh is on record, and absent means no skipped refresh is on record. Nothing compares the copy against the workspace, so absent is not a promise that the two match. When it matters, start_preview refreshes rather than reports.

A rebuild of the copy is scheduled rather than immediate: a change records an intent, and the build waits for the working session to quiet down, so one build covers the whole session and always lands before the workspace sleeps. While work keeps coming, the wait keeps stretching with it, up to an hour at most, and then the copy is refreshed anyway. check_progress on that job says Scheduled with the time it is currently expected to start (which moves as you keep working), and it holds nothing up. You can keep editing, publish, or roll back while it waits, and you never need to poll it.

A project imported from an existing GitHub repository has no such copy. That repository owns its own build setup, so the platform never renders one, and the preview answers only while the workspace is awake: once it sleeps, the link shows a holding page instead of the site. Call start_preview before you hand the link out, and again before you point anybody back at a link you shared earlier. Never tell the owner of an imported project that their preview link will keep working on its own.

Norms and limits

  • Plans come in two levels, and the tenant is a team. A team holds people (with roles), sites, and ONE plan; everybody has a team of their own, and every site lives in one. A team plan is Free, Pro ($25 a month flat, no per-seat charge at any team size), or Enterprise (custom, arranged directly). Every NEW account starts its own team on a 14-day Pro trial, no card: while it runs the team is gated exactly as Pro, and when it ends the team lands on Free with everything it made intact. A site plan is Free, Basic or Pro, and every project carries one. Free teams include 5 projects in total (archived ones count), 3 seats, and publishing to the platform address, with every site on the Free site plan. A project whose setup never finished does NOT count, whether it was a first build, a copy or an import: the row exists (its id was already handed back) but nothing was ever built, so it does not spend one of the five. A setup still running does count, until it either lands or fails. Pro removes the project cap, raises the team to 25 seats, unlocks custom domains, and includes one site on the Basic site plan; the team's other sites stay on Free until their own plan is bought. A site's own plan is bought per site with upgrade_site_plan: a purchased Basic or Pro supersedes the team's included-site designation and the team-derived answer both, and it carries its own custom-domain right, so a site on a Free team with a purchased plan publishes to its own domain. Pro adds advanced analytics on top of the bigger allowances: live visitors, the Journeys map, and a funnel for every page and form rather than the two every site reads. That one is a SITE capability and no team plan grants it, because it describes one site's traffic; a paid team includes a site at Basic, and Basic is not it. The "Made with Iconic" badge reads the same axis. It is printed on a site's own public pages, so the SITE's plan decides it: a Free site wears it and a Basic or Pro site does not, whatever plan the team around it is on. A paid team's one included Basic site is on Basic, so it does not wear it; the team's other sites are Free sites and do. The mark is baked in when a site is built, so buying a plan takes it off at the NEXT publish, not before. A seat is a person IN the team, counting everybody who has joined plus every invitation still waiting, so inviting past the number refuses with a message that names the plan and the number. "Unlimited" describes the Pro site plan, never the team. Plan-capped actions refuse with a neutral message pointing at account management, or, where the Billing tools are hidden from you, at the plans page (/plans) that only describes plans: relay whichever you get, word for word, and don't improvise pricing. When the allowance that ran out belongs to a SHARED team, the refusal names that team and says a team owner or admin can manage its plan, because it is not the asker's own plan that refused; on somebody's own team it stays worded as theirs.
  • Email: plan-aware sending limits cover the site's outbound mail, attempts included: each SITE plan (the site's own plan, never the workspace plan) includes a monthly email allowance (Free 50, Basic 1,000, Pro 10,000) plus a daily burst limit (free 25, basic 200, pro 1,000; UTC). The free plan's monthly allowance is a hard limit; paid plans can send past it with overage billed per send. Only mail the app itself sends is metered, meaning the site's own transactional and marketing sends: a form confirmation to a visitor, a campaign, a test. Mail one of Iconic's own rails sends is not the site's and rides no allowance at all: it is never refused by these limits and never counts against one. That covers a team invitation, an allowance notice, a form-capacity notice, an access request, and the notification the site's owner and admins get when somebody fills in one of their forms.
  • Email pace, separate from the allowance: mail leaving from the platform's own sending address is also capped on PACE, per TEAM: 50 an hour and 200 a day, and any one recipient can be sent at most 10 form confirmations a day across everything that team sends. All site mail leaves from that address today, so these apply to every team. They are not part of any plan and no plan changes them: they exist because every site sending from the shared address shares one sending reputation, and one account bursting spends everybody's. Windows are UTC, and a refusal says which cap it hit and when it reopens. Relay that as given: it is a pace limit, not a broken send and not a spent allowance, and nothing was sent.
  • Email designs live in Emails: Iconic is the email service. Never export an email design as a downloadable file or offer it "for your email tool". Save it with save_email_design so it lives in the owner's Emails screen, wired to its form when there is one. If the trigger you want isn't supported yet (today only form confirmation emails send automatically), still save the design and tell the owner it's ready in Emails for when sending expands.
  • SEO: one shared daily lookup budget per workspace across all seven seo_* tools (resets midnight UTC), plus the pooled monthly allowance per site plan described above.
  • Secret keys: never in chat, in either direction. add_secret returns a browser form link; list_secrets returns names only; delete_secret takes a name, never a value; .env files are redacted from read_file/grep.
  • Analytics privacy: the beacon uses no cookies, no localStorage, no fingerprinting; it collects nothing personal about visitors, honors Do Not Track, and visitor counts are estimates. Say so plainly if the user asks.
  • Analytics windows and caps: the free site plan reads the last 7 days of analytics history (paid plans read everything); nothing is ever deleted, so upgrading reveals the full history instantly. Each plan also includes a monthly analytics-event allowance (Free 10k, Basic 100k, Pro 1M); events above it are still recorded in full; the overage is measured on a conservative billable count, and get_analytics states whether the amount above the allowance is currently charged (month_usage.overage_bills_now). Within a single day a site reports up to 300 distinct pages, 300 distinct event names, 150 distinct referrers and 600 distinct journey steps separately; anything past that is counted under (other) for that day, and get_analytics says so rather than quietly rounding the picture off. None of this reaches get_activity, which is a different question: the window and the allowance are about VISITOR STATISTICS, and a site's own history of publishes, saved versions, submissions and mail is read in full on every plan. It pages by cursor rather than by window, 200 entries at most in one answer, and a page that leaves entries behind says so.
  • Form submissions: each form holds a plan-sized number of stored submissions (Free 1,000, Basic 10,000, Pro 100,000). A form at its ceiling stops saving new submissions, emails its owner once a day, and reports itself in list_form_responses and in the owner's Data screen. Visitors still get a success answer, because the visitor did nothing wrong and a public error would tell anyone probing how full the form is. Nothing already stored is ever removed.
  • Placeholders: template content is never shipped. After building, read the main page back (or grep for leftover placeholder markers) to confirm nothing remains.
  • Output discipline: tool outputs are capped (file reads, exec output, list pages) with explicit truncation markers. Page with offset/cursor/limit rather than re-requesting blindly.
  • Attribution: first Pexels use in a conversation requires the credit line from search_media.
  • Language: talk outcomes, not machinery: the user never needs to hear env-var names, framework names, or vendor names. The database is "your project's built-in database", not the vendor's product name.

MCP endpoint: https://mcp.withiconic.ai/mcp This guide: https://withiconic.ai/agents (HTML) · https://withiconic.ai/agents.md (markdown) Product: https://withiconic.ai

Prefer the raw file? The same guide is served as markdown at withiconic.ai/agents.md, and over MCP as the resource iconic://docs/agents-guide.

iconic
Use casesFeaturesPlansPrivacyTerms
© 2026 The San Francisco Design CompanyIt’s either Iconic or it isn’t.