Start for free. Sign up without a card and you'll get a test project and a test key right away. Once your company (or, for a personal account, you) is verified, you can create a live key in the portal.
Hypamoon for agents: the MCP server
The Hypamoon API is also an MCP server (Model Context Protocol). Add it to your chat app or coding agent and the agent builds on Hypamoon for you: it publishes a site, stores form entries, finds or connects a domain, sets up a number with the voice AI assistant and a call plan, sends SMS, adds a chat to your site, runs agents and server code, and hands you the code to build the integration into your own app. It can simulate a call and read the transcript before anything is real.
The MCP URL (referred to below as HYPAMOON_MCP_URL):
https://api.hypamoon.com/api/v1/mcpIt is the same API with the same key: Authorization: Bearer hm_test_... or
hm_live_.... The consumer apps (Claude.ai, ChatGPT, Grok) sign in with OAuth
instead of a key; see below. Start with your test key. In test mode nothing costs money,
numbers are test numbers and calls are simulated; the agent can run the whole
quickstart without a phone. Swap in the live key when everything works.
The examples read two environment variables:
export HYPAMOON_MCP_URL="<the MCP URL above>"
export HYPAMOON_API_KEY="hm_test_..."What the key allows
The key needs the scope project:read; without it the server returns 403 missing_scope. Its other scopes determine which tools the agent sees. A test key
has all scopes. A read-only live key sees get_project, get_balance,
search_numbers, list_numbers, get_call_plan, list_calls, get_call,
list_messages, get_message, list_conversations, get_conversation,
list_sites, get_site, list_collections, query_documents,
get_form_snippet and get_integration_code. simulate_call and release_number only exist for a test key.
There are sixteen scopes, and every tool needs one of them. The tools that came
later use the same scopes: apps, domains, site versions and store exports use
sites:read and sites:write; agents and secrets use project:read and
project:write; email uses messages:read and messages:write; the brain,
payments on sites and paid access use data:read and data:write. A key or a
connection with all scopes sees every tool that is available, also new ones,
without asking again.
Every tool carries all four annotations: readOnlyHint, destructiveHint,
idempotentHint and openWorldHint. A tool that changes anything has
readOnlyHint: false, so an app that asks before a write action (ChatGPT does)
asks for it.
Add it to your tool
Lovable
In your Lovable project, open Connectors, choose + and then MCP server (a custom or personal connector). Fill in:
- URL: the MCP URL.
- Authentication: a header
Authorizationwith the valueBearer hm_test_...(some versions call this "Bearer token" or "API key"; in that case, paste only the key).
Then ask Lovable in the chat, for example: "Use the Hypamoon connector. Call get_project and follow next_steps to add an AI phone receptionist to this app. Use get_integration_code with environment lovable for the code."
The connector key is for the agent. The app itself also needs the key, as a
secret of its Edge Functions (HYPAMOON_API_KEY); get_integration_code tells
Lovable to ask you for it through its secrets form. Never paste a key into the
chat.
Claude Code
Claude Code signs in with OAuth, like the apps, so there is no key to copy:
claude mcp add --transport http hypamoon "$HYPAMOON_MCP_URL"Then type /mcp in Claude Code, pick hypamoon and choose Authenticate.
Your browser opens; log in at Hypamoon and pick the project.
Where nobody can sign in (CI, a script), use a key instead:
claude mcp add --transport http hypamoon "$HYPAMOON_MCP_URL" \
--header "Authorization: Bearer $HYPAMOON_API_KEY"This stores the key in your own Claude Code settings. For a shared
.mcp.json in a repository, keep the key out of the file and let Claude Code
read it from the environment:
{
"mcpServers": {
"hypamoon": {
"type": "http",
"url": "${HYPAMOON_MCP_URL}",
"headers": { "Authorization": "Bearer ${HYPAMOON_API_KEY}" }
}
}
}Cursor
In ~/.cursor/mcp.json (all projects) or .cursor/mcp.json (one project):
{
"mcpServers": {
"hypamoon": {
"url": "${env:HYPAMOON_MCP_URL}",
"headers": { "Authorization": "Bearer ${env:HYPAMOON_API_KEY}" }
}
}
}Cursor can also sign in with OAuth instead of a key: leave out headers and
give only the url. Cursor then opens the Hypamoon consent screen in your
browser, as described below for the apps. During registration Cursor sends
cursor://anysphere.cursor-mcp/oauth/callback as one of its callbacks; that
exact address is allowed, no other custom scheme is.
Use a smaller set if your editor limits tools
Cursor shows about 40 tools per server, and Hypamoon has over 100. Add
?tools= to the MCP URL to load one or more sets, separated by commas:
?tools=sites, ?tools=sites,data. Every set also brings get_project,
get_capabilities, list_activity, get_recent_errors and list_toolsets (which lists the sets and how to switch).
Leave it out, or use ?tools=all, for every tool. Your key's scopes still
decide what you can do; a set only hides tools.
| set | what it covers |
|---|---|
sites |
websites: deploy, uploads, versions, addresses, WordPress import, and forms with their collection (create_collection, get_form_snippet, create_chat_widget for the public token, query_documents) |
media |
the media library: upload pages for photos and files, list and organise them, and put them on a site |
domains |
check, register and connect domains, DNS, a new secret link for an unlisted site (rotate_site_secret_url), and Google Search Console verification (verify_search_console) |
data |
collections and documents, secrets, the business brain |
phone |
numbers, the AI receptionist, calls, SMS, webhooks, chat |
mail |
email from your own domain |
mail_automation |
email that goes by itself: templates, automations on events, scheduled email, sequences, and lists (lists not available yet) |
commerce |
payments, orders, paid access, shipping an app to the stores |
apps |
server code for a site: deploy, logs, users, files, database |
agents |
agents on a schedule and their approvals |
team |
members, roles, four-eyes approvals (approving or rejecting a waiting deploy) |
agency |
for agencies: client projects, access, owner invitations, who pays, budgets, usage, invoice lines, activity, and payment links and subscriptions for clients |
ChatGPT ignores ?tools=. An optional ?via=<channel> on the URL only tells
us where you found Hypamoon; it changes nothing about what you can do.
Claude, ChatGPT and Grok (the apps): sign in with OAuth
The consumer apps add a remote MCP server by URL and sign in with OAuth; they do
not let you set your own Authorization header. Hypamoon is an OAuth 2.1
authorization server for exactly that. You paste the MCP URL, the app sends you
to a Hypamoon consent screen, and there you choose:
- the project: your test project or your live project (start with the test project);
- what the app may do (the scopes;
project:readis always on).
The app gets a token for that project with those scopes. It works like a key with the same scopes: the same tools, the same test mode, the same errors. You need to be an owner or admin of the project. The connection is listed in the portal at https://app.hypamoon.com/koppelingen (Connect your chat), with the app, the project (test or live), the scopes and when it was last used. Revoke it there; the app stops working immediately and has to ask for access again.
The token also works on the REST API (every /api/v1 route), not only on the
MCP URL. It is for the assistant, though, not for your app: when the assistant
builds an app for you, create a separate API key for that app at
https://app.hypamoon.com/api-sleutels.
- Claude.ai (and Claude Desktop): Customize, Connectors, Add
custom connector. Name
Hypamoon, URL: the MCP URL. Leave the OAuth client fields empty; Claude registers itself. Choose Connect and sign in. On the free plan there is room for one custom connector. Tip: sign in at app.hypamoon.com in the same browser first; then the connector connects in one step. - ChatGPT (on the web, also on the free plan): open Plugins, choose
Add, then Add custom MCP server. Name
Hypamoon, server URL: the MCP URL. Choose OAuth and leave the client fields empty; ChatGPT registers itself (dynamic client registration). Confirm the warning, choose Create as a plugin, install it, then connect and sign in. In a work account, an admin may have to allow custom MCP servers first. If the dialog asks for an icon, use https://hypamoon.com/downloads/hypamoon-icon.png. Tip: sign in at app.hypamoon.com in the same browser first; then it connects in one go. ChatGPT reads the list of tools when you connect and keeps it. When Hypamoon adds tools, refresh the plugin or connect it again. ChatGPT asks for your confirmation before a tool that changes something (publishing a site, deploying an app); tools that only read are marked as such and run without asking. If ChatGPT offers only the tools that read, your plan or workspace limits custom servers to reading. ChatGPT sees the same tools as Claude with the same scopes, except buying or transferring a domain, payments setup, refunds and secrets, which the account owner does in the Hypamoon portal. - Plugin archive (ChatGPT and Claude): download https://hypamoon.com/downloads/hypamoon-plugin.zip. In ChatGPT: Plugins, Add, Upload plugin archive. In Claude: Customize, Plugins, Add, Upload plugin. Then connect Hypamoon and sign in. The archive holds only the MCP URL, the name, the icon and short instructions for the assistant; no key or token.
- Grok (on the web, iOS and Android; connectors work on every Grok plan, the free one included): go to https://grok.com/connectors, choose New Connector, then Custom. Paste the MCP URL and choose OAuth; Grok registers itself (dynamic client registration), so there is no client ID to fill in. Connect and sign in with the email link. Tip: sign in at app.hypamoon.com in the same browser first; then the connector connects in one go. Grok reaches the MCP URL from its own servers, which is why it has to be a public address like ours. On Grok Business or Enterprise, an admin adds the connector first in https://console.x.ai under Grok Business, Connectors, Add Connector, Other; members then connect and sign in as above.
- Grok Build (the command line):
grok mcp add --transport http hypamoon <MCP URL>with the MCP URL above, then sign in when it asks. - xAI API: add a remote MCP tool with
server_urlset to the MCP URL andauthorizationset to yourhm_key (hm_test_...orhm_live_...).
Signing in is by email link. If the link opens in a new tab, the app's window picks it up by itself and continues there; the new tab only tells you to go back to that window. If the link opened in the app's window itself, choose Continue here on that page. If you closed that window in the meantime, add the connector again: you are signed in now, so it connects in one step.
The menus of these apps change often; if a name differs, look for "custom
MCP server", "custom connector" or "plugin" and give it the MCP URL. The
APIs of these vendors (the Claude Messages API with mcp_servers, the
OpenAI Responses API with an mcp tool, the xAI API) can still send a key as
the Bearer token, as before.
How the OAuth part works (for client builders)
It follows the MCP authorization specification (revision 2025-11-25, which includes 2025-06-18): OAuth 2.1 with PKCE, Protected Resource Metadata (RFC 9728), Authorization Server Metadata (RFC 8414), Dynamic Client Registration (RFC 7591), resource indicators (RFC 8707) and token revocation (RFC 7009).
POSTto the MCP URL without a token returns401withWWW-Authenticate: Bearer resource_metadata="https://api.hypamoon.com/.well-known/oauth-protected-resource/api/v1/mcp", scope="project:read ...".- The resource metadata (also at
/.well-known/oauth-protected-resource) names the MCP URL asresourceandhttps://api.hypamoon.comas the authorization server. https://api.hypamoon.com/.well-known/oauth-authorization-serverlists the endpoints:authorization_endpointhttps://app.hypamoon.com/oauth/authorize(the consent screen, where your dashboard login lives),token_endpoint/oauth/token,registration_endpoint/oauth/registerandrevocation_endpoint/oauth/revokeon the API host.code_challenge_methods_supportedis["S256"].- Register with
POST /oauth/register(JSON). Redirect URIs must behttps, orhttponlocalhost,127.0.0.1or[::1](any port). Public clients usetoken_endpoint_auth_method: "none"; a client that asks forclient_secret_postorclient_secret_basicgets a secret once. - Authorization code flow with PKCE
S256(required) and theresourceparameter set to the MCP URL. The redirect carriescode,stateandiss. - Tokens: an access token
hm_oat_...for one hour and a refresh tokenhm_ort_...for 30 days. The refresh token only comes when the client registeredrefresh_tokeningrant_types(the default). Every refresh gives a new pair and retires the old refresh token. Using a retired refresh token again (after a ten-second grace period for parallel requests) revokes the whole connection. - Errors at the MCP URL: an invalid, expired or revoked token gives
401witherror="invalid_token"; a token withoutproject:readgives403witherror="insufficient_scope".
Client ID Metadata Documents (the newer alternative to registration) are not
supported yet; the metadata says so (client_id_metadata_document_supported: false), and clients fall back to registration.
Any other MCP client
Transport Streamable HTTP, stateless: every request is one POST with a
JSON-RPC 2.0 message and gets one JSON response. No session id and no SSE stream
(a GET returns 405). Protocol versions 2025-11-25, 2025-06-18,
2025-03-26 and 2024-11-05. Send the key on every request, including
initialize.
The tools
| tool | scope | what it does |
|---|---|---|
get_project |
project:read |
The project, its mode (test or live) and a setup checklist with next_steps. Always the first call. |
get_capabilities |
any key | What Hypamoon can do, per rail (sites, data, apps, domains, phone/SMS/chat, payments, email, agents (and the brain once it is available), advice/SEO/stores): what it is, its status (live, early_access, coming or not_on_this_connection), the tool to start with, the tools this connection has for it and which plan includes it (no amounts). The status follows the feature flags, the price source and the tools the key can see, so it never drifts. Not in ChatGPT while the app is in review. |
get_balance |
project:read |
The prepaid balance, this month's spending against the cap, automatic top-up and the latest 20 transactions. With a test key: the live project's balance, read-only. Topping up is done in the portal. |
set_company_details |
project:write |
A business (company name, registration number, address, contact person) or, with account_type personal, your own name, address and phone number; required before ordering a number. The registration number is kvk_number for a company in the Netherlands, registration_number elsewhere. A country we do not serve gives country_not_supported. In test mode no registration number is needed. For a business the contact can be a role (like Domain administration); a phone number still needs a person. To keep a role on the domains while the contact is a person, send registrar_contact_name; left out, the saved value stays. With domains registered or transferred through Hypamoon, the contact, email, phone and address also go to the registry: domain_contacts says per domain updated, unchanged, skipped (a test domain, or a change of holder, which needs support) or failed. |
search_numbers |
numbers:read |
Available numbers to order (test numbers with a test key). What it returns is what can be ordered right now. |
list_numbers |
numbers:read |
The numbers the project has, with their ids. |
order_number |
numbers:write |
Orders a number. Safe to retry: without idempotency_key the key is derived from the arguments, so the same call returns the same number. Pass a new idempotency_key to order a second number with the same arguments. |
release_number |
numbers:write, test key only |
Removes a test number from the test project, so another one can be ordered. A live number cannot be released through the API. |
get_assistant |
assistant:write |
The voice AI assistant. |
set_assistant |
assistant:write |
Language, voice, languages, instructions and website of the assistant. |
get_receptionist_test_link |
assistant:write |
A link to the Assistant page, where the user talks to their own assistant through the microphone in the browser, in test mode too and without a number, verification or payment (up to 3 minutes per call, 5 calls or 10 minutes a day per project). Behind HYPAMOON_TELEFONIST_BROWSER; not in ChatGPT. |
get_call_plan |
numbers:read |
The call plan of a number, with its version. |
set_call_plan |
numbers:write |
What happens when someone calls: a small graph (forward, ai_agent, hours, menu, announcement, voicemail, hangup). |
list_calls |
calls:read |
Calls, inbound and outbound, newest first, with a cursor; direction filters by one kind. |
get_call |
calls:read |
One call with transcript and summary. |
start_call |
calls:write |
The assistant calls one person who expects it (an appointment, a callback they asked for). Never cold calling or sales: telemarketing to consumers needs their prior consent under telemarketing law, and national do-not-call registers must be respected. The assistant always says it is a digital assistant. Test numbers give every outcome in test mode; in live mode the agent asks you to confirm every call first. Safe to retry: without idempotency_key the key is derived from the arguments, so nobody is called twice. |
record_call_consent |
calls:write |
Records that one person agreed to marketing calls from this project (source: form, call, import, manual; evidence: a reference to the proof; consented_at). Needed before start_call with purpose: "marketing". Stored as a keyed hash plus the last two digits. Only the owner or an API key. Not in ChatGPT while the app is in review. |
list_call_consents |
calls:read |
Lists the recorded consents, active and revoked; phone_number narrows it to one number. Not in ChatGPT while the app is in review. |
revoke_call_consent |
calls:write |
Revokes consent by consent_id, or every active consent for a phone_number. Nothing is deleted; from then on a marketing call to that number returns consent_required. Only the owner or an API key. Not in ChatGPT while the app is in review. |
list_webhooks |
webhooks:write |
The webhook endpoints. |
create_webhook |
webhooks:write |
Registers your app's webhook URL; returns the secret once. |
delete_webhook |
webhooks:write |
Deletes an endpoint. |
enable_webhook |
webhooks:write |
Switches an endpoint back on after it was switched off. |
send_test_webhook |
webhooks:write |
Sends a signed webhook.test and reports how it went. |
send_message |
messages:write |
Sends one transactional SMS (order confirmation, shipping update, reminder) to a mobile number. A destination that is not supported gives destination_not_allowed with a reason. Never marketing: that needs the recipient's prior opt-in under marketing law. Safe to retry: without idempotency_key the key is derived from the arguments. |
list_messages |
messages:read |
SMS messages, newest first, with their status and a cursor. |
get_message |
messages:read |
One SMS message with its status: queued, sent, delivered or failed. |
start_conversation |
chat:write |
Starts a text chat with the project's assistant: the same assistant that answers the phone, with its instructions, languages, opening hours and website. Returns the conversation with the fixed greeting. Safe to retry: without idempotency_key the key is derived from the arguments. |
send_chat_message |
chat:write |
Sends one customer message and returns the assistant's reply. In a chat the assistant does not book, transfer or send anything: it takes a message for the summary. Safe to retry: without idempotency_key the key is derived from the conversation and the text. |
list_conversations |
chat:read |
Chat conversations, newest first, without messages, with a cursor. |
get_conversation |
chat:read |
One chat conversation with all its messages and, once closed, its summary. |
close_conversation |
chat:write |
Closes a chat conversation; the summary follows as conversation.summary.ready. Closing twice is harmless. |
create_chat_widget |
chat:write |
Creates a public chat token for the chat widget on a website, for the exact origins you give (like https://www.example.com). Returns the token and embed_code, the script tag for every page. The token is public by design: it only chats, only from those origins, with a bot check and limits. A test key gives a test token that also works from localhost. |
list_chat_widgets |
chat:read |
The public chat tokens with their origins and embed_code, including revoked ones, and the daily spending limit of the public chat. |
update_chat_widget |
chat:write |
Changes a public chat token in place, so the snippet keeps working: display_name (the business name in the chat window and its greeting; null falls back to the site name, then the project name), the label, allowed_origins, turnstile or the daily limit. |
revoke_chat_widget |
chat:write |
Revokes a public chat token immediately: the widget stops, including in open conversations. Revoking twice is harmless. |
deploy_site |
sites:write |
Publishes a static website at https://<slug>.hypamoon.app and returns its url. Free, in test mode too. The agent sends complete files for a new site (at least /index.html, css and js inline or as files, images, fonts, PDFs, video (mp4, webm) and audio (mp3, m4a) preferably as source_url that Hypamoon fetches, base64 only without a URL and never for video or audio), no build step. A PDF opens inline in the browser; video and audio play with seeking (Range requests) and count as one site request per MB served. To change an existing site it sends site_id, keep_existing: true, if_match_version and only the changed files; the url stays the same. Without keep_existing the site gets exactly the files sent, and the response lists removed paths in warnings. With password the site asks every visitor for that one shared password (once per browser); null removes it. With media it puts files from the media library on the site without uploading them again: folders like portfolio/, file ids or paths like /media/logo.webp, each with its 640 version; private originals never go on a site, and a file you also send in files wins. |
create_upload_urls |
sites:write |
Upload links for files too large to send inline: per file path, sha256 and bytes; the agent PUTs the bytes to each upload_url (15 minutes, checked on size, SHA-256 and the type of the bytes), then names the file in deploy_site with path, sha256 and bytes instead of content. Limits per plan: free 200 files, 5 MB per file (a PDF 10 MB, video or audio 10 MB), 50 MB per site; paid (a paid plan or credit) 2,000 files, 25 MB per file (a PDF 25 MB, video or audio up to 90 MB, only via an upload link), 1 GB per site. |
create_drop_link |
sites:write |
A one-time upload page for the user, https://app.hypamoon.com/drop/<token>, valid for 24 hours, for this project (and a site, with site_id). For when the agent's environment cannot reach an upload_url (a sandbox in a chat app may block the PUT), or the user has the files. The user opens it without signing in, drags or chooses files (images including SVG, pdf, fonts, video and audio, within the plan's limits) and sees "Done. Go back to your chat." The answer has the url (shown once), a user_message to give with it and the id for get_drop. Not in ChatGPT while the app is in review. |
get_drop |
sites:read |
A drop link: status (open, done, expired), files (path, sha256, bytes, type, the original name) and deploy_files, the same list in the form deploy_site takes (no content; another path is fine, keep sha256 and bytes). Not in ChatGPT while the app is in review. |
create_media_link |
sites:write |
An upload page for the project's media library (https://app.hypamoon.com/drop/<token>, 24 hours or until Done), with an optional folder, site_id (Done also puts the files on that site), images_only and strip. Everything lands in Media; the chat then calls list_media. Not in ChatGPT while the app is in review. |
list_media |
sites:read |
The media library, newest first: folders with counts, and per file id, path under /media/, size, width and height, the 640 version, alt, url (null while it is on no site: private) and the sites that use it. folder, site_id, limit and cursor. Never the bytes. Not in ChatGPT. |
get_media |
sites:read |
One file from the library by media_id or path. Not in ChatGPT. |
update_media |
sites:write |
Rename (name), move (folder) or set the alt text of a library file; the 640 version moves along, sites keep their copy until the next deploy. Not in ChatGPT. |
delete_media |
sites:write |
Delete a library file with its versions; refused (409 conflict) while a site uses it, with the site and path. Not in ChatGPT. |
get_confirmation |
project:read |
The status of an action a chat set up for confirmation in the portal (see "Confirm in the portal" below): pending, processing, done, cancelled, failed or expired, with confirm_url (and group_confirm_url when several wait) and, when done, a short result. Only the user's own confirmations in this project. Not in ChatGPT while the app is in review. |
propose_plan |
sites:write |
Asks the user once to approve a whole goal (site_on_domain, email_from_domain or move_domain) instead of a link per step; returns one confirm_url. After the user confirms, the steps of that plan (connect_domain, set_dns_record, import_zone_file, switch_domain_nameservers) run without a new link for 24 hours; a step outside the plan gets its own link. Only for chat connections, behind HYPAMOON_PLAN_BEVESTIGEN. Not in ChatGPT while the app is in review. See "Approve a goal at once" below. |
list_sites |
sites:read |
The static websites of the project with their ids, urls, files (without content) and password_protected. |
get_site |
sites:read |
One static website with its url, files (without content) and password_protected. |
delete_site |
sites:write |
Takes a website offline right away and removes it from list_sites. For 30 days it can be restored as it was with restore_site (or in the portal under Sites & apps, Recently deleted); after that its files and versions are erased for good. permanent: true erases everything right away, also for a site that is already deleted; only when the user explicitly asks for it (not in ChatGPT while the OpenAI review runs). An app on the site is deleted with it and a custom domain is disconnected. The slug stays taken. |
restore_site |
sites:write |
Puts a site deleted in the last 30 days back live at its old address, with its files, password, visibility and versions. Without site_id it lists what can be restored, with restore_until. An app on the site and a custom domain do not come back; restore_notes says how to set them up again. The plan's site limit applies. Not in ChatGPT while the OpenAI review runs. |
suggest_site_address |
sites:read |
Checks the address the user wants and suggests up to three free ones from the name. Names that look like a bank, login, payment or support page, a brand, or anything with hypamoon in it are refused. Nothing is reserved. |
update_site_address |
sites:write |
Renames a site (the old address redirects with 301 for 90 days and then stays reserved for the project for 30 more days; at most 2 old addresses redirect, and at most 3 renames per site and 10 per project per 30 days, past that rate_limited with a user_message that says when; a connected domain and public tokens keep working) and/or makes it public or unlisted (only at a secret link of 26 random characters, not indexed, the readable address returns 404). A secret link is not a login; use password for real protection. Four-eyes approval applies. |
rotate_site_secret_url |
sites:write |
A new secret link for an unlisted site; the old one stops working within seconds. |
list_site_versions |
sites:read |
On every plan (the plan sets the retention: Free and Online 30 days, Reachable 90, Business a year). The history of a site, newest first: every deploy that changed files (including partial, import, approved, rollback, promoted preview) with number, time, author (user, via, client: ChatGPT, Claude, API, portal, wizard), message and the changed paths. Previews are listed with preview_url. auto_label marks good versions to go back to: before big change (live when a deploy hit the guardrail, kept like a label) and stable (live for at least 24 hours and not rolled back); a preview the guardrail made has held by guardrail. Paginated with cursor. |
get_site_diff |
sites:read |
What changed between any two versions (from and to, by id or number; default: the one before and the current one): per path added, modified, removed or replaced; a unified diff for text files, old and new size for binary files, with limits on size. |
rollback_site |
sites:write |
Puts the files of an earlier version live again right away, as a new version. Deploy checks and four-eyes approval apply; idempotent; if_match_version. |
restore_site_files |
sites:write |
Puts only the files in paths back as they were in an earlier version, live right away; every other file stays. A new version with cause file_restore and a message. Deploy checks and four-eyes approval apply; idempotent. Not in ChatGPT while the OpenAI review runs. |
promote_site_version |
sites:write |
Publishes a preview (deploy_site with preview: true, or one the guardrail held back, at a secret link with noindex) as a new version. Four-eyes approval applies. |
label_site_version |
sites:write |
Gives a version a name, like "before launch"; labeled versions are kept beyond the retention of the plan, the plan sets how many labels a site can have. |
create_collection |
data:write |
Creates a collection: a named list of JSON documents, the project's own data layer. For the entries of a contact form, sign-ups, reservations, or facts for the assistant. access: private (default), public_write (a form on a website, with the public token, a bot check and limits), public_read (a page shows the newest documents), assistant_read (the assistant gets the newest documents added through the API). triggers: email_on_insert (live only), webhook_on_insert (document.created). email_routing: who gets that email instead of the billing email (at most 5 addresses, optionally per value of a field). Optional JSON Schema. Where the shop settings are available, role (products or orders) makes it a collection the shop uses. |
list_collections |
data:read |
The collections with their access, triggers, schema and document_count. |
update_collection |
data:write |
Changes name, description, access, triggers, email_routing or schema; fields you leave out stay. email_routing is replaced as a whole, null sends the email to the billing email again. |
delete_collection |
data:write |
Deletes a collection: its documents can no longer be reached and a form that posts to it stops. Deleting twice is harmless. |
insert_document |
data:write |
Adds a JSON document (at most 64 kB) to a collection; with a schema it must match. The collection's triggers fire. |
query_documents |
data:read |
The documents of a collection, newest first, with limit, cursor, before and a filter on top-level fields. |
update_document |
data:write |
data replaces a document, merge changes some fields (JSON Merge Patch: null removes a field). |
delete_document |
data:write |
Deletes one document for good. |
get_form_snippet |
data:read |
The HTML of a working form for a collection with public_write, with the fields you name, a honeypot and the form.js script tag, and the project's public token filled in. Never a mailto link. |
simulate_call |
calls:read, test key only |
Runs a simulated call with transcript and summary and sends the real webhooks. A simulated caller (optional caller_goal) talks to the project's own assistant with its real instructions, knowledge and tools; tools are not executed. 10 free per day per test project. The first thing to try. |
get_integration_code |
any | Code for lovable, supabase-edge-function, nextjs-route, express or python-flask: the key as a server-side secret, API calls, and a webhook receiver with the signature check. part: "sms" gives a function that sends order updates by SMS. part: "calls" gives a function that lets the assistant confirm an appointment by phone, and how to read the outcome in the webhook receiver. part: "widget" gives the chat widget for a website: where the script tag goes in that environment, its options (data-color, data-position, data-title, data-welcome, data-lang, data-open) and a button of your own with window.Hypamoon.chat.open(). |
Sites
deploy_site gives a project a website without separate hosting: the agent
writes plain HTML, CSS and JavaScript, sends the files, and the site is live at
https://<slug>.hypamoon.app right away. Ask for a page for your business,
and then put the number on it (order_number, set_assistant) or the chat
widget (create_chat_widget with the site's url in allowed_origins, then
deploy_site again with the embed_code in index.html). There is no build
step, so the agent sends what the browser loads, never React or Vite source. A
project can have as many sites as it likes; on Free an account can create up to
5 new sites a day, each with up to 200 files and 50 MB (5 MB per file). A
change is live for everyone as soon as deploy_site answers, all files at
once.
To share a site with a few people, ask for a password: the agent sets
password on deploy_site, and every visitor sees a password form first, on
every path, until they enter it once per browser (ten tries per address per
ten minutes). One shared password, no accounts; the response only says
password_protected: true. A new password logs everyone out, and null opens the
site again.
Moving a site from another static host: two plain-text files go along with
deploy_site. /_redirects has one rule per line, from to [status] (301,
302, 307 or 308): to is a path on the site or a full https:// URL, and
:splat in its path takes the part under a * in from
(/docs/* https://docs.example.com/:splat 301). The domain always comes from
the file, never from the visitor, and the query is not passed on. /_headers
works like Netlify's (a path line, indented Name: value lines) for
Content-Security-Policy, X-Frame-Options, Referrer-Policy, Permissions-Policy,
Cache-Control, Link, Strict-Transport-Security without preload,
Access-Control-Allow-* and your own X- headers; deploy_site refuses any other
header with the line number (Set-Cookie, Location, Content-Type, anything on
/_hypamoon/), and allows at most 100 paths and 300 header lines. A Next.js
static export (output: "export") deploys as it is, /_next/ included.
Sites live at https://<slug>.hypamoon.app. The old address,
https://<slug>.apps.hypamoon.com, now returns a permanent redirect (301)
to the new one. Always use the url from the response, not a hand-built address.
A static site has no visitor accounts: for sign-in, build an
app with sign-in. Without code, password
keeps a site private, paid access gives
each buyer a personal sign-in link, and the agent is told never to build a
password screen in JavaScript (the files are public, so it protects nothing).
Server code on the same address is an app.
Media
The media library holds the project's photos and files, apart from any one
site: the user uploads them in the portal under Media, or through an upload page
from create_media_link that works on a phone without signing in. A file in the
library is private until it is on a site. list_media shows the folders and the
files with their path under /media/, size, alt text and the sites that use
them; deploy_site with media puts them on a site, with a 640 version for
fast pages. update_media renames, moves or sets the alt text, and
delete_media refuses while a site still uses the file.
An SVG is welcome for a logo or an icon, up to 1 MB, as long as it only draws:
anything that can run (a script, an event attribute, a link or a style that
reaches outside the file) is refused with one short line on that file, never
cleaned up quietly. On a site, an SVG under /media/ or /uploads/ is served so
that nothing in it can run, also when someone opens it directly.
The media tools are in Claude, Grok and other MCP clients, and through the API
(GET /media, POST /media/links); not yet in ChatGPT.
Your own domains
A site can run on the user's own domain. Connecting a domain the user already
owns works today. Buying a new domain with register_domain is simulated with
a test key, and real with a live key or a normal connection as soon as the
project is verified for it; until then it answers 403 verification_required
with a link to what is missing. The tools use the sites:read and
sites:write scopes.
| Tool | Scope | What it does |
|---|---|---|
check_domain |
sites:read |
Whether one domain is available, its price for the first year and for renewal, can the user register it here, and the link to the registry's terms (registry_terms_url). Some names cost more at the registry than usual for their extension: the check then gives that price, and such a name can be bought for 1 year at a time up to a ceiling, with accept_price_eur on register_domain. |
suggest_domains |
sites:read |
Available domains, like a domain search: for a few words (the words themselves on .nl and .com first, then short variants like getbakery or bakeryhq), or for up to ten names the agent thought of itself (names), checked in one call. Taken names come back in unavailable, invalid ones in rejected. Nothing is sponsored. |
register_domain |
sites:write |
Registers a new domain in the user's own name, with DNS here. The agent must show the user the domain, the total price and the registry's terms and get an explicit yes first (accept_registry_terms, max_price_eur, and for a name with a higher registry price accept_price_eur). Safe to retry; after a failed attempt use a new key. Not available in ChatGPT: there the account owner registers a domain in the portal. |
connect_domain |
sites:write |
Connects a domain the user already owns. Default: the user points the domain's nameservers to the two we return; existing records, mail included, are copied first, so email keeps working, and mail_check warns when something is missing (pass dkim_selectors then). Fallback: only www through a CNAME at the user's own DNS provider. With site_id the site goes live on it. Connecting your own domain is free on every plan; buying one with register_domain costs the registration price, and the CNAME fallback carries a small surcharge. The Hypamoon badge stays on the site unless the project has a paid plan. If another project is waiting on the same domain, a TXT record proves ownership. An unused connection expires after 7 days. |
get_domain |
sites:read |
One domain (or all of them) with its status, the next step and, once live, its url. For a registered domain also the expiry date, whois privacy and whether the registry asks the user to verify their email. |
activate_domain |
sites:write |
Checks whether the nameservers (or the CNAME records) are in place, scans the previous DNS again while it waits, and puts the site on the domain once it is active. get_domain itself only reads. |
update_domain |
sites:write |
Changes a setting of a connected domain. hsts tells browsers to always open the domain over https: off (default), on (for six months) or on_with_subdomains. Once on, a browser that visited the site only opens the domain over https for months, even after turning it off; include subdomains only if every subdomain works over https. Only possible once a site is live on the domain with a valid certificate. |
set_domain_auto_renew |
sites:write |
Turns automatic renewal on or off for a domain bought or transferred through Hypamoon. On (default): renewed for a year before it expires, from the project's balance. Off: it expires on its expiry date. Not shown in ChatGPT while the app review runs. |
get_domain_authcode |
sites:write |
The transfer code of a registered domain, to move it to another registrar. Only for the account owner (connected through OAuth as owner, not with an API key); logged, and the owner gets an email. |
list_dns_records |
sites:read |
The DNS records of a connected domain. |
set_dns_record |
sites:write |
Adds, replaces or deletes one DNS record. A new A, AAAA or CNAME replaces the one already on that name. On an active domain a change that replaces or deletes a live record first answers confirmation_required with a preflight; confirm_overwrite: true after the user agreed. The records that serve the site are managed by Hypamoon. |
disconnect_domain |
sites:write |
Takes the site off a connected domain and removes its DNS here; the user then sets the previous nameservers back. A registered domain stays. |
diagnose_domain |
sites:read |
What is set up on a domain or subdomain right now, with one suggested fix per problem: nameservers (at Hypamoon or not), the records here with cleanup labels, what answers on the domain, www, the subdomain and the main names (status, server, certificate), email (MX, SPF, DKIM, DMARC, Microsoft 365 or Google Workspace complete), conflicts, a connection or transfer in progress. Read-only, for any domain. Not in ChatGPT. |
undo_dns_change |
sites:write |
Reverses the last DNS change made through Hypamoon on a domain, within 30 days: a record set, replaced or deleted, an old record replaced by connect_domain, a cleanup, a zone file import or a restore. Again goes one change further back. Without confirm: true it only shows what it would do. Not in ChatGPT. |
Solving a domain problem without asking us. Every error of a domain tool
(connecting, transferring, DNS records) carries, next to type and
user_message, a diagnosis (what is there exactly: which record, where it
points, what answers there now if a quick HTTP check can see it) and a fix
(one suggested action: a tool with arguments, or a portal link). The
user_message is plain words the chat can pass on, like "portal.example.com
still points to your old host (192.0.2.10). Replace it with your new site?".
When an error is unclear, diagnose_domain names each problem with a fix.
Nothing live is overwritten silently. connect_domain on a subdomain that
still points elsewhere, set_dns_record that replaces or deletes a live
record, switch_domain_nameservers and transfer_domain (with dns: hypamoon) while the DNS here does not match what is live, all answer
confirmation_required with preflight: what changes, what is live there
now, and whether email is affected. Nothing changed; the fix is the same call
with the confirmation flag (replace_existing, confirm_overwrite,
ignore_preflight). Send it only after the user agreed. Every DNS change
through Hypamoon keeps the previous state in the audit log, so
undo_dns_change (or Undo last DNS change under Domains in the portal) can put
it back for 30 days. In the portal, Check this domain runs the same diagnosis.
In the test project. Connecting a domain to a site through the nameservers
is real in the test project too: connect_domain with site_id creates a real
DNS zone, set_dns_record, import_zone_file and preflight_domain work on
it, and the site goes live on the domain once the nameservers point here. The
same limits apply as in live (a verified phone where that is required, three
domains waiting at a time, five new ones a day, the ownership check). Without a
site_id, or through the CNAME fallback (it carries a monthly surcharge, and a
test project never pays), the connection is simulated: nothing changes in real
DNS and activate_domain simulates the switch. Buying (register_domain) and
transferring (transfer_domain) a domain always stay simulated in test. When
the site moves to live with move_to_live, its real domain comes along.
A registered domain is always in the user's name, never ours. Complaints about a domain go to hello@hypamoon.com with "Abuse:" in the subject; we answer within five business days.
Watching and parking domains
Included in every plan: Free watches up to 3 domains, Online and Reachable up to 25, Business up to 100. Not in ChatGPT.
| Tool | Scope | What it does |
|---|---|---|
watch_domain |
sites:write |
Watches a domain the user owns, registered anywhere and not connected to a site. Checked once a day: DNS that points to a host where the domain is not set up (anyone could claim it there), spam or a changed title on the homepage, a changed host, expiry in 30 or 7 days, the certificate, and mail advice. Ownership is proven with the TXT record in ownership.challenge; a domain already active here or bought through Hypamoon needs none. park: page moves the domain to Hypamoon's nameservers and shows "This domain is reserved"; park: redirect sends visitors to redirect_to with a 301. A parked domain without mail gets null MX, SPF -all and DMARC p=reject. Included in the plan, up to a number of domains per plan. |
list_domain_health |
sites:read |
The watched domains with status ok, warning or problem, the reason in one sentence, the findings, and whether a domain is parked. dns_cleanup lists every domain of the project on Hypamoon's nameservers, watched or not, that still holds records left over from a previous host, with the counts. Read-only. |
get_dns_cleanup |
sites:read |
The cleanup suggestion for one domain on Hypamoon's nameservers: every record with a label (safe_to_remove, ask or keep), a group and the reason; the previous host (the address the control panel names point to); spf_advice; and the cleanups of the last 30 days. Names at the previous host are checked over https. Read-only. |
clean_up_dns |
sites:write |
Removes the safe_to_remove records in one step, plus the ask records the user chose (include_record_ids); exclude_record_ids keeps some of the safe ones. Records labeled keep are never removed. In a chat it waits for one confirmation in the portal. |
The owner of the project gets one mail per domain per day at most, only for a new problem.
Cleaning up after a move. A zone copied or imported from a previous host
often carries what that host made by itself: the names of a hosting control
panel (cpanel, whm, webdisk, webmail, cpcalendars, cpcontacts,
autoconfig, autodiscover, ipv6, and the SRV and TXT records of
_autodiscover, _caldav and _carddav), old _acme-challenge tokens, and
names that still point to the old server. Once that hosting ends and its
address goes to someone else, those names show their server. get_dns_cleanup
labels every record:
safe_to_remove: a control panel name that points to the previous host, or an old certificate token. If the domain's own site or its email still runs at that host, or the name answers over https with a valid certificate, it becomesaskinstead.ask: a decision for the user, with the reason: another site at the previous host,ftp, a DKIM key of another mail service (Postmark, SendGrid and the like, or the control panel'sdefault._domainkey).keep: never in a cleanup. Microsoft 365 (MX to*.mail.protection.outlook.com,autodiscovertoautodiscover.outlook.com,selector1andselector2DKIM,MS=), Google Workspace, everything Hypamoon manages (the site, Hypamoon Mail, verifications), SPF, DMARC and the domain's MX. How the SPF could be tightened is inspf_advice; a cleanup never changes it.
clean_up_dns removes the chosen records in one go. In a chat the user confirms
it once in the portal, with the passkey step; in the portal it is the button
under Domains, Cleanup suggestion. Before anything is removed, the records go
into the audit log as a zone file; the answer has the same zone_file. For 30
days Restore removed records under Domains puts them back (only what is
missing), and records put back are not suggested again unless the user chooses
them. Records imported with import_zone_file carry their label in the record
comment, and the import answer has the counts in cleanup. Not in ChatGPT: there
the user cleans up under Domains in the portal.
Moving to Hypamoon
For a user who already has a domain and a site elsewhere.
| Tool | Scope | What it does |
|---|---|---|
import_zone_file |
sites:write |
Imports the zone file (BIND format) the user exported from their current DNS provider into the domain's DNS here. More complete than the automatic copy of connect_domain. What cannot be imported is listed. Every import keeps a restore point. |
preflight_domain |
sites:read |
Before switching the nameservers: queries every known record at the current nameservers and at ours, and lists the differences. ready only when mail (MX, SPF, DKIM, DMARC), the domain itself and www match. Read-only. |
restore_domain_dns |
sites:write |
Sets the DNS here back to a restore point (by default the previous DNS as it was found), and returns it as a zone file with the previous nameservers, so the move can be undone. |
transfer_domain |
sites:write |
Moves the registration of the user's domain to Hypamoon with the transfer code from the current registrar. The agent must show the user the amount and the registry's terms and get an explicit yes first (accept_registry_terms, max_price_eur). The nameservers switch to the DNS here when it completes, or stay (dns: keep). Follow it with get_domain. Not available in ChatGPT. |
switch_domain_nameservers |
sites:write |
For a domain registered or transferred here: points the nameservers to the DNS here (only when the preflight is ready, unless the user accepts the differences), or back to the previous ones. |
import_site |
sites:write |
Copies an existing static site from the user's own domain into a site here, through the same checks as deploy_site. Only for a domain this project has proven it owns; someone else's site is refused. Same host only, robots.txt respected, within the site limits; the answer lists what did not come along (like forms or WordPress parts that need a server). |
inspect_wordpress |
sites:read |
Step 1 of moving a WordPress site: reads what any visitor can see (the public content API and the home page) and reports pages, posts, media, forms, products, the permalink structure, and what cannot move (drafts, form entries, users, orders). Read-only, on any public address, limited per project and per site. Never uses or accepts a login. |
import_wordpress |
sites:write |
Moves the published pages and posts of a WordPress site on a domain this project has proven it owns into a site here, through the same checks as deploy_site: same addresses (a /_redirects file for any that change), images within the site limits, one collection per form, products to the product collection where payments are available, and posts that do not fit in the private collection wordpress_posts. When the Hypamoon Connect plugin already sent the pages and posts of that site, it uses those (also when the content API is closed). Never touches the domain or DNS. |
connect_wordpress |
data:write |
For data behind the WordPress login, without passwords or screenshots: a one-time code of 8 characters (15 minutes) that the user enters in the Hypamoon Connect plugin (Tools > Hypamoon). Not available in ChatGPT. |
get_wordpress_inventory |
data:read |
The inventory the plugin sent. detail: compact (the default) gives the structure only: per kind of data the group, count, period, a few field names, personal data yes or no and why, relations, the current choice and the open suggestion; never values. detail: full adds every field name, statuses, collections and the selection rules. Read-only. |
suggest_wordpress_selection |
data:write |
Helps the user choose: without items it returns the compact inventory; with goal and items (per key on or off with a short reason) it saves a suggestion. Never applied by itself: the user applies it in WordPress (Tools > Hypamoon > Move data), or says yes in the chat. Personal data is marked separately and never ticked automatically. |
select_wordpress_import |
data:write |
Applies a choice, only with confirm: true after the user agreed (without it: a preview). from_suggestion applies the open suggestion; per key on or off, a period, statuses, fields to leave out, include_meta and names (for options). Personal data only with include_personal_data: true and confirm_text (the user's yes, word for word, kept in the audit log); otherwise a 409 with the question to ask. start_round: true lets the plugin start sending at its next check-in. |
set_wordpress_embeds |
data:write |
Puts the project's chat window and forms on the connected WordPress site, without pasting code: the chat on every page (on or off, colour, position, title, welcome), and forms that post to collections with public_write (a missing one is created with an email per entry). Makes sure a public token allows the site. The plugin applies it when the admin opens Tools > Hypamoon; a form goes on a page with the Hypamoon form block or [hypamoon_form id="..."]. Without chat and forms it only reads. Not available in ChatGPT. |
The safe order: connect_domain, import_zone_file (if the user can export
one), lower the TTL at the current provider a day ahead, preflight_domain
until ready, then switch the nameservers. restore_domain_dns and the
previous nameservers are the way back.
Moving a WordPress site follows a fixed plan that the server gives the agent:
inspect_wordpress and tell the user what moves and what does not;
connect_domain without a site (nothing switches yet); prove the domain if
asked; import_wordpress; show the new site at <slug>.hypamoon.app and wait
for approval; connect_domain with the site and preflight_domain until
ready; only after an explicit "go" in the conversation switch the nameservers
(or transfer the domain); then tell the user what is left for them (export
form entries, keep the old hosting for a week, move email first if it is hosted by
the same company).
Data behind the WordPress login (drafts, private posts, custom post types with
ACF fields, form entries, WooCommerce products, orders, customers,
subscriptions, users) comes along with the Hypamoon Connect plugin, never with a
password: connect_wordpress gives a code, the user enters it in the plugin, the
plugin sends an inventory (counts and field names, no content), the agent asks
what the user needs and shows the inventory (get_wordpress_inventory),
proposes a selection and saves it (select_wordpress_import). Personal data
stays off until the user explicitly says yes. The user clicks Send in the
plugin; the data lands in private collections (wp_posts, wp_pages,
wp_orders, wp_customers, wp_form_<name> and so on), and a later round adds
more without connecting again.
Since plugin 1.2.0 the inventory also lists post types that are not public
(public: false, like hours or applications), the plugin's own database tables
(table:<name>, with their columns, private and off until chosen) and settings
from wp_options (options, chosen by name with names; names that look like
a password, key, token, secret, salt or auth setting never move). With
include_meta on content or users, every other field comes along too, and
fields that point to a user or a post (like opdrachtgever_id) become
relations with the collection, the wp_id and the document_id of the
target.
A WordPress portal with logins (members, clients, candidates, hours) becomes a
Hypamoon app: Hypamoon is the backend, so no export scripts and no other
backend. deploy_app with auth and the portal's roles, apply_app_migration
for a D1 schema of the imported data, import_app_users to turn wp_users
into app users (no passwords; silent first, invite later), and
set_table_owner_column so each person sees only their own rows.
get_advice with type: wordpress_portal gives the full plan.
The same connection puts Hypamoon on the WordPress site itself:
set_wordpress_embeds turns on the chat window and chooses the contact forms.
The plugin picks this up when the admin opens Tools > Hypamoon and stores it, so
the chat and the forms keep working after the plugin's token expires. Visitors
talk to the public chat and form endpoints with the project's public token;
form entries land in the collection and are emailed to the owner.
Data and forms
A project keeps its own data in collections: lists of JSON documents that the
agent shapes itself, without fixed components like "form" or "knowledge base".
The contact form on a site is the first use. Ask for a contact form and the
agent calls create_collection with "access": {"public_write": true} and
"triggers": {"email_on_insert": true}, then get_form_snippet, puts the HTML
on the page and calls deploy_site again. form.js shows a small bot
check in the form and posts the fields to the collection with the project's
public token (the same token as the chat widget, so the site's origin must be in
its allowed_origins). A visitor's entry is stored and, in live mode, emailed to
the project's billing email address with Reply-To set to their email field;
document.created goes to your webhooks, even in test mode. Read the entries
with query_documents.
To send that email somewhere else, set email_routing on the collection:
{"to": ["info@example.com"], "rules": [{"field": "subject", "equals": "Jobs", "to": ["jobs@example.com"]}]}.
The first rule whose field matches the entry wins, then to, then the billing
email. At most 5 addresses per collection. An address must be confirmed before
it gets anything: the billing email, a project member's email and addresses on
a domain connected to the project count right away; any other address gets one
email with a link (live projects only) and receives nothing until it clicks.
Until then its entries go to the billing email. email_routing.recipients in
the result shows each address with status confirmed or pending. The same
settings are in the portal under Data, "Email on new entries". ChatGPT does not
see email_routing while the OpenAI review of the app runs.
For knowledge, create a collection with "access": {"assistant_read": true} and
add documents with insert_document: every new chat gets the newest of them
(8 kB at most) in the assistant's instructions. Only documents added through the
API reach the assistant, never form entries. The receptionist will follow later.
Limits: 20 collections per project on Free (50 on Online, 100 on Reachable, 200 on Business), 10,000 documents per collection, 64 kB per document; a form accepts 30 entries per hour per visitor and 500 per day per collection.
Secrets
These tools are not currently offered in ChatGPT.
| tool | scope | what it does |
|---|---|---|
request_secret |
project:write |
Asks the user for a secret an integration needs, like their online store's API key. Takes name, purpose, allowed_hosts (exact host names, https only, each optionally with a path prefix like api.example.com/v2) and optionally auth_scheme (bearer, basic or header with header_name). Returns a one-time link to the portal (/secrets/new), valid for 30 minutes. The user signs in, sees what is asked and where it may go, pastes the value and confirms. |
list_secrets |
project:read |
The secrets with name, purpose, allowed_hosts, auth_scheme, status (active or pending) and last_used_at. Never the value. |
delete_secret |
project:write |
Revokes a secret immediately: the stored value is erased and an open link for that name stops working. Revoking twice is harmless. |
The rule: a secret never passes through the chat. Chat history and model
context live with the assistant's provider, a prompt injection can make an
agent repeat what was said earlier, and assistant stores do not allow a
connector to ask for keys or passwords. So the agent asks with
request_secret, the user pastes the value in the portal, and the agent only
ever gets the name and the status back. There is no get_secret.
Only an owner or admin of the project can fill in a link, and when the link came from an OAuth connection, only the user behind that connection. The page quotes the purpose as what the assistant says, shows which key or connection asked and when, and puts the registrable domain of every host in bold, so a look-alike address stands out. Shared hosting, tunnel and request-inspection hosts (like webhook.site, ngrok or netlify.app) are refused. Asking again for the same name gives a new link and voids the old one; the current value keeps working until the user confirms the new one, and if the hosts change, the page shows old and new side by side with a warning.
Using a secret: server code (a webhook receiver, a function) calls
POST /api/v1/secrets/{name}/proxy with the API key and a body like
{"method": "GET", "url": "https://api.example.com/v1/orders"} (optional
headers and body). Hypamoon adds the secret (Authorization: Bearer,
Authorization: Basic, or the named header) and returns status, a few
headers and the body (body_encoding is utf8 or base64). Only https to
a host that is exactly on the secret's list (and under its path prefix, if it
has one), no redirect to another host or outside the prefix, at most 60
requests per secret per minute, 1 MB each way, 15 seconds for the whole request
including redirects. Responses come back uncompressed and whole: the proxy asks
for identity, refuses Accept-Encoding, Range and If-Range from the
caller (422), and does not pass on a compressed response (502).
The protection is that the secret only ever goes to the host the user chose.
As an extra safety net, if the response contains the secret (plainly or
encoded: JSON or HTML escaped, url-encoded, base64, double base64, hex, UTF-16
or reversed), the whole response is withheld and you get 502 upstream_error.
Do not rely on it: a service can always return the secret in a form nobody can
recognize. A path prefix also refuses ; and %25 in the path. Never call the
proxy from browser code: the API key does not belong in a page.
REST, same rules as the tools: GET /api/v1/secrets,
POST /api/v1/secrets/requests, DELETE /api/v1/secrets/{name} and the proxy
above. They are not in the OpenAPI document yet.
Payments on sites (early access)
Payments are in early access: everything below works, and some parts are still
limited. Whether a project takes real payments or test payments is one answer,
the same everywhere: get_payments_status returns availability
(early_access) and mode (live or test), with a one-line note to pass
on. A test project always pays in test mode. The full guide, with the checkout,
the quote and the shop settings, is at Payments.
A buy button on a site the user builds. The user connects their own Mollie account in the portal and is the seller: the money of every sale goes straight to their account, and Hypamoon never holds it. Hypamoon takes an application fee per successful payment, only after the owner has agreed to it in the portal. Outside Europe the portal connects the seller's own account at a second payment provider; the tools and the checkout work the same.
| tool | scope | what it does |
|---|---|---|
connect_payments |
project:read |
Returns the link to the Payments page of the portal, where the owner connects Mollie (or creates a Mollie account), picks a profile and agrees to the fee. Only the owner can do this, in the portal. Not offered in ChatGPT. |
get_payments_status |
project:read |
connected, profile, fee_consent, test_mode, availability, mode, note, ready and missing. Reads only Hypamoon's own records. |
list_payment_profiles |
project:read |
Only for a provider with several payment profiles per account: the profiles of the connected account, the default profile, and per site the profile its payments go to (profile null: the default; fallback true: the site's own profile is gone or blocked, so the default is used). Not offered in ChatGPT. |
set_site_payment_profile |
project:write |
Only for a provider with several payment profiles per account: site_id and profile_id send one site's payments to that profile; profile_id default removes the choice. The profile must be on the connected account and not blocked. Owner only. Not offered in ChatGPT. |
create_product_collection |
data:write |
Creates the product collection (default name products), or makes an existing collection with that name the product collection. One per project; calling again is harmless. Public read on by default, public write always off. Where the shop settings are on, this is create_collection with role: "products", and this name is a hidden alias until 12 December 2026. |
get_shop |
data:read |
The shop settings with every default filled in, the seller as buyers see them (from set_company_details) with what is missing, feed (the public product feed for Google Merchant Center, XML and JSON) and buyer_orders_path (/_hypamoon/orders, where buyers see their orders). Not offered in ChatGPT. Where available. |
configure_shop |
project:write |
Changes the shop settings: send only what changes, null clears a field. Currency, prices with or without VAT, shop name, contact email, links to terms, privacy and returns, order number prefix, extra checkout questions, the right of withdrawal, stock rules, buyer emails, download links (days and times), the orders page for buyers and the product feed (link pattern, brand). Not offered in ChatGPT. Where available. |
get_invoice |
data:read |
The invoice of a paid order in the seller's name and its credit notes: number, lines, VAT per rate and country, totals, notes, pdf_url and buyer_url. Says why there is none (no VAT setting, VAT calculated by the payment provider, seller details missing). Not offered in ChatGPT. Where available. |
create_invoice |
data:write |
Makes the invoice of a paid order that has none yet, with the next number of the seller's gapless series. Not offered in ChatGPT. Where available. |
export_orders |
data:read |
A CSV of invoices and credit notes over a period: invoices, vat (per rate and country) or oss (sales with the VAT of other EU countries). The seller files their own returns. Not offered in ChatGPT. Where available. |
issue_gift_card |
data:write |
Issues a gift card without a payment (sold at the desk, or as an apology), with amount_cents and a reason; with email the person gets it as a PDF, now or on send_on. Valid at least 24 months. Owner or builder only. Not offered in ChatGPT. Where available. |
check_gift_card |
data:read |
A gift card by code or id: balance, valid until, status and its full history. Not offered in ChatGPT. Where available. |
redeem_gift_card |
data:write |
Takes amount_cents (or "all") off a card; part is fine. Idempotent with idempotency_key, never below zero. Owner or builder (a viewer only at the desk, if allowed). Not offered in ChatGPT. Where available. |
list_gift_cards |
data:read |
The gift cards of the project, newest first, with status, limit and before. Only the last four characters of each code. Not offered in ChatGPT. Where available. |
void_gift_card |
data:write |
Cancels a card with a reason; the balance goes to zero and this cannot be undone. Owner or builder only. Not offered in ChatGPT. Where available. |
render_pdf |
data:read |
Turns one HTML document with its CSS into a PDF in a sandbox: no JavaScript, nothing loads from the internet. With site_id the files of that site (stylesheets, images, fonts by path) are put in and its colors and fonts are ready as --hm-* CSS variables. At most 2 MB of HTML and 20 pages, 10 seconds. Returns pages, bytes and blocked_resources; include_pdf: true adds the PDF as base64 (up to 1 MB). Nothing is stored. The same as POST /api/v1/pdfs and env.HYPAMOON.renderPdf in an app. Not offered in ChatGPT. |
get_checkout_snippet |
data:read |
The html of a buy button for one or more product ids, plus the script tag (https://api.hypamoon.com/checkout.js). Uses the project's public token, like forms. Optional shipping, button_label, return_path, and where available variant_id per product. |
get_cart_snippet |
data:read |
The drop-in cart: one script line (https://api.hypamoon.com/cart.js), <hypamoon-cart> for the header and <hypamoon-add-to-cart> per product (a variant picker appears for a product with variants), plus an example of window.Hypamoon.cart for a cart in your own design. Every total comes from the quote. Optional products, shipping, return_path, floating, public_token. The default for a shop with more than one product. Not offered in ChatGPT. Where available. |
create_download_upload_urls |
data:write |
Upload links for files sold as a download (name, sha256, bytes per file, at most 20). PUT the bytes, then put the returned file objects on the product with "delivery": "download". The files are never public; buyers get links that expire, after ticking that they lose their right of withdrawal. Not offered in ChatGPT. Where available. |
list_orders |
data:read |
The orders, newest first, with status (open, paid, shipped, canceled, refunded), items, amount, customer and shipping address, and where available number, fields, amounts, amount_refunded and tracking. Filter with status; page with before. |
get_order |
data:read |
One order. An open order older than a minute is first checked with the payment provider. |
update_order_status |
data:write |
shipped (only a paid order) or canceled (only an open order). Moves no money. Where available, tracking_number and tracking_url with shipped: the buyer gets a shipping email. |
refund_order |
project:write |
Refunds a paid or shipped order from the user's payment account: everything that is left, or where available only amount_cents. Needs confirm: true, after an explicit yes from the user; without it nothing happens. Calling it twice refunds once. The answer has stock_restore (see below). Not offered in ChatGPT or Claude. |
get_refund_link |
project:write |
A link to one order in the portal with the refund form open (order_id, or order_number like QS-1001; optional amount like 12.50 and reason to fill in). Read-only: it moves no money and prepares nothing. The user checks the amount there and confirms the refund themselves; the portal refunds through the same code as refund_order. Not offered in ChatGPT. Where available. |
A product is a document in the product collection: name, price_cents (a
whole number of cents, VAT included), optional stock (leave it out for unlimited;
it goes down when an order is paid) and optional active (false takes it
off sale). The button sends only product ids and quantities. The server reads
the amount from the product collection, takes the fee from the owner's
consent and the account from the connection; a site cannot set any of them.
The checkout (POST /api/v1/public/checkout?token=hm_pub_..., done by
checkout.js) asks the visitor for an email address (and an address with
shipping), runs the same bot check as forms, and sends the visitor to the
payment page. Afterwards the visitor comes back to the site with
?hypamoon_order=<id>. Each click carries an idempotency_key: sending the
same checkout twice gives one order. Limits: 20 checkouts per network per hour
and 300 per project per day. A project with a site that was taken offline
cannot take payments at all. Only products added with the API or MCP can be
sold.
When a payment succeeds the order becomes paid, the stock goes down, the
buyer gets an order confirmation by email (in the language of the site, with
the lines, the total, the seller and the right of withdrawal; it is always
sent, once; who it comes from: see "Who emails to buyers and app users come
from" below), the owner gets an email (if two buyers paid for the last
item at the same time, both orders are paid and the email says to check the
stock) and endpoints subscribed to order.paid get the order. Where the shop
settings are available, create_webhook also takes order.shipped,
order.refunded, order.canceled and stock.low.
Orders also appear as documents in a private collection orders, which can
never be made public; that copy is for reading, the order itself stays the
source.
When a paid order is refunded in full, the stock that went down for it goes
back up, once. That also happens when the owner refunds in full in the
dashboard of their own payment account (a partial refund changes no stock). Only what really
went down comes back: if two buyers paid for the last item at the same time,
the second order only returns what was left. Products without stock
(unlimited, like a course or a download) are never touched, and a product that
was deleted or lost its stock in the meantime is skipped. A shipped order
gets its money back but not its stock, because the item is gone. refund_order
and POST /api/v1/orders/{id}/refund return the order with an extra field
stock_restore: restored (true or false), reason and items (the
product_id and quantity put back by this call). reason is restored,
already_restored (an earlier call or the dashboard did it), shipped,
nothing_to_restore (no product in the order had a stock),
not_lowered (the stock did not go down when it was paid, so nothing goes
back; correct it by hand if needed) or failed (the money is refunded, but the
stock could not be updated; calling again tries once more). It is left out for
an order of paid access.
No tool ever returns a payment link: a payment is started by a visitor on the site, never in the chat. In ChatGPT these tools are for physical goods the user sells on their own site, and the connect step stays in the portal.
Test projects pay in test mode; whether a live project takes real payments,
get_payments_status says with mode.
REST, same rules as the tools: GET /api/v1/payments,
POST /api/v1/payments/product-collection, GET /api/v1/orders,
GET and PATCH /api/v1/orders/{id}, POST /api/v1/orders/{id}/refund,
GET and PATCH /api/v1/shop, and the public POST /api/v1/public/cart/quote
and POST /api/v1/public/checkout. They are described in
Payments, not in the OpenAPI document.
Paid access to a site (early access)
Built on payments, so it runs in test mode the same way. The server instructions have the fixed recipe "Turning a tool into a business" (steps a to g: look at what is there, explain what the user does themselves, address and domain, product and price, paid access on the site, a test purchase in test mode, live only after an explicit yes).
The user is always the seller, on their own Mollie account (Mollie is for sellers in the EU/EEA, the UK and Switzerland). Hypamoon takes only the fee the owner agreed to. A buyer pays once or per month or year, checks "I want access now and I understand I lose my right of withdrawal", and gets a personal sign-in link by email (it works once, for 30 minutes). The site then only lets in visitors with a valid pass; revoking one takes effect within seconds.
Who emails to buyers and app users come from. Sign-in links and
invitations of an app, the emails of paid access and the emails of a shop
(order confirmation, shipping, refund, downloads, gift cards) follow one rule.
When the site runs on the project's own domain and that domain is a verified
sending domain of the Mail module (which is on and not paused), they come from
that domain with the name of the shop or app, like "Black Bird Bakery" <orders@blackbirdbakery.nl> for a shop and noreply@ for paid access and
apps, in the look of the site; they then count for the Mail module and
follow its recipient rule: a buyer or the holder of an access pass only gets
such an email about their own order, gift card or pass, and an app user is
known through the app. Otherwise
(no verified sending domain for that domain, or the site is on hypamoon.app)
they come from "Hypamoon", with the name of the shop or app in the subject and
text and "on behalf of" at the top, without the site's colours. Replies always
go to the seller or the owner. If sending from the own domain fails (daily
limit, a provider outage), the same email still goes out from Hypamoon.
| tool | scope | what it does |
|---|---|---|
check_business_readiness |
project:read |
Step a of the recipe: the sites (address, paid_access, password, unlisted), the access products, and the payments status with subscriptions_ready and missing. With a shop (where available, not in ChatGPT) also shop: downloads (every download file uploaded), buyer_account (the orders page for buyers) and feed (the product feed, with products left out and why). Verification and mode come from get_project. |
create_access_product |
data:write |
A product with type: "access" in the product collection: name, price_cents (set by the user), interval (none, month or year) and site_ids. Does not lock the site yet. In ChatGPT the answer leaves the price out. |
set_site_access |
data:write |
Paid access on or off for one site. On: within seconds visitors without a pass see a short page with a buy button and "I already paid, send me my link". Not on a site with a password. |
list_access |
data:read |
The passes, newest first: status, kind, sites, valid_until, the buyer's email and name, and the subscription (pending, active, past_due, canceled, ended, paid_until). Plus counts, and revenue (not in ChatGPT). |
revoke_access_pass |
data:write |
Ends one buyer's access within seconds and stops a subscription on it. Gives no money back (that is refund_order). Needs confirm: true. |
refund_order on an order of paid access also ends the pass, and stops a
subscription. A chargeback ends the subscription at Mollie and never collects
again. A failed collection sends the buyer an email with a link to pay; every
email about a subscription has a link to cancel it. In ChatGPT the same steps
apply, without amounts in tool descriptions or answers and without any link
that starts a purchase: buying happens on the user's own site.
Shipping an app to the App Store and Google Play
The server instructions have the fixed recipe "Shipping an app to the App Store and Google Play" (steps a to f: readiness, the user's own accounts, privacy answers, listing, export, and that building and submitting in the cloud comes later).
The app is always published under the user's own developer account (Apple guideline 4.2.6). No tool signs in to Apple or Google, and no tool asks for a password, API key or certificate. App Privacy and Data safety exist only in the consoles: the tools prepare the answers, the user submits them.
| tool | scope | what it does |
|---|---|---|
check_store_readiness |
sites:read |
Per store ready with blocking, warnings and next_steps, every check (online, complete content, privacy policy URL, support contact, a 1024x1024 icon, native value for guideline 4.2, no external payment pages for digital goods, no advertising trackers without permission), native_value (present and can add), and accounts (the user's own steps; fee and enroll_url are left out in ChatGPT). |
generate_store_listing |
sites:read |
Draft texts within each store's limits (Apple name, subtitle, promotional text, description, keywords, categories; Google title, short and full description, category), review notes, and the screenshots and graphics per store. In English; source_language says when the site is in another language. |
get_privacy_answers |
sites:read |
The answers for Apple App Privacy and Google Data safety from what the site and project use on Hypamoon (forms and fields, chat widget, bot check, payments, paid access, push registration, sign-in, location, third-party scripts), each with its reason, plus not_collected and a click-by-click list per console. Cautious: when in doubt, it counts as collected. |
export_app_project |
sites:write |
A Capacitor project as a zip (download link valid for 15 minutes): it opens the site at its domain or hypamoon.app address, with icon and splash from the site, an app id proposal, an offline screen and push registration, plus a README for Android Studio and Xcode. No secrets in the zip. |
REST, same rules as the tools: GET /api/v1/sites/{id}/store-readiness,
GET /api/v1/sites/{id}/store-listing, GET /api/v1/sites/{id}/privacy-answers
and POST /api/v1/sites/{id}/app-export. The app registers its push token at
POST /api/v1/public/app-devices (no key: an app cannot keep a secret); sending
notifications is a later phase.
From the test project to live
New projects since October 2026 are one project: sites, data and apps are
always real, and there is no separate go-live step. Phone, outbound calls, SMS,
email to other people, payments and buying domains become real the first time
the user asks for them, once the project is verified for that function (see
Going live). Older projects can still have a
separate test project. Merging it into the live project (a Merge test project button on that
page, with a dry run that first shows what moves) is rolling out. Until then,
move_to_live moves sites from such a test project to the live project. Not
available in ChatGPT for now.
| tool | scope | what it does |
|---|---|---|
move_to_live |
project:write |
Moves the chosen sites (sites, by id or slug; default all) to the live project with every version, label, password and address; the collections they use (a site uses a collection when its files name it) with all documents; an app on the site with its sign-in users, files and database; the agents on those collections (they arrive paused); and domains connected for real. The test form token (hm_pubtest_) in the files is replaced by a new live token with the same allowed origins, as a new version labelled "moved to live". A collection that a site staying in test also uses is refused, unless shared_collections is copy (the live project gets its own copy). The answer lists what moved, what stayed, warnings and the next step. |
undo_move_to_live |
project:write |
Within 24 hours: moves everything of a move back to the test project (move_id, default the latest). The sites get the test token back as a new version. A copied collection stays in live. After 24 hours, support can still move it back from the move log. |
Only an owner can move to live (through a connected chat or the portal; an API
key without a person behind it cannot), and only when the live project is
verified: otherwise 403 verification_required lists what is missing (company
details, a payment with SCA, a verified phone). The plan limits of the live
project apply. The database part is one transaction, and a move log keeps the
rows from before for the undo.
The chat connection stays on test. A chat connected through OAuth points to the project the user chose when connecting. After the move, the sites are in live, and a test connection no longer sees them. The user reconnects and chooses the live project, or switches the connection in the portal under Connect your chat (the Switch to live button under Your connected chats, with a fresh confirmation of the access). The portal also has a button: Sites & apps, Move to live.
Going live: there is no switch
Wanting a function is enough; what we check is who asks and whether they are verified. When a chat, an app or an agent asks for a phone number, a call, an SMS, an email to someone else, a payment or a domain:
- Who asks. The scope of the key or connection, the role in the project, any access limit on the member or key, and the confirmation that money and anything that cannot be undone already need.
- Verified for this. Every function needs the company level (or the personal level): details, a spending cap, a payment method or balance, and a verified phone. On top of that: outbound calls need the full level; SMS needs the business register check (or approved identity documents); payments need your own payment provider connected; buying a domain needs the business register check (or the documents).
When both are in place it is real right away, and stays real. When something is
missing, the request answers 403 verification_required:
{
"type": "https://hypamoon.com/docs/errors/verification_required",
"detail": "Sending text messages needs a verified phone number on your account. Nothing was done.",
"function": "sms",
"missing": ["a verified phone number on your account"],
"next_step": "Ask the user to finish this at https://app.hypamoon.com/verify-phone, then call this again with the same arguments.",
"verify_url": "https://app.hypamoon.com/verify-phone"
}Nothing was sent, ordered or charged. Give the user the link; when they are done,
call the same tool with the same arguments (and the same Idempotency-Key).
What never becomes real: a test key (hm_test_), a connection limited to test,
and review accounts. simulate_call tries a call without cost. Spending caps,
the balance and the growth limits still apply. In ChatGPT this applies after the
app review; until then a function there is simulated until it is in use.
Advice for beginners: blueprints and a readiness check
For someone who has never built a shop, a booking page, a restaurant site or a course, "make me a webshop" easily ends as a nice-looking mockup: a form that saves nowhere, a cart without a checkout, a password in JavaScript. Two tools and a short block in the server instructions keep the agent honest, without turning the chat into a questionnaire.
| tool | scope | what it does |
|---|---|---|
get_advice |
project:read |
The blueprint for one kind of application: webshop, bookings (appointments), restaurant or course_membership (events, portfolio, internal tools and SaaS come later). Optional country (an ISO code like NL, or a name; default the Netherlands) and audience (consumers, businesses, both). Returns ask_first (questions with why, a default, and blocking for the two or three that stop a working result), build_plan (the Hypamoon tools in order, including an honest order request while payments are not on), go_live_checklist for the region (EU in detail, UK and US basics, a general fallback; each point usually_required or recommended with why and how_on_hypamoon), pitfalls, and summary_markdown. Guidance, not legal advice. |
check_readiness |
sites:read |
Reads the live files of a site (site is its id, slug or address), detects the kind or takes type, and checks what can be checked automatically: terms, privacy, returns and contact pages with company details; forms that save to a real collection (not nowhere, not only mailto, not localStorage); buy buttons and a cart that lead to a real checkout; prices with currency; no placeholder text; no test wording on a live project; internal links that work; a 404 page; no password checked in JavaScript. Also findability: a title (10 to 60 characters) and meta description (50 to 160) per page, exactly one h1, html lang, Open Graph title, description and image, alt text on images, images over 500 KB, a viewport meta and a favicon; and structured data (JSON-LD): existing blocks must parse and have their required fields, and without one it offers the snippet for the kind. Returns works, missing (each with a plain explanation and next_step with the tool) and check_with_owner (what only the owner can confirm). |
Search engines (9 October 2026). Every public site gets /robots.txt and
/sitemap.xml from the sites worker when it has none of its own (its own file
wins). robots.txt welcomes search engines and the crawlers of AI search and
answer services, and disallows the crawlers that collect data for AI model
training (each by its published name; the list is in the robots.txt itself),
with a Sitemap line. An unlisted, password, paid, offline or AI-doubted site gets
Disallow: / and X-Robots-Tag: noindex. Every html page has a
Link: <...>; rel="canonical" header on the primary host, and a site with an
active custom domain sends <slug>.hypamoon.app to it with a 301 (except
/.well-known). After a deploy of a public site in a live project (or a test
site on an active custom domain) the changed pages are sent to IndexNow, at most
once per 10 minutes and 30 times a day per site.
| tool | scope | what it does |
|---|---|---|
verify_search_console |
sites:write |
Google Search Console verification with the user's token (site_id or domain, method, token). dns adds the TXT record google-site-verification=<token> to a domain connected with nameservers (a domain property); meta adds the meta tag to /index.html as a new version labelled "search console verification" (a URL prefix property). Refuses unlisted, password, paid and offline sites. Returns property, next_steps for Search Console and the sitemap to submit. REST: POST /search-console/verify. |
get_site_stats |
sites:read |
Page views per day, the top pages and origins (direct, internal, search, social, email, shortener, external) for a site of this project, last days (default 30, at most 90), from Analytics Engine without cookies; bots left out. REST: GET /sites/{id}/stats. |
fetch_site_page |
sites:read |
Fetches one path (path, default /) or a full url of a site or app of this project live, through Hypamoon's server, and returns status, the main headers (content-type, cache-control, location, x-*; never set-cookie), final_url, the redirects followed and the start of the body as text (20 KB, truncated). Only the site's own addresses and its connected domains; redirects only to addresses of this project (else redirect_blocked), at most 3. GET or HEAD, 10 s per hop, private addresses refused, 30 per minute per project. The way to prove a deployed site or app endpoint works live. REST: GET /sites/{id}/fetch. |
Never mandatory for the user. The agent may call get_advice quietly to
inform itself. It asks at most the questions marked blocking, uses the
defaults for the rest and says briefly which ones, and builds; "just build it"
is enough. The checklist and check_readiness are an offer ("Want me to check
what you might want to arrange before going live?"), never a condition for a
deploy, and their texts say "before you go live, you might want to", not "you
must".
Build hints on every deploy. deploy_site returns warnings with code
build_hint (and hint, message, paths, next_step) when it sees a form
without a real target (no collection, only mailto, only localStorage, or a
collection that does not exist), localStorage used as a database, a password
checked in JavaScript, a button to # or javascript: that promises to buy,
order or book, a cart without a checkout, or test wording or the test token on
a site of the live project. They are advice: the deploy has succeeded. A theme
toggle in localStorage and a form to a collection are fine. A partial deploy
that does not include a page's own script says nothing about that page's
forms and buttons.
Rules for the agent itself (in the server instructions): everything a visitor submits is saved in a collection; localStorage is not storage; no fake login; no payment or order button that goes nowhere; no invented reviews, customers or prices; and at the end, tell the user in plain words what really works and what does not yet.
REST, same rules as the tools: GET /api/v1/advice?type=&country=&audience= and
GET /api/v1/sites/{id}/readiness?type=. ChatGPT does not get these tools, the
build hints or the new block in the instructions while the app is in review.
Agents
An agent keeps working for the project after the chat. It has a trigger, plain instructions, a fixed set of actions and limits, and every run is logged.
| tool | scope | what it does |
|---|---|---|
create_agent |
project:write plus the scope of each action |
Creates an agent: name, instructions, trigger, actions, optional limits and status. |
list_agents |
project:read |
The agents with their trigger, actions, limits, status and pending_approvals. |
get_agent |
project:read |
One agent with its five newest runs and their actions. |
update_agent |
project:write |
Changes name, instructions, trigger, limits or status; actions replaces the whole set. |
pause_agent |
project:write |
Stops new runs until update_agent sets status back to active. |
delete_agent |
project:write |
Deletes the agent: no new runs, pending approvals expire, its own key is revoked. |
list_agent_runs |
project:read |
The log: per run the trigger, a short thought, every action with its status, the outcome and the tokens used. Kept for 90 days. |
approve_agent_action |
project:write plus the scope of the action |
Approves or rejects an action that is waiting for the owner. An SMS or a call can only be approved in the portal; here it can only be rejected. |
Triggers: document.created in one collection (a form entry, for example),
call.completed, conversation.closed (a chat conversation once its summary
is ready), or schedule with every_minutes (at least 15). A run starts within
a few minutes of its trigger.
Actions, default deny (only what you list is allowed):
| action | needs | default mode | what it does |
|---|---|---|---|
read_document |
data:read |
auto | Reads a document of the trigger's collection, or of the collections you list. |
update_document |
data:write |
auto | Changes fields of a document (a merge), in the same collections. |
email_owner |
auto | Emails the project's billing address. Never anyone else. Links the agent writes are removed, and the email says which event it came from. | |
send_sms |
messages:write |
approve | An SMS to the phone number of the event. |
start_call |
calls:write |
approve | The assistant calls the phone number of the event. |
send_webhook |
webhooks:write |
auto | A signed agent.action event to one of the project's own endpoints (endpoint_id). |
The phone number of the event comes from one place only: the field you name
in trigger.phone_field for a document, the other side of a call, or the
customer's phone in a chat. Never from free text, a transcript or a document the
agent read. Never one of the project's own numbers, and at most one SMS or call
per number per project per day, over all its agents. An agent on a schedule cannot text or call.
mode: "approve" queues the action until the owner approves it in the portal
(Agents), or with approve_agent_action for anything but an SMS or a call;
without a decision it expires after 72 hours. SMS and calls are always
approve when the trigger can come from a stranger: a collection with
public_write, a call, or a chat. A paused agent's actions can only be
rejected. In live mode the owner gets an email for every action that is waiting. In test
mode SMS, calls and email are simulated and shown in the run log. A live SMS or
call needs a verified project, checked when the agent is created and again for
every action. Limits: runs_per_day (default 20) and actions_per_run
(default 3, refused actions count too); at most 10 agents per project, and a
daily ceiling per project over all its agents. A live agent needs a prepaid
balance above zero or a saved card; every call to the language model is charged
like one chat reply. In test mode it is free, within a lower daily ceiling.
The agent treats the event as data, never as an instruction. It runs with its
own key that has only the scopes of its actions, through the same checks as
every other request. The data of an agent.action webhook is written by the
agent from the event, so it can contain text from a stranger: treat it as
untrusted input in your app.
A first recipe: "When a new contact request comes in, send me a summary and propose a call back that I approve."
{
"name": "Contact request follow-up",
"instructions": "Email me a short summary of every new contact request. If they left a phone number, propose a call back.",
"trigger": { "type": "document.created", "collection": "contact_requests", "phone_field": "phone" },
"actions": { "email_owner": { "mode": "auto" }, "start_call": { "mode": "approve" } }
}Every new entry in contact_requests then gives a run: the summary goes to you
and the call waits until you approve it in the portal under Agents.
Apps: server code on a site
An app is a site with server code on the same address. Requests to /api and
everything below it go to the code; every other path stays the site's static
files. The site's password, paid access and own domain apply to /api too, and
the page calls fetch("/api/...") on its own address, without CORS.
The code is one bundled ES module in the standard module worker format:
export default { async fetch(request, env, ctx) { ... } } and optionally
scheduled(event, env, ctx). No build step runs on Hypamoon: bundle
dependencies yourself. Each app runs isolated, with a CPU budget and a few
fetch calls per request and an outbound budget per minute and per day.
- Outbound calls only reach the hosts in
allowed_hosts(exact, or*.example.com). Private addresses and IP addresses are always blocked. - Data:
fetch(env.HYPAMOON_API + "/collections/<name>/documents")without anAuthorizationheader. Hypamoon adds the app's own key, limited to the project's data. - Secrets: never in the code or in
vars. Store each one withrequest_secret, list its name insecretsand sendx-hypamoon-secret: <name>on the fetch; Hypamoon adds the value, only to the hosts of that secret. - Schedules: cron in UTC with minutes in steps of 5, at most 3 per app.
| tool | scope | what it does |
|---|---|---|
deploy_app |
sites:write |
Publishes an app: main_module, modules, optional allowed_hosts, secrets, schedules and vars. Without site_id it first creates a site (with files, or a simple start page). With site_id it is a new version of that site's app: the modules you send replace the code, keep_existing_modules: true replaces only the modules you send, and without main_module and modules the running code stays (to change only settings, or to move a test app to live). Settings you leave out keep their value. Checked before it goes live (mining, hidden or downloaded code, fake login pages). |
list_apps |
sites:read |
The apps with their url, api_url, version, allowed hosts, secrets, schedules and limits. |
get_app |
sites:read |
One app with its kept versions (the newest 10) and its newest errors. |
get_app_logs |
sites:read |
The newest requests, errors, scheduled runs and outbound calls of the last 7 days. Never headers, cookies, query strings or bodies. |
rollback_app |
sites:write |
Puts a kept version live again with the settings it had; without version the previous one. |
delete_app |
sites:write |
Deletes the code, versions, schedules and key. The site stays. |
A small example: a contact form in index.html posts to /api/contact. The
app reads the form, looks up the weather for the visitor's city at
api.weatherapi.com with x-hypamoon-secret: weather_key, stores the entry
with POST env.HYPAMOON_API + "/collections/contact_requests/documents", and
answers with JSON.
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/api/contact" && request.method === "POST") {
const form = await request.formData();
const city = String(form.get("city") || "Delft");
const weather = await fetch("https://api.weatherapi.com/v1/current.json?q=" + encodeURIComponent(city), {
headers: { "x-hypamoon-secret": "weather_key" },
}).then((r) => r.json());
await fetch(env.HYPAMOON_API + "/collections/contact_requests/documents", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ data: { name: form.get("name"), email: form.get("email"), city } }),
});
return Response.json({ ok: true, temperature_c: weather.current?.temp_c ?? null });
}
return new Response("Not found", { status: 404 });
},
};Apps with sign-in, uploads and a database
An app can give the people who use it their own sign-in, keep private data
per person, let them upload files and keep its own database. These tools are
in tools/list alongside the other app tools.
Sign-in for the people who use the app.
deploy_appwithauth(signup:inviteoropen,roleslike["admin", "member"],default_role). People sign in on the site itself at/_hypamoon/auth/loginwith a one-time link by email (a bot check guards the form). Hypamoon keeps only their email address and name; there are no passwords. The page can callGET /_hypamoon/auth/me, and signs out with a form that POSTs to/_hypamoon/auth/logout.A code next to the link. The same email has a 6-digit code; after sending, the sign-in page shows a code field right away (valid 10 minutes, 5 attempts, only in the browser that asked). An invite has a code too, for another device ("I have a code").
Account page.
/_hypamoon/accounton the site: name, email (changed with a code to the new address), devices, sign out everywhere, download my data, and delete account (after 7 days, with an email). Link to it from the app.auth.protectandauth.session_days.protect: "site"keeps every page and file behind the sign-in (the default for a new app with signup invite; existing apps stay open until they set it),nonekeeps pages public;/apistays with the code.session_days(1 to 90) shortens how long a sign-in lasts after the last visit. Leaving either out keeps the current setting.Shorter sign-ins for the admin role. Whoever has the admin role (the first of
roles) signs in again after 14 days without a visit, everyone else after 90 (7 in a sensitive app).auth.admin_session_days(1 to 90) sets it per app, never longer thansession_days. A change applies from the next sign-in: sessions that are already open run out as they were.Not in a frame on another site. The pages of a site whose app has sign-in get
Content-Security-Policy: frame-ancestors 'self'andX-Frame-Options: SAMEORIGIN, so another site cannot load them in an iframe and trick a click (clickjacking). To allow a partner to embed it, set your ownframe-ancestors(orX-Frame-Options) in/_headers; then Hypamoon sets neither. A site without sign-in stays embeddable as before.auth.sensitiveandauth.retention_months(not available yet). Not switched on yet, and not for ChatGPT. When it is on,sensitive: trueis for health, money, children or HR: sign-ins last 7 days and the code gets no email address.retention_months(1 to 120) is how long someone may be inactive before they get an email to sign in and keep their account; after 30 more days without a sign-in their account and data in the app are deleted. Default 24, or 12 for a sensitive app; your ownretention_monthsalways wins. The last admin of an app (the first role) never gets that email and is never deleted: of the admins that are not blocked, the most recently active one always stays. Blocked people and buyers with a valid entitlement are skipped too.Passkeys (not available yet). Not switched on yet: when it is on, the sign-in page also shows "Sign in with a passkey", and after the first sign-in by link people are offered to add one (fingerprint, face or device PIN). A passkey works on the address where it was made. Nothing to build in the app.
What the code sees. Hypamoon checks the sign-in before a request reaches the code and hands the person over ready to use in
env.HYPAMOON:env.HYPAMOON.useris{ id, email, name, roles, entitlements, groups, is(role), has(entitlement) }, ornullwhen nobody is signed in.env.HYPAMOON.requireUser(request)returns that user, or a ready401JSON response.env.HYPAMOON.requireRole(request, "admin")(or a list of roles) returns the user, or a ready401or403JSON response.await env.HYPAMOON.users.list()(orlist({ role: "member" })) gives the people of the app, andawait env.HYPAMOON.users.get(id)one person ornull, each{ id, email, name, role, created_at, last_sign_in_at }. Only for a signed-in admin (the first role inroles); otherwise it throws an error with.status401 or 403. Read users this way rather than with your ownfetchto/app-users: it is usually answered next to the app, without a round trip to the API. Inviting, changing a role and removing someone still go through/app-users(see the People screen).await env.HYPAMOON.fetchPage(url)fetches one public web page for the signed-in user, from any site, without listing it inallowed_hosts(for example to import a recipe). It is off until you deploy the app withfetch_pages: true(which needsauth). It returns{ url, status, contentType, body, truncated }, withurlthe final address after redirects. OnlyGETover http or https, only text, HTML or JSON (also JSON-LD), at most 2 MB (truncated: truewhen cut off), 10 seconds and 3 redirects; private addresses and Hypamoon hosts are blocked, also after a redirect, and no cookies orx-hypamoon-*headers are sent. Each user can fetch 20 pages per hour, and fetches count against the app's outbound budget. A refusal throws an error with.status: 401 not signed in, 403 blocked address, 415 not text, 429 limit reached, 501 off for this app, 502 or 504 the page failed or was too slow. The app log shows only the host and the duration.await env.HYPAMOON.sendEmail({ to, subject, text })sends one email from the app's server code (also fromscheduled(), no signed-in user needed). Optional:cc,bcc,html,attachments,reply_to,category(transactionalfor a reply to something the recipients did, otherwise an unsubscribe link with the project's postal address is added, which needs a business address in the company details, and the email goes to one recipient),idempotency_key(the same key never sends twice) andfrom.tois one address or a list. An attachment'scontentmay be aUint8Array, like the PDF fromrenderPdf:attachments: [{ filename: "invoice.pdf", content: pdf }]. It follows exactly the rules ofsend_emailfor the app's project: the Mail module must be on, the sender isnoreply@on the project's verified sending domain unlessfromis an address on that domain, and every recipient must be known to the project (a user of the app, a form submission, a document, a chat conversation) or confirmed by the owner. The daily and per minute limits and the content check apply, and every recipient counts for the Mail module.await env.HYPAMOON.sendEmailBatch([{ to, subject, text }, ...])sends 1 to 100 separate emails in one call (each person sees only their own; no attachments), all or nothing; a refusal also has.index. With a test key nothing goes out (status: "simulated"). It returns the email ({ id, status, ... }); a refusal throws an error with.status,.code(the error type, such asmail_not_enabled,email_domain_not_verified,recipient_not_allowed,email_limit_reached),.userMessage(a sentence to show the person) and sometimes.nextStep. The app cannot confirm a recipient itself; that stays with the owner. An existing app getssendEmailwith its nextdeploy_app.await env.HYPAMOON.getEmail(id)reads back an email this app sent, with theidthatsendEmailorsendEmailBatchreturned (ornull):status(queued,sent,delivered,bounced,complained,failed,simulated),reason(plain words, like "The mailbox is full."),recipient_status(one entry per recipient, so with several people one can bounce while the others are delivered) andtimeline(each step with its time: queued, sent, delivered, delayed, bounced, complained).await env.HYPAMOON.listEmails({ to, status, since, limit, cursor })lists this app's emails, newest first, as{ data, next_cursor }. An app only ever sees its own emails, never those of another app or of the API. To react to a bounce, subscribe a webhook toemail.bounced(see Webhooks for email below). An existing app getsgetEmailandlistEmailswith its nextdeploy_app.const pdf = await env.HYPAMOON.renderPdf(html, { format: "a4" })turns one HTML document with its CSS into a PDF (render_pdf) and returns it as aUint8Array:return new Response(pdf, { headers: { "content-type": "application/pdf" } }). Options:format(a4, a5, a6, letter, legal),landscape,margin({ top: "12mm", ... }),max_pages(at most 20) andfilename. Files of the app's own site load by path (<img src="/logo.svg">, a<link>to a stylesheet, fonts), and the site's colors and fonts are ready as--hm-*CSS variables; anything else does not load and JavaScript does not run. At most 2 MB of HTML, 10 seconds, 120 PDFs an hour per project. Nothing is stored. A refusal throws an error with.status,.codeand.userMessage. An existing app getsrenderPdfwith its nextdeploy_app.Example, a timesheet app that mails on submit and on approval:
Node.js// POST /api/hours/:id/submit, by the employee const user = env.HYPAMOON.requireUser(request); if (user instanceof Response) return user; // ... mark the row as submitted ... await env.HYPAMOON.sendEmail({ to: env.MANAGER_EMAIL, // in vars; the manager is a user of the app subject: `Hours submitted: ${user.name} (week ${week})`, text: `${user.name} submitted the hours for week ${week}. Review them at ${env.HYPAMOON_SITE_URL}/review`, idempotency_key: `submitted-${id}`, }); // POST /api/hours/:id/approve, by an admin const admin = env.HYPAMOON.requireRole(request, "admin"); if (admin instanceof Response) return admin; // ... mark the row as approved, read row.email and row.week ... try { await env.HYPAMOON.sendEmail({ to: row.email, subject: `Your hours for week ${row.week} are approved`, text: `${admin.name} approved your hours for week ${row.week}.`, category: "transactional", idempotency_key: `approved-${row.id}`, }); } catch (e) { // the approval stands; tell the admin why the email did not go out return Response.json({ approved: true, email_sent: false, message: e.userMessage ?? e.message }); }
Use it as
const user = env.HYPAMOON.requireUser(request); if (user instanceof Response) return user;. Never check the signature yourself and never useHYPAMOON_AUTH_SECRETin the code: that is how apps broke (a check without the right prefix turns every signed-in person into a 401). The request still carries the signed headersx-hypamoon-userandx-hypamoon-user-sig, only to forward them unchanged to the Hypamoon API (/app-files,/app-users). A header without a valid signature never reaches the code, and a request from another site that could change something arrives without a user (no cross-site requests in someone's name). The first role inrolesis the app's admin role everywhere (the People screen, files of other users). A session lasts 90 days after the last use and at most a year after signing in.Its own database.
database: truegives the app a SQLite database asenv.DB, only its own.apply_app_migrationcreates and changes the tables: DDL and DML only, noATTACH,PRAGMA,VACUUM, triggers or transactions. The database counts toward the data of the plan; above it the app can only read and delete until there is room.Private data per user.
set_table_owner_columnmarks a table as per-user, with the column that holds the owner (user_idby default, thesubof the signed-in person). From then on Hypamoon itself keeps each person's rows apart: the code usesenv.DB.mine("items")withselect({ where, orderBy, limit, offset, columns }),get(where),count(where),insert(row),update(where, patch)anddelete(where), always for the signed-in person (the owner is filled in and cannot change), and plain SQL that names the table is refused, even inside a string (pass values as parameters). Migrations may still add an index or a column and change the table's own rows, but no view or copy that reads around it.admin_role(optional) lets people with that role pass{ all: true }; without it nobody sees other people's rows, admins included. Tables shared by everyone keep plainenv.DB.prepare. An app with per-user tables gets its bindings only through theenvoffetch(no global import ofenv). Plan this from the first table: personal data goes in tables with an owner column (one row per record), never in one JSON row for the whole app. Only per-user tables are covered byexport_app_useranddelete_app_user_data;deploy_appandcheck_readinesswarn when an app with sign-in and a database has none ("export and delete per user won't cover your data"), andcheck_readinesslists tables that look like a backup or copy (backup,bak,copy,legacy,old,_v1, a date) with the question whether they can go. Drop a copy after an import once the user agrees, instead of leaving it behind.Uploads. The code asks
POST env.HYPAMOON_API + "/app-files"(name, type, exact size) and hands theupload_urlto the page, which PUTs the file there once. Forward thex-hypamoon-userandx-hypamoon-user-sigheaders of the request on that call to make the file the person's own: it is stored in their own folder,GET env.HYPAMOON_API + "/app-files/<id>"with the same headers gives a download link for 15 minutes that only opens for them (or the admin role, the first ofroles), and the file goes along with export and deletion. Images, PDF, audio, video, plain text, CSV and JSON; the bytes must match the type, PDFs and text go through the abuse check, and files count toward the files of the plan.A person asks for their data, or to be forgotten.
export_app_userreturns everything of one person as JSON: their account, role and passkeys, every row in the per-user tables and every file with a link for 15 minutes.delete_app_user_datadeletes all of that for good, after the owner confirms.remove_app_userasks whether the data should go too (delete_data). Both listunmarked_tables: tables that are not per-user, so Hypamoon cannot tell which rows there belong to the person. When the app has no per-user table at all,app_tablesisnot_includedand the answer says so: no row of the app's own database was exported or deleted.Looking at the app as another user. Hypamoon has no "view as" or impersonation: an admin signs in as themselves. If the app's own code lets an admin act or look as someone else (a header or a switch in the app), write every start of that to the app's own audit table, also when nothing is changed, with who, as whom and when; ask for a reason before acting for someone (approving, signing); and show a clear banner while it is on.
Retries. A webhook from another service can go to
https://<site>/_hypamoon/queue/api/<path>: Hypamoon answers 202 at once and deliversPOST /api/<path>to the app, again after 30 s, 1, 2, 4 and 8 minutes while it fails (x-hypamoon-attemptsays which try). Scheduled runs retry the same way. Every attempt, retry and give-up is inget_app_logs.Four-eyes approval. With Team and four-eyes approval on, a live
deploy_apporrollback_appwaits for another owner or builder (approve_deploy), like a site.
| tool | scope | what it does |
|---|---|---|
list_app_users |
sites:read |
Who may sign in to the app, with role and last sign-in. Email addresses are masked (s***@example.com); reveal: true shows them in full and is logged. |
list_app_mails |
sites:read |
The sign-in links, codes, invitations and account emails the app sent, with their delivery status (queued, sent, delivered, bounced, complained, failed) and the reason. Recipients masked; never the link or the code. For "Check your email, but nothing arrived". |
invite_app_user |
sites:write |
Lets someone sign in with a role and emails them a sign-in link (send_email: false for no email). |
import_app_users |
sites:write |
Makes the people in a collection (default wp_users, from the Hypamoon Connect plugin) users of the app, with role_map from WordPress roles to app roles. No passwords: people sign in with a link or code by email. Silent by default; send_email: true invites each new person within the invitation limits (the rest comes back as remaining), invite_existing also invites people added earlier who never signed in. Idempotent on the WordPress id and the email address; the app user id is written into each document (hypamoon_app_users). dry_run shows the plan. |
set_app_user_role |
sites:write |
Gives a user another of the app's roles; the app sees it within seconds. |
remove_app_user |
sites:write |
Stops someone from signing in; open sessions end within seconds. delete_data true also deletes their data. |
export_app_user |
sites:write |
Everything of one person as JSON, with 15-minute links to their files. |
delete_app_user_data |
sites:write |
Deletes everything of one person for good (confirm: true after the owner agrees). |
list_app_files |
sites:read |
The files the users uploaded through the app, with the total size. |
delete_app_file |
sites:write |
Deletes one of those files for good. |
apply_app_migration |
sites:write |
Runs new SQL migrations on the app's database, in order, each all or nothing. |
set_table_owner_column |
sites:write |
Makes a table private per user (or shared again with remove: true). |
create_app_from_template |
sites:write |
A working app in one call (template accounts-data-uploads): a new site with sign-in, one table private per user with the fields you choose (text, long_text, integer, number, boolean, date, list), uploads per row (photos including HEIC, voice memos, video including MOV, PDF) and a share link per row that opens without signing in. It runs deploy_app, apply_app_migration and set_table_owner_column for you; extend it from there. Not in ChatGPT. |
get_app_code |
sites:read |
The server code of an app: main_module and every module of the running version, or of a kept earlier one with version; module for one module. Up to about 200,000 characters per answer; a module that does not fit comes back truncated. Not in ChatGPT. |
get_app_schema |
sites:read |
The tables of the app's database with columns, indexes, number of rows and whether they are per user, the views, the applied migrations and the name for the next migration. Not in ChatGPT. |
query_app_db |
sites:read |
One read-only SELECT on the app's database, at most 50 rows; email addresses and phone numbers masked, every query in the activity log. Not in ChatGPT. |
create_app_group |
sites:write |
Creates a group (team) in an app with auth.groups: true: a key the code checks with env.HYPAMOON.user.inGroup(key), a name, and optionally a role and an entitlement everyone in it gets too (never the admin role). Not in ChatGPT. |
add_app_group_member |
sites:write |
Puts a user of the app in a group; the app sees it within seconds. Not in ChatGPT. |
list_app_groups |
sites:read |
The groups of an app with their role, entitlement and members (email addresses masked). Not in ChatGPT. |
Rolling out from October 2026, so not every project has these yet:
- The admin page in the app.
/_hypamoon/adminon the site is a ready page for the app's first role: members with last active, invite, change roles, block and unblock, approve or reject requests, and invite links with a maximum number of uses and an end date that can be revoked. The owner sees the same on the Users page of the portal. - More ways to join. Besides
inviteandopen,auth.signupcan beapproval(anyone asks to join, also as a waitlist; an admin approves) ordomainwithallowed_domains. A shared invite link (/_hypamoon/auth/join?t=...) works with every sign-up mode. - Buyers as users. With
auth.buyers: true, someone who buys paid access to the site (create_access_productwithentitlement) becomes a user of the app with that entitlement:env.HYPAMOON.user.has("pro")orenv.HYPAMOON.requireEntitlement(request, "pro"). A refund or cancellation takes it away. They sign in once, with the app's sign-in.
The ChatGPT app is in review at OpenAI: until it is approved, ChatGPT does not
get export_app_user, delete_app_user_data or set_table_owner_column, and
remove_app_user there works as before.
A small app with accounts and private data per user: a wardrobe. Deploy it
with auth: { signup: "invite", roles: ["admin", "member"] } and
database: true, apply 0001_init.sql, mark items with
set_table_owner_column (column: "user_id", admin_role: "admin"), then
invite people.
CREATE TABLE items (id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, photo_file_id TEXT, created_at TEXT NOT NULL);
CREATE INDEX items_user ON items (user_id, created_at);// Forward the signed user to the Hypamoon API, so a file belongs to that user.
const asUser = (request) => ({
"content-type": "application/json",
"x-hypamoon-user": request.headers.get("x-hypamoon-user"),
"x-hypamoon-user-sig": request.headers.get("x-hypamoon-user-sig"),
});
export default {
async fetch(request, env) {
const url = new URL(request.url);
// Hypamoon checked the sign-in already: never verify the signature yourself.
const user = env.HYPAMOON.requireUser(request);
if (user instanceof Response) return user; // 401 JSON, nobody signed in
const items = env.DB.mine("items"); // only this user's rows, always
if (url.pathname === "/api/items" && request.method === "GET") {
return Response.json(await items.select({ orderBy: "created_at desc", limit: 200 }));
}
if (url.pathname === "/api/items" && request.method === "POST") {
const { name, photo_file_id } = await request.json();
const row = await items.insert({ id: crypto.randomUUID(), name, photo_file_id: photo_file_id || null, created_at: new Date().toISOString() });
return Response.json(row, { status: 201 });
}
if (url.pathname === "/api/upload-url" && request.method === "POST") {
const { name, content_type, size } = await request.json();
const r = await fetch(env.HYPAMOON_API + "/app-files", { method: "POST", headers: asUser(request), body: JSON.stringify({ name, content_type, size }) });
return new Response(r.body, { status: r.status, headers: { "content-type": "application/json" } });
}
if (url.pathname === "/api/admin/items" && request.method === "GET") {
const admin = env.HYPAMOON.requireRole(request, "admin");
if (admin instanceof Response) return admin; // 403 JSON for everyone else
return Response.json(await env.DB.mine("items", { all: true }).select({ orderBy: "created_at desc" }));
}
return Response.json({ error: "Not found" }, { status: 404 });
},
};A staging for an app (not available yet)
For an app with real users or data, a staging is a second copy to work on
without anyone noticing: its own code, its own database and its own address,
https://<slug>--staging.hypamoon.app. Every function there is on test
(payments, email to other people, phone and SMS are simulated), and search
engines never index it (noindex, nofollow, Disallow: /). For an app with
sign-in, the staging is only for its own signed-in users unless you create it
with protect: false; its users are separate from the app's.
| tool | scope | what it does |
|---|---|---|
create_staging |
sites:write |
Creates the staging of an app (app_id) with the app's running version. Its database gets the app's schema plus your seed (INSERT statements, staging only); the app's rows only with copy_data: true, when the owner explicitly asks (at most 20,000 rows). Calling it again returns the existing staging. |
promote_staging |
sites:write |
Puts the staging on the app: first the migrations that ran only on the staging, then its running code with its settings. Data never moves back. Needs a key in the app's mode; with four-eyes approval on, the code waits for a colleague. |
delete_staging |
sites:write |
Deletes the staging: code, database, users, files and key. The app does not change. |
New code goes to the staging with deploy_app and staging: true (with the
app's site_id). The staging's id from create_staging is the app_id for
apply_app_migration, get_app_logs and invite_app_user on the staging.
Schedules are kept with the staging's versions but only run on the app.
REST: POST, GET and DELETE /api/v1/apps/{id}/staging,
POST /api/v1/apps/{id}/staging/deploy (the body of POST /api/v1/apps
without site_id) and POST /api/v1/apps/{id}/staging/promote. Not in ChatGPT
while the app is in review.
Email from your own domain
An extra module: the owner turns it on in the portal (Mail). Test keys can try
everything, and a test email is never sent (status simulated).
- A sending domain is a domain the project already has (
connect_domainorregister_domain). If its DNS is hosted here,add_email_domainadds the records itself: an existing SPF record is merged instead of a second one, the user's own mail (MX) is never touched and nothing is deleted, so their mailbox keeps working. If the DNS is hosted elsewhere,recordslists what to add there. A DMARC record is only suggested, never added. Thenverify_email_domainuntil the status isverified. - Who gets email. Only people the project already knows: an address in a
form submission, a document, a chat conversation or a user of one of the
project's apps. Anyone else only after the owner confirms the address
(
allow_email_recipient, in a chat where the owner is signed in, or in the portal). Never a bought, scraped or imported list. - One email, several people works like Resend:
tois one address or a list, with optionalccandbcclists, at most 50 recipients together. Everyone intoandccsees the others, as in any email. When people must not see each other, usebcc, orsend_email_batch: 1 to 100 separate emails in one call, each with its ownto(a personal invitation per person). A batch is all or nothing: if one email is refused, none goes out and the error names it (index). No attachments in a batch. - Attachments. At most 10 files and 10 MB together, each a
filenamewith one ofcontent(base64),media_id(a file from Media) orpath(an https address on one of the project's own sites; nothing else is fetched). PDF, PNG, JPG, GIF and WebP, plus ICS, TXT and CSV as UTF-8 text. The type is checked on the file's first bytes; executables, HTML, SVG, archives and PDFs with scripts are refused (attachment_not_allowed,attachment_too_large). Inlinecontentcounts toward the request size (keep it under about 4 MB). Only the name, type and size of an attachment are kept, never the file. - Limits. Every recipient counts: a daily limit per project, 200 recipients and 20 calls per minute (a batch is one call), and each recipient is one email for the Mail module's bundle.
- New recipients from documents. People who only appear in documents the
app wrote are capped per day, each address counted once (also across cc, bcc
and a batch): 25 to start, 100 after 200 sent emails, 500 after 1,000 sent
emails and 14 days, as long as the last 200 emails stay under 2% bounces and
without spam complaints. A verified business or a paid plan gets there sooner
(50 emails for 100; 300 emails and 7 days for 500). A pause or a spam
complaint in the last week sets it back to 25. App users who signed in,
buyers with a paid order and addresses the owner confirmed never count.
list_emailsshows the current limit, what is left and what raises it (mail.new_recipients). Every email passes a content check (email_blockedsays why). Sending pauses automatically above 5% bounces or 0.1% spam complaints over the last 200 emails. - Status and timeline. Every email has a
status, a shortreasonwhen it bounced, was marked as spam, is delayed or was not sent (no provider jargon),recipient_statusper recipient and atimelinewith times.app_idsays which app sent it.list_emailsfilters onapp_id,to,statusandsince; the portal (Mail) shows the same, with a filter per app, recipient, status and period. - Webhooks for email.
create_webhooktakesemail.sent,email.delivered,email.delayed,email.bouncedandemail.complained. Each comes once per recipient, signed like every event, withdata:email_id,app_id,recipient,recipient_field,status,reason,occurred_atandtest. Never the subject or the content. Passapp_idto get only the email events of one app (an endpoint withapp_idtakes only email events). With a test keyemail.sentandemail.deliveredstill arrive (test: true), so the receiver can be tested. - Unsubscribing.
categoryother(the default) adds an unsubscribe link and aList-Unsubscribeheader, is not sent to people who unsubscribed or marked an email as spam, and goes to exactly one recipient (each person gets their own unsubscribe link). Under the unsubscribe link goes the project's postal address from its company details (set_company_details), as the US CAN-SPAM Act asks of marketing email. Without a business address,otheris refused with 422company_details_missingand auser_messageasking the user to add it in Settings, Company.transactionalis for a reply to something the recipients did, needs no address, and may go to several people. Under CAN-SPAM and the GDPR the user is the sender of their email; Hypamoon delivers it.
| tool | scope | what it does |
|---|---|---|
send_email |
messages:write |
One email from a verified sending domain to people the project knows: to (one or a list), cc, bcc, attachments. Returns the email with status sent (test: simulated). Safe to retry. In ChatGPT one recipient and no attachments while the app is in review. |
send_email_batch |
messages:write |
1 to 100 separate emails in one call, each with its own to, all or nothing, no attachments. Returns data with each email. Safe to retry. Not in ChatGPT while the app is in review. |
list_emails |
messages:read |
The project's emails, newest first, with status, reason, recipient_status, timeline, app_id, recipients (to, cc, bcc, recipient_count) and attachments (name, type, size), plus whether Mail is on or paused and the daily limit. Filters: app_id, to, status, since. |
get_email |
messages:read |
One email: queued, sent, delivered, bounced, complained, failed or simulated, with reason, the status per recipient and the timeline. |
list_email_domains |
sites:read |
The sending domains with status and DNS records. |
add_email_domain |
sites:write |
Makes a domain of the project a sending domain and sets or lists its DNS records. |
get_email_domain |
sites:read |
One sending domain with its records and the last check. |
verify_email_domain |
sites:write |
Checks the records now: pending, verified or failed. |
allow_email_recipient |
messages:write |
The owner confirms an address that is not in the project. Only with the owner signed in; an API key cannot. |
check_email_address |
messages:read |
Checks 1 to 50 addresses before sending: format, the project's suppression list, likely typos (gmial.com), throwaway domains, and whether the domain accepts email. result is valid, risky, invalid or unknown, with reason, suggestion and would_send. Sends nothing. Not in ChatGPT while the app is in review. |
get_email_deliverability |
messages:read |
Per sending domain over 7 and 30 days: sent, delivered, bounced, complained and the rates against the thresholds (2% bounces, 0.1% complaints), health and advice in plain words, SPF, DKIM and DMARC, and dedicated_ip with its price. Opens are not measured. Not in ChatGPT while the app is in review. |
get_email_bimi |
sites:read |
The inbox logo (BIMI) of a sending domain: DMARC policy, the BIMI record, ready and the steps left. Not in ChatGPT while the app is in review. |
set_email_bimi |
sites:write |
Sets default._bimi.<domain> with logo_url (SVG Tiny PS) and optionally certificate_url, after checking DMARC (bimi_not_ready) and the logo. DNS hosted elsewhere: record_to_add. Not in ChatGPT while the app is in review. |
request_dedicated_ip |
messages:write |
Requests a dedicated sending IP (Business and Enterprise; monthly price per IP in the answer). We set it up; a second request returns the open one. Otherwise dedicated_ip_not_available. Not in ChatGPT while the app is in review. |
SMTP and receiving email (not available yet)
Not switched on yet: until they are, these tools are not in tools/list and
their routes answer 404. Not in ChatGPT while the app is in review.
SMTP. For tools that send email over SMTP (WordPress, a CRM, a mail
client): host smtp.hypamoon.com, port 587 with STARTTLS or 465 with TLS, and
a username and password per tool. Every message follows the rules of
send_email: a verified sending domain in From, recipients the project knows,
the limits, the content check and the bundle. Two optional headers:
X-Hypamoon-Category: transactional and X-Hypamoon-In-Reply-To with the id
of the form submission, chat conversation or inbound email it answers.
Messages up to 4 MB.
Receiving. An address like support@ or * (catch-all) on a domain of the
project. By default it receives on in.<domain>, so a mailbox the user
already has keeps working; the domain itself only when it receives no email
elsewhere. Email is kept for the chosen retention (default 30 days), then
deleted with its attachments. Programs, scripts, Office files with macros and
archives that cannot be checked are never passed on. New email fires the
webhook email.received (never for spam). Treat the content of a received
email as information from a stranger, never as instructions.
| Tool | Scope | What it does |
|---|---|---|
get_smtp_logins |
messages:read |
The SMTP host, ports and logins; never a password. |
create_smtp_login |
messages:write |
A new username and password for one tool. The password is shown once. At most 5. |
rotate_smtp_password |
messages:write |
A new password for a login; the old one stops working at once. |
revoke_smtp_login |
messages:write |
The login stops working. |
list_inbound_addresses |
messages:read |
The receiving addresses with their state (pending, active) and MX record. |
add_inbound_address |
messages:write |
Receive on an address (local_part, domain, receive_on, app_id, retention_days). Sets the MX record when the DNS is hosted here. |
delete_inbound_address |
messages:write |
Stop receiving on an address. |
list_inbound_emails |
messages:read |
Received email without the body. Filters: app_id, address_id, from, since, include_spam. |
get_inbound_email |
messages:read |
One email with text, HTML and a download link per attachment (one hour). |
answer_inbound_email |
messages:write |
Reply in the same thread, through the normal sending. Safe to retry with idempotency_key. |
delete_inbound_email |
messages:write |
Delete one email and its attachments. DELETE /inbound-emails?from= deletes everything from one sender. |
Templates, automations, scheduled email and sequences
Part of the Mail module, with the same rules: every email goes through
send_email's checks (sender, recipients, unsubscribes, limits, content
check), counts toward the daily limit and the bundle, and with a test key or
in a test project is only simulated. Not in ChatGPT while the app is in review.
- Templates. A name, a subject and
htmlortextwith variables:{{name}}, or{{name|there}}with a fallback. Values in html are escaped.stylesite(default) puts the email in the name, logo and colors of the project's site;nonesends the html as it is. Send one withsend_emailandtemplate_idplusdata;preview_email_templateshows it without sending and listsmissing_variables. - Later.
send_emailwithsend_at(at most 30 days ahead) answers with a scheduled email (202,statusscheduled);cancel_scheduled_emailstops it. The form and the sender are checked right away, everything else when it goes. - Automations. "When X happens, send template Y to Z":
form.submitted(a form on one of the project's sites;filter.collectionfor one form),booking.created(a submission in thebookingscollection, or the one infilter.collection),order.paid,order.payment_failed(failed, expired or canceled at checkout) andapp_user.created(filter.app_id).toiscontact(the person of the event, default) or a fixed address that appears in the project.delay_minuteswaits first. The variables are the form fields, ororder_id,order_number,totalandcurrency, plusemailandname. Each event sends at most once per automation, also when it is reported twice. An abandoned cart is not an event: the cart lives in the visitor's browser until checkout; a checkout that is not paid isorder.payment_failed. - Sequences. 1 to 20 templates, each with
wait_minutesbefore it. People join withadd_to_email_sequence(only someone the project knows) or through an automation withsequence_id. A sequence stops for a person when they unsubscribe (always), when an event instop_onhappens for their address (likeorder.paid), or withremove_from_email_sequence(for example when they replied). - The queue.
list_scheduled_emailsshows everything that goes later or went, withsource(api,automation,sequence,list,confirmation),statusand, once sent,email_id. A limit per minute or per day moves an email to later instead of dropping it.
| tool | scope | what it does |
|---|---|---|
create_email_template |
messages:write |
Saves a template: name, subject, html or text with variables, style. |
list_email_templates |
messages:read |
The templates with their variables. |
update_email_template |
messages:write |
Changes only the fields you pass. |
delete_email_template |
messages:write |
Deletes a template; refused while an automation, sequence or list uses it. |
preview_email_template |
messages:read |
The template filled with data, without sending: subject, html, text, missing_variables. |
create_email_automation |
messages:write |
When an event happens, send a template or start a sequence, with delay_minutes. |
list_email_automations |
messages:read |
The automations. |
update_email_automation |
messages:write |
Changes an automation, like enabled: false. |
delete_email_automation |
messages:write |
Deletes an automation; what it already scheduled still goes. |
list_scheduled_emails |
messages:read |
Emails that go later or went, with status and source. |
cancel_scheduled_email |
messages:write |
Stops an email that is still scheduled. |
create_email_sequence |
messages:write |
Saves a sequence of templates with waits and stop_on. |
list_email_sequences |
messages:read |
The sequences, with how many people are in each. |
delete_email_sequence |
messages:write |
Deletes a sequence; everyone in it stops. |
add_to_email_sequence |
messages:write |
Adds one person the project knows to a sequence. |
remove_from_email_sequence |
messages:write |
Stops a sequence for one address. |
Lists and newsletters (not available yet)
Not switched on yet: until it is, these tools are not in tools/list and
their routes answer 404.
- Double opt-in, always.
add_email_list_contactsends a confirmation email; the address is on the list only after the person clicks the link. A list can also take sign-ups from a form (form_collection). There is no way to add confirmed addresses, so a bought or imported list can never be sent to. The confirmation email counts toward the cap on new recipients a day; a confirmed contact does not. - Sending.
send_to_email_listsends one email per confirmed contact as categoryother: an unsubscribe link and the business's postal address (refused without one). People who unsubscribed or complained get nothing. It goes at 15 emails a minute per project and counts toward the daily limit; what does not fit today goes tomorrow. Unsubscribing uses the project's unsubscribe list, the same as every other email.
| tool | scope | what it does |
|---|---|---|
create_email_list |
messages:write |
A list with a sender and, optionally, a form that signs people up. |
list_email_lists |
messages:read |
The lists with pending, confirmed and unsubscribed counts. |
delete_email_list |
messages:write |
Deletes a list and its addresses. |
add_email_list_contact |
messages:write |
Sends the confirmation email to one address that asked to be on the list. |
list_email_list_contacts |
messages:read |
The addresses on a list with their status. |
remove_email_list_contact |
messages:write |
Removes one address from a list. |
send_to_email_list |
messages:write |
One email per confirmed contact, at a fixed pace. Safe to retry. |
DMARC reports: who sends mail as your domains (not available yet)
Not switched on yet: until it is, these tools are not in tools/list and
their routes answer 404.
Mailbox providers (Gmail, Outlook, Yahoo and others) send a daily DMARC report
to the address in the rua of a domain's DMARC record: how much mail claiming
to be from the domain they received, from which servers, and whether it passed
DKIM and SPF. Every project gets its own report address on our side, so these
reports turn into a readable overview. Included with Mail at no extra cost,
and also for a connected domain without the Mail module (insight only).
- Turning it on.
enable_dmarc_reportsadds the project's report address to the domain's DMARC record. Without a DMARC record (DNS hosted here) it addsv=DMARC1; p=nonewith the address: this only observes, delivery does not change. An existing record keeps its policy and its other report addresses; changing a live record first returnsconfirmation_requiredwith apreflight, and only goes through withconfirm: trueafter the user agreed (undo_dns_changereverses it for 30 days). With DNS elsewhere,recordis what the user sets there (state: add_yourself). Thedmarc_suggestionofadd_email_domainalready includes the address. - Reading it.
get_dmarc_reportshows per domain the messages, the share that passed DMARC and the policy; the biggest senders (Google, Microsoft 365, Hypamoon Mail and other known senders by name, otherwise the server's domain or IP); what fails, with the DKIM and SPF results; and concreteadvice, like turning on DKIM at a sender or moving top=quarantine. - Alerts. Possible spoofing (mail from a sender that never passed for the
domain fails, from 5 messages) and a broken setup (a sender that used to
pass now mostly fails) send one email to the project and the webhook event
email.dmarc_alert, at most once a week per unknown sender and once a day per known sender. - Kept. Counts per day, domain and sender for 400 days; the full report for 30 days. Deleted with the project.
| tool | scope | what it does |
|---|---|---|
get_dmarc_report |
sites:read |
Per domain: messages, pass_rate, policy and whether reports are on; top senders, failing, advice and alerts. days 1 to 90, optional domain. Not in ChatGPT while the app is in review. |
enable_dmarc_reports |
sites:write |
Adds the report address to the domain's DMARC record, or returns the record to set elsewhere. Asks first before changing a live record (confirm). Not in ChatGPT while the app is in review. |
The brain: how the business works (not available yet)
Not switched on yet: until it is, these tools are not in tools/list and
their routes answer 404.
The brain is one place per project for how the business actually works: its processes, the steps in them, the decision rules people apply every day, the exceptions, the words only insiders use, and who decides what. It is not a place to dump documents. The receptionist and the chat read what the owner confirmed; agents and the user's own Claude or ChatGPT read it through these tools.
- Capturing.
interview_processreturns a short interview guide: ask one question at a time, write the answers down withsave_process(always a draft), show the rules back in plain words, and callconfirm_processonly after the owner says yes. Every change is a new version; old versions stay readable withget_processandversion. - Rules. A rule has a name, a condition (
when), an outcome (then) and a risk:low,moneyorlegal. A condition is a small JSON shape, never code:{"field": "party_size", "equals": 8},{"field": "day", "in": ["friday", "saturday"]},{"field": "party_size", "min": 7, "max": 12},{"field": "allergy", "present": true}, combined with{"all": [...]}or{"any": [...]}, at most 4 levels deep and 20 comparisons. A rule withoutwhenis the default for every other case. The outcome is a flat object of short values. - Applying.
resolve_ruletakes a process and a situation and returns what applies. Each rule decides the keys of its own outcome, so "closed on Mondays" and "book groups up to 8 directly" live side by side; for each key the most specific matching rule wins, and a default rule only fills in what nothing more specific says.needs_inputlists the fields to ask for;ambiguousandno_rulecome with a suggested open question. Every money or legal rule comes back withaction_modeapprove: tell the customer what it says, never promise or carry it out without a person approving. Rules show when they were last confirmed, andneeds_reconfirmationafter 180 days. - Learning. When the receptionist, the chat or an agent doesn't know
how the business handles something, it becomes an open question
(
add_open_question; the same question again counts once more). The owner answers it once withanswer_open_question(or in the portal under Brain): as a rule, a term or a plain answer the assistants use from then on.
| tool | scope | what it does |
|---|---|---|
interview_process |
data:write |
The interview guide for capturing a process in the user's own chat. Changes nothing itself. |
list_processes |
data:read |
The processes, with the active version and a newer draft. |
get_process |
data:read |
One process with steps, rules (also as plain sentences), exceptions, the fields its rules use and all versions. |
save_process |
data:write |
A process with steps, rules, exceptions and roles in one go, as a draft. With process_id a new version. |
confirm_process |
data:write |
Makes the newest draft the active version. Only after the owner said yes. |
resolve_rule |
data:read |
Finds the rule that applies in a situation, with outcome, risk, action_mode and last confirmed date. |
define_term |
data:write |
A word the business uses and what it means, or a new version of it. |
lookup_term |
data:read |
What a word means here, matched on the term and its synonyms. |
add_open_question |
data:write |
A question nobody could answer yet. No duplicates; at most 50 new a day. |
list_open_questions |
data:read |
The questions still open (or answered, dismissed, all). |
answer_open_question |
data:write |
Turns an answer into a rule (a new draft, or confirmed with confirm when the owner said so), a term or a plain answer. |
Example: capturing table reservations
A run of the flow on a test project (6 October 2026), for a fictional restaurant ("Bistro Fenna"). The owner talks in her own AI chat with Hypamoon connected; the tool calls are shown in brackets, and the data they wrote is what the tools returned below.
What the tools returned in that run:
save_processstored version 1 as a draft with seven rules ("Closed on Mondays", "Open other days", "Small groups", "Large groups", the two last-seating rules and "Dogs"), the exception for regulars and the role Floor manager;fieldswasday,party_size,dog.confirm_processmade it active.resolve_rulewith{"party_size": 10, "day": "saturday"}:matched, outcomebook_directly: false,deposit_per_person_eur: 10,last_seating: "21:00",open: true, decided by "Large groups", "Last seating on Friday and Saturday" and "Open other days";riskmoney, soaction_modeapprove, andmissing_fields["dog"]because the dog rule could still add something.- With
{"party_size": 4}:needs_inputforday, because "Closed on Mondays" could still changeopen. With{"party_size": 4, "day": "monday"}:open: false. - A caller asked whether a group can bring their own birthday cake. No rule
covered it, so the assistant said it would check with the team and
add_open_questionrecorded it. The owner answered later, in her chat:
That made version 2 active. resolve_rule with {"party_size": 10, "day": "friday", "own_cake": true} now returns "Own cake" first, with
own_cake_allowed: true on top of the group rules, and still
action_mode approve: the assistant tells the caller about the fee but
never agrees to it on its own.
Confirm in the portal
When a chat connected through OAuth (Claude, ChatGPT, Grok or another chat app)
asks for something that cannot be undone, the tool does not do it. It sets the
action up and answers with one link, https://app.hypamoon.com/confirm/<id>. The
user opens it, reads in plain words what happens (for example "Delete the site
bouwbot-demo.hypamoon.app. It goes offline right away. For 30 days you can
restore it under Sites & apps, Recently deleted; after that its files are erased
for good.") and presses Confirm, with the passkey step the portal already asks
for critical actions. Only then does Hypamoon run the same tool, through the
same route as the API. Cancel changes nothing. Today shows what is still
waiting, and /confirm lists it.
- The tools:
delete_site,delete_collection,delete_app,delete_secret,delete_webhook,delete_agent,release_number,remove_member,remove_app_user,delete_app_user_data,revoke_access_pass,disconnect_domain,invite_member,update_member_role,set_member_access,allow_email_recipient,set_dns_record,import_zone_file,clean_up_dns,switch_domain_nameservers,connect_domain,approve_deployandmove_to_live. Three only in some cases:create_webhookfor a host the project does not use yet,set_call_planwhen a new outside number is added, andapprove_agent_actionwhen it approves (a reject runs directly, likereject_deploy).delete_documentis not on the list: one document per call, and confirming each one would make cleaning up test entries unworkable. - In Claude Code,
register_domainandtransfer_domainalso wait for the owner, with OAuth and with an API key (then the person who created the key confirms). Nothing is bought until then; if no confirmation can be set up, the tool refuses.refund_orderthere moves no money: it answers with the link ofget_refund_link, where the owner checks the amount and refunds. - Otherwise only chat connections. An API key (a script, a customer's app, an MCP client with a key) keeps working directly, as before; four-eyes approval still applies where it already did.
- The answer has
confirm_url,id,status(pending) and auser_messageto pass on. Asking the same thing again gives the same link. The link works for 24 hours, only for the person who asked, and only in the project it belongs to. get_confirmationwith theidtells the chat whether it is done. Not in ChatGPT while the app is in review.set_dns_recordthat adds or changes a record on a domain that is stillpending(the nameservers do not point to Hypamoon yet, also according to public DNS) runs directly: nothing is live there yet. Deleting a record, and everything on an active domain, still waits for a confirmation.import_zone_filefollows the same rule: directly on such a pending domain, and a confirmation on an active domain or withreplace_existing(then records are deleted).- Several at once: when the same chat connection sets up more than one
confirmation shortly after each other (at most 30 minutes between two), the
answer also has
group_confirm_urlandpending_in_group. That one page lists them all, checked, with one Confirm all and one second step; each action still runs and is logged on its own.get_confirmationreturns the same fields. Not in ChatGPT while the app is in review. - With four-eyes approval on, a confirmed
delete_sitethen waits for a second owner or builder, as always. - An agency working in a client project (the
projectargument) still runs directly, except a domain purchase or transfer from Claude Code, which is refused there.
Approve a goal at once
Behind HYPAMOON_PLAN_BEVESTIGEN (with HYPAMOON_BEVESTIG_IN_PORTAAL), off by
default. Design: docs/goedkeuren-per-doel.md.
propose_planwithgoalanddomain(andsite_idfor a site) sets up one confirmation for the whole goal. The portal shows it as one card: the goal, what happens in plain words, the cost, and the technical steps under Details.- After Confirm (with the same passkey step), the chat calls the usual tools.
For 24 hours a step that fits the plan runs without a new link: the same user,
project and chat connection, the domain of the plan, the kinds of records of
the plan, and at most a fixed number per kind. Each step is logged
(
api_plan_stappenand the activity log). - Always its own link: deleting or overwriting a record (unless the site plan
says it replaces what is on the address), a zone import that deletes records,
skipping the nameserver check, switching back to the previous nameservers,
another domain,
clean_up_dns,disconnect_domain, and any cost the plan did not mention (like the cname surcharge in a nameservers plan). - A plan is always its own card; it is never part of a "Confirm all" group.
Team
A project has people with a role: owner (everything), builder (build and deploy, keys and webhooks, approve deploys), editor (change content, not structure), viewer (read only) and billing (only the balance and invoices). An API key or a connected app acts with the role of the person who created it, and stops working when that person leaves the project.
An editor works on content only. Their chat sees the read tools and five tools
that write: insert_document, update_document and delete_document in an
existing collection, deploy_site on an existing site with keep_existing: true,
and create_upload_urls for images. On a site an editor changes html, htm, md,
txt and json files that are already there, and adds or replaces images (png,
jpg, jpeg, gif, webp, avif, ico). Scripts, event handlers, forms, embeds and
stylesheet links in a page stay exactly as they are. An editor never creates or
deletes a site or a page, never touches js, css, svg, _redirects, _headers or
a manifest, never changes the name, password, address or access of a site, and
never creates or changes collections, apps, domains, mail, payments, phone
numbers, agents, keys, webhooks, secrets, the team or verification. Anything
else answers "Editors can change content, not structure. Ask a builder or the
owner." A collection with personal data (the personal_data flag, orders and
buyers) stays closed to an editor, also for reading, until an owner turns on
personal data for that editor under Team. Products and prices affect payments:
an editor reads the product collection (role: "products", with its variants)
and the discount codes like a viewer, but adds, changes or deletes a product, a
price, a variant, stock or a code only after an owner turns on "Products and
prices" for that editor under Team. Without it the answer starts with "Products
and prices affect payments." An editor never issues or redeems a gift card and
never writes in the gift card collection, with or without these settings. With four-eyes approval on, a live
change by an editor waits like any other deploy; an owner can also hold only
the changes of editors ("Editor changes need approval").
list_members: the people and their roles, and the open invitations.invite_member: emails an invitation with a role. The agent can never give a role above the role of the user it acts for; a builder only invites editors and viewers. In ChatGPT the role list in the tool shows the four roles from before the editor (the app is in review); the tool acceptseditorthere too. Inviting, changing roles and removing members work with a live key or connection only.update_member_roleandremove_member: owner only. Removing someone also revokes their keys and connected apps for the project.set_member_access(not in ChatGPT): limits a member to some sites of the project, owner only.accessis{ "sites": [...], "apps": [...], "collections": [...] }with ids (a site may also be given by slug or name), ornullfor the whole project. Each site brings its app. Withoutcollectionsthe tool fills in the collections the sites post to (a form fromget_form_snippet, or a call to/public/collections/<name>/) and returns them assuggested_collections.invite_memberandupdate_member_roletake the same optionalaccessoutside ChatGPT. Owners are never limited. A limited member sees only those sites, apps and collections inlist_sites,list_collections,list_appsand the portal, and none of the project-wide tools (new sites or collections, domains, email, payments and orders, phone, team, keys, webhooks, agents, secrets, agencies, go-live). Anything outside answers "You have access to: Newme2day site. Ask the owner for more." Their keys and connected apps follow on the next request, also after a change. The rules of their role still apply on top: a limited editor only changes content.list_pending_approvals,approve_deployandreject_deploy: with four-eyes approval on (an owner turns it on in the portal under Team), a livedeploy_siteanswers with a pending approval instead of a live site, and the live site keeps its previous version until another owner or builder approves it.delete_sitewaits the same way, and the site stays online until then. The other owners and builders get an email. Nobody approves their own deploy, not even one their agent made. Test projects never wait.list_activity: read-only log of who did what, in the portal or through an agent, and through which app. Refused calls are in it too (mcp_aanroep_geweigerd): a tool that does not exist for that key or connection, a missing scope or role, or another 401 or 403, with the tool, the reason, the status and the app. Never the arguments or a token.get_recent_errors: read-only list of the failed tool calls and API requests of the project from the last 14 days, newest first: when, which app (chatgpt, claude, grok, oauth, mcp or api), the tool or route, the error type, the status, the detail that was returned, how long it took and the request id. Every error message ends withRequest id: req_...; pass it asrequest_idto find that one error. Arguments are only summarized (field names, types, lengths and counts), never their values; email addresses, phone numbers, keys and URL paths in the detail are masked. At most 1000 errors per project per day are kept (dropped_todaycounts the rest). Not in ChatGPT while that app is in review. Same asGET /v1/errors.
One chat, more projects
Not live yet (behind HYPAMOON_MULTI_PROJECT, off). When you connect a chat
with OAuth, a second step after Allow asks which projects it can use: This
project (the default, as before), Selected projects, or All my projects, also
new ones. In every project the chat can do only what your role there allows;
if you leave a project, the chat loses it too. You change the choice later in
the portal under Connect your chat. Not available in ChatGPT, and not for API
keys: a key always belongs to one project.
list_projects: the projects this connection can use, with the role, the mode (test or live), the default project and the project you connected first.use_project: makes a project the default for this connection. Tools withoutproject_idwork there from then on, also in later messages.project_idon every other tool: do this one call in that project.
Every answer of a tool that changes something ends with the project it
happened in. On the REST API, an OAuth token of such a connection takes the
Hypamoon-Project header with a project id.
Agencies and client projects
For an agency that builds sites and apps for clients. The agency keeps its own Hypamoon project and, next to it, one project per client. A client project belongs to the client: you can build it before the client has an account, and the client takes it over later. From then on the client decides what your agency may do and can revoke your access at any time.
How it goes:
create_client_projectstarts a project for a new client. You build it in test, from your own chat or code, with theprojectargument or theHypamoon-Projectheader.invite_clientemails the client a link. The client signs in, sees what your agency can do and who pays, and accepts. The project is now theirs and your agency keeps working on it through the relation.- A client who already has a project gets a link from
request_client_accessinstead, and accepts in the portal under Agencies. - You see all clients in your own portal under Clients (app.hypamoon.com/clients): what is being built, what waits for the client, usage and open payments. The client sees your agency under Agencies, with a button to revoke.
In the REST API these are the routes under /clients (the agency side) and
/agencies (the client side), in the Agencies section of the
API reference. Over MCP they are in the agency toolset.
They need the scopes clients:read and clients:write on the agency's own key
or connection. Not available in ChatGPT.
list_client_projects: your client projects, with status (pending or active), stage (building: you set it up and the client has not taken it over; client_owned) and the limit the client set (test, or live).create_client_project: a new project for a client in the building stage. Your agency is its temporary owner through the relation, it stays in test with Free limits, and the one-project-per-owner limit of signing up does not apply.invite_client: emails the client a link to take the project over, from Hypamoon's own mail. The client then sees what your agency can do, who pays and how to revoke, and accepts.request_client_access: for a client who already has a project. Returnsconfirm_url; give it to the client, who accepts or declines in the portal under Agencies. Nothing is emailed.revoke_client_relation: stop working on a client project. What you built stays. The client can revoke your access at any time, in the portal or withDELETE /agencies/{id}.get_client_usage: cost per month (by source and by payer: the client's own credit or your agency), the app meters, the current payer and your budget, to invoice with your own markup or to show in your own client portal.get_client_activity: the client's activity log; what your agency did carriesvia_bureauwith your agency's name.
Who pays. By default the client pays usage from its own credit. Your agency
can offer to pay: payer: "agency" on create_client_project or
request_client_access, or later:
set_client_payer: propose who pays (client or agency), or agree to what the client proposed. It only changes when both sides agree; the client answers in the portal under Agencies, andpayer_changeshows a proposal that waits. When your agency pays, calls, texts, numbers, AI and other metered use of the client project go on your agency's balance, with the client project as reference. Plan, domains and team stay with the client.set_client_budget: the most your agency pays for one client project per month (euros excluding VAT), or null. When it is reached, new paid usage there gets a 402billing_blockedand your agency gets one email that month.export_client_invoice_lines: the usage as invoice lines per month, payer and source, as JSON or CSV, for your own accounting. Hypamoon sends no invoice.
Getting paid by your client. On your agency's own payment account (connect
it on your agency project first with connect_payments). The money goes to your
agency; Hypamoon keeps only the transaction fee you accepted.
create_payment_link: one payment. Returnscheckout_url; give it to your client yourself.create_subscription: monthly or yearly. The first payment throughcheckout_urlsets up the mandate. Not every payment account supports this yet; a 409 says so.list_client_paymentsandcancel_client_subscription, andcancel_client_payment_linkto withdraw an open link. These also work after your relation with the client ended, so you can stop what still runs.
Handing over and cleaning up.
transfer_project: hand over a project in the building stage. Withto_emailthe client gets an email and becomes the owner after accepting;agency_keeps_accesssays whether your agency keeps working on it or stops. Withto_agency_project_idanother agency gets an email and takes over after accepting in its portal (Clients); your access ends then. Nothing changes until the receiving side accepts. A project the client already owns cannot be handed over: the client chooses its agencies. The answer listsopen_payments.cancel_project_transfer: withdraw an open owner invitation or handover.restore_client_project: bring back a project in the building stage that your agency ended and nobody took over. Such a project has no owner: Hypamoon emails your agency, moves it to the trash after 30 days and deletes it 30 days later; until then you can restore it.
When a relation ends or a project is handed over, your agency stops paying for
it (the client's owner gets an email that they pay again), its budget and any
open payer proposal go away, and open_payments lists your payment links and
subscriptions for that client, which keep running on your own account until you
stop them.
Both carry the client project in the metadata. Subscribe a webhook
(create_webhook) to client_payment.paid, client_payment.failed,
client_subscription.active, client_subscription.payment_paid,
client_subscription.payment_failed or client_subscription.canceled; the
event never contains the payer's email or name.
Working inside a client project. Pass project (the client's project id)
to any other tool, or send the header Hypamoon-Project: <id> on the REST API.
Hypamoon checks the active relation on every call. Your agency works there as a
builder: no team, no payment details, no buying or transferring domains, no
moving to live and no deleting whole sites, apps, collections, domains or
numbers. The client's four-eyes approval applies to your deploys (the client
can also turn it on for your agency alone), and you only work live if the
client ticked that box. A viewer of your agency only reads.
Every tool calls the same REST route as the API reference describes, so the
checks, the errors and the results are the same. A failed call comes back as a
tool result with isError: true and a text like:
Hypamoon API error 422 company_details_missing: Set the company details (for a personal account: your own contact details) first with PUT /api/v1/project/company. Missing: contact.name, contact.phone.
How to fix: https://hypamoon.com/docs/errors/company_details_missing
Request id: req_...The agent reads the explanation behind the link and fixes the cause.
A session, step by step
What a good agent does with a test key (the server tells it this in its instructions):
get_project: modetest, andnext_stepssays what is missing.set_company_detailswith values it asked you for: a business, or your own details withaccount_typepersonal. You do not need a company.search_numbers, thenorder_numberwith one of the results.set_assistantwith instructions it asked you for.set_call_plan: your phone first, then the assistant.get_integration_code, then it builds and deploys the webhook receiver in your app.create_webhookwith the receiver's URL; you store the secret asHYPAMOON_WEBHOOK_SECRET. Thensend_test_webhook.simulate_call, and it checks that your app stored the call;get_callshows the transcript and the summary.
Then you go live. There is no switch: ask for the real thing and it happens
once you are verified for it. A tool that still needs something answers
verification_required with missing, next_step and verify_url, a link to
exactly that step in the portal; nothing was done, and after you finish it the
agent calls the same tool again. What the portal asks, at https://app.hypamoon.com:
- Verification: you are verified for what you use. A business: the company level unlocks the live project, live keys and national numbers. A personal account: the personal level (your name, your address, a payment method and a cap; no registration number) unlocks live keys, and the identity documents (proof of address and an ID) unlock a number with a local area code, inbound calls and SMS. A national number needs a business. Numbers with a local area code need the number documents, outbound calls and a cap above 500 EUR per month need the full level.
- Balance (https://app.hypamoon.com/tegoed): top up the prepaid balance
(from 10 EUR, by card or bank payment), or save a card so it tops up automatically.
Every live SMS, call minute and number is deducted from it; when it is too
low, a request gets
402 insufficient_balance.get_balanceshows it. - Spending cap (Billing): set it; the Overview shows costs against it.
- A key or a connection that is limited to test stays simulated. With a key: create a live key (API keys) and swap the test key for it, in the connector and in your app's secrets. A normal chat connection needs nothing extra.
Voice AI is always included, on the live project too; there is nothing to switch on. Then repeat steps 2 to 7.
When something goes wrong
Every failed tool call and every API error carries user_message: one calm
sentence in plain English that the agent can pass on to the user as it is
(the tool text says "User message (plain words you can pass on to the user as
is)"). Many also carry next_step for the agent itself.
- Is Hypamoon down?
get_status(any connection;GET /api/v1/statuswithout a key) says per component (sites, API, portal, phone, AI receptionist, email, payments) whether it works and whether there is a reported problem, from https://status.hypamoon.com. Itsuser_messageis, for example, "Hypamoon is working normally. The problem is likely in your chat app." Call it before telling the user that Hypamoon is down or blocked. - The upload is blocked.
create_upload_urlssays in its answer (hint) what to do when the PUT toupload_urlfails: send images under ~3 MB as base64 indeploy_sitecontent instead, or create a drop link (create_drop_link, thenget_drop). A deploy that names a file whose upload link never received anything getsupload_not_receivedwith the same advice. - Known problems. Every
get_adviceanswer hasknown_problems: an upload blocked by a sandbox, an image too large,version_conflict,phone_verification_required, a site nobody else can see (unlisted or with a password) and "is Hypamoon down", each with what to do and what to tell the user.GET /api/v1/advice?topic=<id>returns one.
create_drop_link, get_drop and get_status are not in ChatGPT while the app
is in review; ChatGPT does get user_message in its errors, without the drop
link.
Rules
- Every answer says what to do next. A tool result ends with
Next:and a failed call carriesnext_step(the tool that fixes it), except in ChatGPT while the app is in review. - The API key is a server-side secret. It belongs in the connector settings and in your app's secrets, never in browser code, a committed file or the chat.
- A key or password for another service never goes in the chat. The agent
asks for it with
request_secretand you paste it in the portal. start_callcalls a real person with a live key. The agent tells you who it will call, on which number and why, and waits for your yes before every live call. Outbound calls need the full verification level.- Every outbound call has a
purpose, recorded with the call:transactional(the default: the person's own question, an appointment confirmation or reminder, an order status, a callback they asked for) ormarketing(anything that sells or promotes). In the Netherlands a marketing call needs the person's prior consent, since 1 July 2026 also for existing customers, somarketingonly calls when consent is recorded withrecord_call_consent; otherwiseconsent_required. ChatGPT does not see the field while the app is in review; its calls are transactional. - A public chat token (
hm_pub_..., fromcreate_chat_widget) is not a key: it is meant for the page source of the website and can only chat from the origins you allowed. It never replaces the API key in server code. - Every request counts against the rate limit of the key (or, with OAuth, of the connected app); a tool call counts twice (the MCP request and the API route behind it).