# Using ammo.host

Your coding agent does the work. This page shows you how to connect it and what it can do.

## Get started

1. Paste this line into your AI coding agent.

   Put this project online with ammo.host. Read https://app.ammo.host/llms.txt and follow it.
2. Your agent asks for your email.
3. Open the email from ammo.host. Check that its code matches the code your agent shows you, then click Approve.
4. Your agent deploys the project and gives you the link.

ammo.host isn't open for new accounts yet. If you already have an account, the email still arrives.

For advanced setups you can [sign in](https://app.ammo.host/login), create an API key on [Keys](https://app.ammo.host/keys/), and give it to your agent yourself.

Your first app starts a 14-day trial. The trial allows up to 2 live apps. When it ends, your apps stop until you pick a plan. Nothing is deleted.

## What you can put online

You can put a static site online, a Node or Bun app, a Python app, or anything else with a Dockerfile. A Node or Bun app includes Next.js, Vite, Astro, SvelteKit, Express, and Hono. A Python app includes Flask, FastAPI, Django, Streamlit, and Gradio. A custom Dockerfile is available on every plan, including the trial.

If you need a value in the browser bundle, such as a Supabase or Firebase URL, set `NEXT_PUBLIC_`, `VITE_`, or `PUBLIC_` on the app before the build. We pass those names into the image build. If you add one after the app is already running, it's too late for the bundle. A visitor can read the value.

## Your data

A server app gets its own PostgreSQL database. We create it on the first deploy. A static site doesn't get one. Vite, a Next.js export, and a static Astro or SvelteKit build are static sites.

We back up your app's database every night and keep each backup for 7 days.

You can download the database from the app page after the first deploy. It's a copy of that app's database as a .sql.gz file. You can download your account, your apps, your deploys, and your billing status as a JSON file from Account.

If you delete the app, its database is removed with it. If you delete the account, your apps and their databases are removed straight away. Invoices stay for tax records. A nightly backup can still hold a copy of a database for up to 7 days. Download your account data, and each app's database, first.

Apps don't get file storage yet, so keep uploads in your database or an outside service.

## Forms

Add a contact or signup form to your site without extra backend setup.

Submit to `https://app.ammo.host/f/{app_slug}/{form_name}`. Any form name works without prior configuration. The owner receives an email for each submission.

### One deploy

The form address is known before the first build, so a site with a form goes online with one deploy.

Every build gets `AMMO_FORMS_URL`, set to `https://app.ammo.host/f/{app_slug}`, and `AMMO_APP_SLUG`. Astro and SvelteKit read them as `PUBLIC_AMMO_FORMS_URL`, Vite as `VITE_AMMO_FORMS_URL`, and Next.js as `NEXT_PUBLIC_AMMO_FORMS_URL`. Build the form action from them, for example `` `${import.meta.env.PUBLIC_AMMO_FORMS_URL}/contact` ``. We set these values. A value you pass with the same name is replaced.

A server app reads `AMMO_FORMS_URL` from its environment while it runs.

For plain HTML or Hugo, call `create_app` first. It creates the app without deploying it and returns `app_id`, `app_slug`, `url`, and `forms_url`. Put `forms_url` followed by `/contact` in the form action, then call `deploy_app` with that `app_id`. An app that is never deployed is removed after 24 hours.

### HTML snippet

Include a honeypot field and a redirect path:

```
<form action="https://app.ammo.host/f/{app_slug}/contact" method="POST">
  <input type="text" name="_gotcha" tabindex="-1" autocomplete="off"
         style="position:absolute;left:-9999px">
  <input type="hidden" name="_redirect" value="/thanks/">
  <label>Name <input type="text" name="name" required></label>
  <label>Email <input type="email" name="email" required></label>
  <label>Message <textarea name="message" required></textarea></label>
  <button type="submit">Send</button>
</form>
```

The `_gotcha` hidden field catches bots. Submissions with a value there are silently discarded. The `_redirect` field sends visitors to a thank-you page on your site. Without it, they land on `/?sent=1`.

### JSON submissions

POST with `Content-Type: application/json`. CORS is limited to the app's own domains.

### Webhooks

Forward submissions to a CRM or automation service. Call `set_form` with `app_id`, `name`, and `webhook_url`. The response includes a `webhook_secret`.

Each submission POSTs JSON to the webhook with header `X-Ammo-Signature: sha256={hmac_hex}`. Verify by computing HMAC-SHA256 of the raw body with the secret.

A network error, timeout or 5xx response is retried after 30 seconds, 2 minutes, 10 minutes and 30 minutes. The webhook fails after 5 attempts, or at once on any 4xx response.

### Reading submissions

Call `list_form_submissions` with `app_id`. Filter by `name`, paginate with `before_id`.

### Limits

* 50 fields per submission
* 5,000 characters per field value
* 32 KB request body

### Deletion

Forms are soft-deleted with the app. Call `delete_form` to remove a specific form and its submissions.

## Lock your app

Put a password in front of an app that shows personal data, a preview for a client, or anything not meant for the public. It works for every kind of app without code changes.

Visitors to the app's address and its custom domains see an ammo.host password page. After the right password their browser stays unlocked for 30 days by default, at most 365. Changing the password signs everyone out.

Lock it from the app page in the dashboard, or ask your agent. It calls `lock_app` with the password you give it. Without one, ammo.host generates a strong password and shows it once.

### Unlock links

`create_unlock_link` makes a link that opens the app without the password, for friends or a client. It can expire after up to 365 days, and an app can have 20 links. `revoke_unlock_links` stops all of them. `unlock_app` makes the app public again.

### What stays open

Certificate renewals keep working. Forms post to `https://app.ammo.host/f/...` on the platform, so they keep working too. Paths under `/.ammo-unlock` on the app's address belong to the lock.

## Limits

You have enough room for a real project. A cap keeps one upload from filling the server.

* You can upload 25 MB at a time.
* Once it's unpacked, it can be up to 100 MB and can include up to 2,000 files.
* Your agent can save small notes and files for you on ammo.host, separate from your app's database. Each note can be up to 0.25 MB, and each file can be up to 25 MB.
* On the trial you can keep 2 apps live. You can publish 10 times an hour and 30 times a day, and you can run 1 build at a time.

## Plans

Plans and prices are on [your pricing page](https://app.ammo.host/billing/) and at [ammo.host/pricing](https://ammo.host/pricing/).

## Tools

Your agent has tools to deploy, check an app's status, read logs, delete apps, and store data. You don't need to call them yourself.

## Details for your agent

This part is for your agent. It can also read the [plain text guide](https://app.ammo.host/llms.txt).

There's no anonymous access. Ask the person for their email and call sign_in_start, or POST it to https://app.ammo.host/api/agent-login/start. Show them the user_code, then poll sign_in_poll until they click Approve in the email. You get an API key. Send it as Authorization: Bearer <key> to https://app.ammo.host/mcp.

The name passed to deploy_app is only a label. The address is generated, like app-b99e304a.ammo.host. Call get_status with the app_id and wait until url_ready is true before opening the link. If https_state is certificate_delayed, stop and pass on the status_message. The app is running, the certificate is late, and nothing is needed. get_logs shows the build, or what the app printed while it ran.

A Python app listens on 0.0.0.0 and on PORT. We set PORT to 8000. Flask, FastAPI, Starlette, and Django use app.py, main.py, wsgi.py, asgi.py, or server.py, with app or application defined, plus requirements.txt or pyproject.toml. We start those with gunicorn. A file that imports http.server and reads PORT can be app.py, main.py, or server.py. We run that with python, and a dependency file is optional. A Procfile line web: <command> starts the app and has to listen on PORT. Streamlit is app.py, main.py, or streamlit_app.py importing streamlit, with a dependency file. Gradio is the same files importing gradio and calling launch() without a fixed port. Python needs one of those layouts. If something's missing, the error names it. For a static site, send index.html and no server code. A Node or Bun app needs package.json and a start script, or server.js, server.mjs, index.js, or app.js, and it listens on PORT. Bun is chosen when bun.lock or packageManager bun@ is present. Put a Dockerfile at the bundle root and we build it as given. A custom Dockerfile is available on every plan, including the trial.

A JavaScript app needs a package.json. Next.js without output export is a Node server on port 8000. output standalone runs node server.js and reads PORT. Otherwise we run next start on 0.0.0.0 and port 8000. Next.js with output export is a static site. Vite, Astro without a server adapter, and SvelteKit with adapter-static are static sites. Astro with the Node adapter in standalone mode, and SvelteKit with adapter-node, are Node servers on PORT. Express and Hono listen on 0.0.0.0 and PORT. Use a start script, or a file such as server.js or src/index.ts. bun.lock, bun.lockb, or packageManager bun@ selects Bun. SvelteKit reads its adapter from vite.config.js, as sveltekit({ adapter: adapter() }). Remove svelte.config.js, svelte.config.ts, or svelte.config.mjs. Set NEXT_PUBLIC_, VITE_, and PUBLIC_ in the app env before the build. They are passed into the image build so the browser bundle can include them. Setting them only on the running app is too late for that. A visitor can read them. SvelteKit 3 declares a public value in src/env.js or src/env.ts with public: true and static: true, and imports it from $app/env/public.

Python, Node, Bun, and Dockerfile apps receive DATABASE_URL on the first deploy. Next.js without output export, Express, Hono, Astro with the Node adapter, SvelteKit with adapter-node, Streamlit, and Gradio get one too. Vite, a Next.js export, and a static Astro or SvelteKit build do not. The database is removed when the app is deleted. On the Node and Bun images we build, node-postgres follows that URL. The helper is switched on after install. A custom Dockerfile gets the same URL, and node-postgres there checks the certificate unless you opt out. We don't ship a CA for that database. The signed-in owner can download the database from the app page, and an account file from Account.

One address can get 3 sign-in emails an hour and 10 a day. One network address can send 10 an hour and 30 a day. Shared file downloads from one network address are limited to 60 an hour and 1,024 MB a day.

A server app gets its own PostgreSQL database. You connect with DATABASE_URL. A static site doesn't get one. File storage isn't available yet, so you don't get a bucket, and get_status.files is null.

### [`deploy_app`](#tool-deploy_app)

Deploy a static site, a Node or Bun app, a Python app, or an app from your own Dockerfile. A custom Dockerfile is available on every plan, including the trial. name is a display label only, not the URL. The address is a generated host such as app-b99e304a.ammo.host. A Node or Bun app needs package.json. Next.js, Vite, Astro, SvelteKit, Express, and Hono are included. Next.js without output export is a Node server. Next.js with output export, Vite, Astro without a server adapter, and SvelteKit with adapter-static are static sites. Hugo sites (hugo.toml/yaml/json or config.toml with content/ and layouts/) are built with Hugo extended and served as static sites. NEXT_PUBLIC_, VITE_, and PUBLIC_ values are passed into the image build. Every build and every running app gets AMMO_APP_SLUG and AMMO_FORMS_URL (the form endpoint base, POST to AMMO_FORMS_URL/<form_name>), also as PUBLIC_AMMO_*, VITE_AMMO_*, and NEXT_PUBLIC_AMMO_* for the browser bundle. ammo.host sets them; a value you pass is replaced. Python, Node, and Bun listen on PORT, which ammo.host sets to 8000. Python: app.py, main.py, wsgi.py, asgi.py, or server.py with app or application, plus requirements.txt or pyproject.toml; Streamlit or Gradio in app.py, main.py, or streamlit_app.py; or http.server reading PORT; or a Procfile web: command. Node or Bun: package.json with a start script, or server.js, server.mjs, index.js, or app.js. Bun also accepts a TypeScript entry, bun.lock, or packageManager bun@. Static sites: _redirects file (Netlify-compatible subset) for URL redirects. Format: '/from /to [status]' per line, status 301/302/307/308, default 301. Supports trailing * splat and :name placeholders. Place at bundle root or in public/ (Astro/Vite/Next) or static/ (Hugo/SvelteKit). A 404.html file at the root is served with status 404 for missing pages. Non-static apps: _redirects is ignored; use the framework's own redirects. A server app gets its own PostgreSQL database. You connect with DATABASE_URL. A static site doesn't get one. File storage isn't available yet, so you don't get a bucket, and get_status.files is null. You need a verified account and an API key. To know the address before the first build, call create_app first and pass its app_id here. Returns app_id, app_slug, forms_url, url, status, state, message, plan, plan_note, and notes (warnings if any). state is one of: building, starting, unhealthy, dns_pending, certificate_pending, live, failed, stopped, deleted. message is a human-readable explanation of the state.

Exact inputs

```
{
  "oneOf": [
    {
      "required": [
        "files"
      ]
    },
    {
      "required": [
        "tarball_b64"
      ]
    },
    {
      "required": [
        "upload_id"
      ]
    }
  ],
  "properties": {
    "app_id": {
      "anyOf": [
        {
          "format": "uuid",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "App Id"
    },
    "commit_sha": {
      "anyOf": [
        {
          "pattern": "^[0-9a-fA-F]{40}$",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Source commit SHA for Cooking deploy events.",
      "title": "Commit Sha"
    },
    "docker_target": {
      "anyOf": [
        {
          "maxLength": 63,
          "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "For multi-stage Dockerfiles: the stage name to build and read EXPOSE from. The final stage is used by default. Pass the name from 'FROM ... AS name'. The stage must EXPOSE one TCP port. Persisted for redeploys.",
      "title": "Docker Target"
    },
    "dry_run": {
      "default": false,
      "description": "If true, validate the bundle and return detected settings without creating or modifying anything. No DB rows, no consumed allowances, upload_id stays usable. Same validation as a real deploy.",
      "title": "Dry Run",
      "type": "boolean"
    },
    "env": {
      "anyOf": [
        {
          "additionalProperties": {
            "type": "string"
          },
          "maxProperties": 50,
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Build-time environment variables. PUBLIC_*, VITE_*, and NEXT_PUBLIC_* are available at build time and bundled into static output. Omit to keep the current env on redeploys; pass {} to clear it.",
      "title": "Env"
    },
    "files": {
      "anyOf": [
        {
          "additionalProperties": {
            "type": "string"
          },
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Files"
    },
    "health_path": {
      "anyOf": [
        {
          "maxLength": 256,
          "pattern": "^/[A-Za-z0-9_./-]*$",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Path for health probes, must start with '/'. If not set and the bundle is a Dockerfile app with a HEALTHCHECK targeting localhost, the path is extracted from that URL. Otherwise defaults to '/'.",
      "title": "Health Path"
    },
    "idempotency_key": {
      "anyOf": [
        {
          "maxLength": 128,
          "minLength": 8,
          "pattern": "^[A-Za-z0-9_-]+$",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Client-generated unique key for idempotent deploys. Retry with the same key and bundle returns the original app. Keys are permanent per owner; reuse creates a new app only if the original was deleted. Different bundle with the same key returns an error. Recommended: UUID or request ID.",
      "title": "Idempotency Key"
    },
    "name": {
      "anyOf": [
        {
          "maxLength": 255,
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Display label only, not the URL. The host is generated, for example app-b99e304a.ammo.host.",
      "title": "Name"
    },
    "tarball_b64": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Tarball B64"
    },
    "template": {
      "default": "auto",
      "enum": [
        "auto",
        "static",
        "python",
        "node",
        "bun",
        "dockerfile"
      ],
      "title": "Template",
      "type": "string"
    },
    "upload_id": {
      "anyOf": [
        {
          "format": "uuid",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "ID from create_upload. Use for bundles too large for inline tarball_b64. Upload your gzipped tarball to the presigned URL from create_upload, then pass the upload_id here.",
      "title": "Upload Id"
    }
  },
  "title": "deploy_appArguments",
  "type": "object"
}
```

### [`create_upload`](#tool-create_upload)

Get a presigned POST form to upload a large bundle. Use when your bundle exceeds the inline tarball_b64 limit (25 MB compressed). Returns upload_url and upload_fields. POST with multipart/form-data: include all upload_fields, then add 'file' field with your .tar.gz. Example: curl -X POST upload_url -F key=... -F policy=... -F 'file=@bundle.tar.gz;type=application/gzip' (include all fields from upload_fields before the file field). Then call deploy_app with the upload_id. URL expires in 15 minutes. Single use. Max 3 pending uploads.

Exact inputs

```
{
  "properties": {},
  "title": "create_uploadArguments",
  "type": "object"
}
```

### [`validate_bundle`](#tool-validate_bundle)

Validate a bundle without deploying. Returns detected kind, template, framework, command, port, health_path (and source), docker_target, build_command, output_dir, ignore_stats, notes, and all validation errors. Does not create DB rows, consume allowances, or consume the upload_id. Run before deploy to check your bundle. Same validation as deploy_app(dry_run=true).

Exact inputs

```
{
  "oneOf": [
    {
      "required": [
        "files"
      ]
    },
    {
      "required": [
        "tarball_b64"
      ]
    },
    {
      "required": [
        "upload_id"
      ]
    }
  ],
  "properties": {
    "docker_target": {
      "anyOf": [
        {
          "maxLength": 63,
          "pattern": "^[A-Za-z][A-Za-z0-9_.-]*$",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Docker Target"
    },
    "env": {
      "anyOf": [
        {
          "additionalProperties": {
            "type": "string"
          },
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Env"
    },
    "files": {
      "anyOf": [
        {
          "additionalProperties": {
            "type": "string"
          },
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Files"
    },
    "health_path": {
      "anyOf": [
        {
          "maxLength": 256,
          "pattern": "^/[A-Za-z0-9_./-]*$",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Health Path"
    },
    "tarball_b64": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Tarball B64"
    },
    "template": {
      "default": "auto",
      "enum": [
        "auto",
        "static",
        "python",
        "node",
        "bun",
        "dockerfile"
      ],
      "title": "Template",
      "type": "string"
    },
    "upload_id": {
      "anyOf": [
        {
          "format": "uuid",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Upload Id"
    }
  },
  "title": "validate_bundleArguments",
  "type": "object"
}
```

### [`create_app`](#tool-create_app)

Create an app without deploying it, so its address is known before the first build. Use it for plain HTML forms or anything else that has to name the app in source. Returns app_id, app_slug, url, forms_url, status, plan, plan_note, and reserved_until. A form posts to forms_url/<form_name>. Then call deploy_app with this app_id; the first deploy can be any kind of app. The app counts toward your app limit right away. One that is never deployed is removed at reserved_until. You need a verified account and an API key.

Exact inputs

```
{
  "properties": {
    "idempotency_key": {
      "anyOf": [
        {
          "maxLength": 128,
          "minLength": 8,
          "pattern": "^[A-Za-z0-9_-]+$",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Client-generated unique key for idempotent app creation. Retry with the same key returns the original app. Keys are permanent per owner; reuse creates a new app only if the original was deleted.",
      "title": "Idempotency Key"
    },
    "name": {
      "anyOf": [
        {
          "maxLength": 255,
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Display label only, not the URL.",
      "title": "Name"
    }
  },
  "title": "create_appArguments",
  "type": "object"
}
```

### [`get_status`](#tool-get_status)

Read app status and its last deployment with a bounded wait. wait_seconds caps at 45. When wait_seconds > 0, the call returns early on any state change (waited and changed flags indicate what happened). Poll until url_ready is true before opening the URL. On a redeploy url_ready stays false until the new version is the one answering, not while the previous version still serves. state is one of: building, starting, unhealthy, dns_pending, certificate_pending, live, failed, stopped, deleted. message is a human-readable explanation of the state. waited is true if wait_seconds > 0 and the server waited. changed is true if the state changed during the wait. last_logs is a list of recent log lines when state is failed or unhealthy (redacted and deduplicated), empty otherwise. If https_state is certificate_delayed, stop polling and tell the user the status_message. The app is running; the HTTPS certificate is late and no action is needed. A later get_status sets url_ready true once the certificate is valid. files is null unless this app has a file bucket. When a bucket exists, files includes the bucket name, the byte count, the quota in bytes, and whether new uploads are stopped. Returns plan and plan_note (trial/free terms) with trial end date. custom_domains uses the same records and status_message as add_domain. For an unclaimed quick deploy pass its claim_token. expiry_warning is null, or has expires_at, expired and tell_user when the app's free time is nearly up; relay tell_user. lock.locked says whether the app is behind a password (lock_app); a locked app also has lock.state, lock.cookie_days and lock.active_links.

Exact inputs

```
{
  "properties": {
    "app_id": {
      "format": "uuid",
      "title": "App Id",
      "type": "string"
    },
    "wait_seconds": {
      "default": 0,
      "maximum": 45,
      "minimum": 0,
      "title": "Wait Seconds",
      "type": "integer"
    }
  },
  "required": [
    "app_id"
  ],
  "title": "get_statusArguments",
  "type": "object"
}
```

### [`get_logs`](#tool-get_logs)

Read an app's logs. kind is runtime (what the app prints) or build (what happened while we put it together). For an unclaimed quick deploy pass its claim_token.

Exact inputs

```
{
  "properties": {
    "app_id": {
      "format": "uuid",
      "title": "App Id",
      "type": "string"
    },
    "kind": {
      "default": "runtime",
      "enum": [
        "runtime",
        "build"
      ],
      "title": "Kind",
      "type": "string"
    },
    "lines": {
      "default": 100,
      "maximum": 500,
      "minimum": 1,
      "title": "Lines",
      "type": "integer"
    }
  },
  "required": [
    "app_id"
  ],
  "title": "get_logsArguments",
  "type": "object"
}
```

### [`delete_app`](#tool-delete_app)

Delete an app. Set confirm to true only after the user agrees. The link stops working. A server app's database is removed. Stored files are removed when the app has a bucket. For an unclaimed quick deploy pass its claim_token.

Exact inputs

```
{
  "properties": {
    "app_id": {
      "format": "uuid",
      "title": "App Id",
      "type": "string"
    },
    "confirm": {
      "const": true,
      "title": "Confirm",
      "type": "boolean"
    }
  },
  "required": [
    "app_id",
    "confirm"
  ],
  "title": "delete_appArguments",
  "type": "object"
}
```

### [`add_domain`](#tool-add_domain)

Add a custom domain to a paid app. Returns records and status_message. A subdomain needs a CNAME to the app's own ammo.host host. A root domain needs an A record and an AAAA record for the edge addresses, plus the TXT record in every case. Then call verify_domain. For www/apex canonical redirects, add both domains then call set_domain_redirect to make one redirect to the other.

Exact inputs

```
{
  "properties": {
    "app_id": {
      "format": "uuid",
      "title": "App Id",
      "type": "string"
    },
    "domain": {
      "pattern": "^[a-z0-9][a-z0-9.-]*[a-z0-9]$",
      "title": "Domain",
      "type": "string"
    }
  },
  "required": [
    "app_id",
    "domain"
  ],
  "title": "add_domainArguments",
  "type": "object"
}
```

### [`remove_domain`](#tool-remove_domain)

Remove a custom domain from an app. A verified name is disconnected on the running app without a new deploy.

Exact inputs

```
{
  "properties": {
    "app_id": {
      "format": "uuid",
      "title": "App Id",
      "type": "string"
    },
    "domain_id": {
      "format": "uuid",
      "title": "Domain Id",
      "type": "string"
    }
  },
  "required": [
    "app_id",
    "domain_id"
  ],
  "title": "remove_domainArguments",
  "type": "object"
}
```

### [`keep_app`](#tool-keep_app)

Keep a trial app on a paid slot. Requires an API key with billing scope (enable Keep apps on /keys/). Starter is €3 per app each month, including VAT. A free Starter slot is used before the quantity goes up, so calling this again does not charge twice. On Pro, a free included slot is used and Starter is not charged. If Pro has no free slot, nothing is charged and the app stays on the trial. If there is no card and no subscription, nothing is charged. Returns app_id, plan, already_paid, charged, quantity, and message.

Exact inputs

```
{
  "properties": {
    "app_id": {
      "format": "uuid",
      "title": "App Id",
      "type": "string"
    }
  },
  "required": [
    "app_id"
  ],
  "title": "keep_appArguments",
  "type": "object"
}
```

### [`verify_domain`](#tool-verify_domain)

Check the TXT record and that the CNAME, or the A and AAAA records, point at this app. When they do, connect the domain on the running app without a new deploy. Returns status_message.

Exact inputs

```
{
  "properties": {
    "app_id": {
      "format": "uuid",
      "title": "App Id",
      "type": "string"
    },
    "domain_id": {
      "format": "uuid",
      "title": "Domain Id",
      "type": "string"
    }
  },
  "required": [
    "app_id",
    "domain_id"
  ],
  "title": "verify_domainArguments",
  "type": "object"
}
```

### [`set_domain_redirect`](#tool-set_domain_redirect)

Set or clear a redirect for a custom domain (www/apex canonical). Both domains must be verified and on the same app. The source domain will permanently redirect (301) to the target domain, preserving the path and query string. To clear a redirect, pass null or empty string for redirect_to_domain_id. No redirect chains: a domain that already redirects cannot be a target. Example: add www.example.com and example.com, verify both, then call set_domain_redirect with www as source and apex as target.

Exact inputs

```
{
  "properties": {
    "app_id": {
      "format": "uuid",
      "title": "App Id",
      "type": "string"
    },
    "domain_id": {
      "format": "uuid",
      "title": "Domain Id",
      "type": "string"
    },
    "redirect_to_domain_id": {
      "anyOf": [
        {
          "format": "uuid",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Redirect To Domain Id"
    }
  },
  "required": [
    "app_id",
    "domain_id"
  ],
  "title": "set_domain_redirectArguments",
  "type": "object"
}
```

### [`deploy`](#tool-deploy)

Deploy a static site. Requires an API key. Provide exactly one of: html, files, zip_base64, or tarball_base64. Returns url, app_id, status.

Exact inputs

```
{
  "oneOf": [
    {
      "required": [
        "html"
      ]
    },
    {
      "required": [
        "files"
      ]
    },
    {
      "required": [
        "zip_base64"
      ]
    },
    {
      "required": [
        "tarball_base64"
      ]
    },
    {
      "required": [
        "tarball_b64"
      ]
    }
  ],
  "properties": {
    "env": {
      "anyOf": [
        {
          "additionalProperties": {
            "type": "string"
          },
          "maxProperties": 50,
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Environment variables. Only used with API key authentication.",
      "title": "Env"
    },
    "files": {
      "anyOf": [
        {
          "additionalProperties": {
            "anyOf": [
              {
                "type": "string"
              },
              {
                "additionalProperties": {
                  "type": "string"
                },
                "type": "object"
              }
            ]
          },
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "File map: path -> content. Content is either a UTF-8 string or {\"base64\": \"...\"}. Example: {\"index.html\": \"<html>...</html>\", \"logo.png\": {\"base64\": \"iVBOR...\"}}",
      "title": "Files"
    },
    "html": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Raw HTML string. Creates a single index.html file.",
      "title": "Html"
    },
    "name": {
      "anyOf": [
        {
          "maxLength": 255,
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "With an API key: a handle; the same name redeploys that app at the same URL. Without a key: a label only, and the URL is auto-generated.",
      "title": "Name"
    },
    "new_claim_link": {
      "default": false,
      "description": "If true and redeploying, issue a fresh claim link (invalidates the previous one).",
      "title": "New Claim Link",
      "type": "boolean"
    },
    "tarball_b64": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Another name for tarball_base64, as deploy_app spells it.",
      "title": "Tarball B64"
    },
    "tarball_base64": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Base64-encoded tar.gz archive.",
      "title": "Tarball Base64"
    },
    "template": {
      "anyOf": [
        {
          "enum": [
            "auto",
            "static",
            "python",
            "node",
            "bun",
            "dockerfile"
          ],
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Template type. Only used with API key authentication.",
      "title": "Template"
    },
    "zip_base64": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Base64-encoded ZIP archive. Single root folder is stripped.",
      "title": "Zip Base64"
    }
  },
  "title": "deployArguments",
  "type": "object"
}
```

### [`save`](#tool-save)

Save a JSON value in a namespace. You need a verified account.

Exact inputs

```
{
  "properties": {
    "collection": {
      "pattern": "^[a-z0-9_-]{1,64}$",
      "title": "Collection",
      "type": "string"
    },
    "expected_version": {
      "anyOf": [
        {
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Expected Version"
    },
    "key": {
      "maxLength": 256,
      "minLength": 1,
      "title": "Key",
      "type": "string"
    },
    "namespace_id": {
      "anyOf": [
        {
          "format": "uuid",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Namespace Id"
    },
    "value": {
      "title": "Value"
    }
  },
  "required": [
    "collection",
    "key",
    "value"
  ],
  "title": "saveArguments",
  "type": "object"
}
```

### [`get`](#tool-get)

Read one JSON value by its collection and key.

Exact inputs

```
{
  "properties": {
    "collection": {
      "pattern": "^[a-z0-9_-]{1,64}$",
      "title": "Collection",
      "type": "string"
    },
    "key": {
      "maxLength": 256,
      "minLength": 1,
      "title": "Key",
      "type": "string"
    },
    "namespace_id": {
      "anyOf": [
        {
          "format": "uuid",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Namespace Id"
    }
  },
  "required": [
    "collection",
    "key"
  ],
  "title": "getArguments",
  "type": "object"
}
```

### [`query`](#tool-query)

List JSON values in a collection. You can filter by content, match the start of a key, and page through the results with a cursor.

Exact inputs

```
{
  "properties": {
    "collection": {
      "pattern": "^[a-z0-9_-]{1,64}$",
      "title": "Collection",
      "type": "string"
    },
    "cursor": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Cursor"
    },
    "key_prefix": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Key Prefix"
    },
    "limit": {
      "default": 50,
      "maximum": 200,
      "minimum": 1,
      "title": "Limit",
      "type": "integer"
    },
    "namespace_id": {
      "anyOf": [
        {
          "format": "uuid",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Namespace Id"
    },
    "order_by": {
      "default": "key",
      "enum": [
        "key",
        "-key",
        "updated_at",
        "-updated_at"
      ],
      "title": "Order By",
      "type": "string"
    },
    "where": {
      "anyOf": [
        {
          "additionalProperties": true,
          "type": "object"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Where"
    }
  },
  "required": [
    "collection"
  ],
  "title": "queryArguments",
  "type": "object"
}
```

### [`save_file`](#tool-save_file)

Save a file from base64. Set share to true if you want a link someone can open without an API key.

Exact inputs

```
{
  "properties": {
    "collection": {
      "pattern": "^[a-z0-9_-]{1,64}$",
      "title": "Collection",
      "type": "string"
    },
    "content_b64": {
      "title": "Content B64",
      "type": "string"
    },
    "content_type": {
      "default": "application/octet-stream",
      "title": "Content Type",
      "type": "string"
    },
    "filename": {
      "maxLength": 255,
      "minLength": 1,
      "title": "Filename",
      "type": "string"
    },
    "key": {
      "maxLength": 256,
      "minLength": 1,
      "title": "Key",
      "type": "string"
    },
    "namespace_id": {
      "anyOf": [
        {
          "format": "uuid",
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Namespace Id"
    },
    "share": {
      "default": false,
      "title": "Share",
      "type": "boolean"
    }
  },
  "required": [
    "collection",
    "key",
    "filename",
    "content_b64"
  ],
  "title": "save_fileArguments",
  "type": "object"
}
```

### [`sign_in_start`](#tool-sign_in_start)

Start the agent sign-in flow. Ask the user for their email address, then call this tool. Returns device_code (keep secret), user_code (show to user), interval (poll wait seconds), and expires_in (seconds). The user receives an email with a link to approve. Show them the user_code and tell them to approve only if it matches. Then poll with sign_in_poll at the interval until you get a key or an error. While new accounts are closed the response includes a note; pass it on to the user.

Exact inputs

```
{
  "properties": {
    "email": {
      "description": "The user's email address.",
      "maxLength": 254,
      "title": "Email",
      "type": "string"
    }
  },
  "required": [
    "email"
  ],
  "title": "sign_in_startArguments",
  "type": "object"
}
```

### [`sign_in_poll`](#tool-sign_in_poll)

Poll for the result of an agent sign-in request. Call this at the interval returned by sign_in_start. Returns status: authorization_pending (keep polling), slow_down (wait longer), access_denied (user denied), expired_token (timed out or used), or success with api_key. Use the api_key as the Bearer token for this session, then deploy. Do not write the key to any file (config folders such as ~/.config, project files, scripts) unless the user asks for that. For later sessions, the user puts the key in the environment variable AMMO_HOST_API_KEY; read it from there. A pending response may include a note; pass it on to the user.

Exact inputs

```
{
  "properties": {
    "device_code": {
      "description": "The device_code from sign_in_start.",
      "title": "Device Code",
      "type": "string"
    }
  },
  "required": [
    "device_code"
  ],
  "title": "sign_in_pollArguments",
  "type": "object"
}
```

### [`set_form`](#tool-set_form)

Configure a form endpoint for your app. Forms accept submissions at POST {platform}/f/{app_slug}/{form_name}. create_app, deploy_app, and get_status return that base as forms_url, and every build gets it as AMMO_FORMS_URL, so one deploy is enough. Any form name works without calling set_form first - forms auto-create on first valid submission with email notifications enabled and no webhook. Use set_form only to: (1) set up a webhook for CRM/automation, or (2) turn off email notifications. Returns endpoint URL, HTML snippet with honeypot spam protection (the hidden _gotcha field), and a fetch() example for JSON submissions. The HTML snippet includes a honeypot field hidden with CSS (bots fill it, humans don't) and a _redirect hidden field. The _redirect value must be a path on the site that exists - create /thanks/ or change the value to the page that should show after sending. Browsers send only the origin as Referer on cross-site POST, so without _redirect the visitor lands on /?sent=1. CORS is limited to the app's own domains (default ammo.host subdomain plus verified custom domains). webhook_secret returned only when webhook is newly set or URL changed.

Exact inputs

```
{
  "properties": {
    "app_id": {
      "description": "The app's UUID",
      "format": "uuid",
      "title": "App Id",
      "type": "string"
    },
    "email_owner": {
      "anyOf": [
        {
          "type": "boolean"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Send email to app owner on each submission. Omit to leave unchanged on existing forms (default true for new).",
      "title": "Email Owner"
    },
    "name": {
      "description": "Form name: 1-40 lowercase letters, digits, or hyphens",
      "pattern": "^[a-z0-9][a-z0-9-]{0,39}$",
      "title": "Name",
      "type": "string"
    },
    "webhook_url": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "HTTPS URL to POST submissions to (Zapier, Make, GoHighLevel). Pass empty string to remove an existing webhook. Must be https and resolve to a public IP.",
      "title": "Webhook Url"
    }
  },
  "required": [
    "app_id",
    "name"
  ],
  "title": "set_formArguments",
  "type": "object"
}
```

### [`list_form_submissions`](#tool-list_form_submissions)

List form submissions for an app. Returns newest first with data, created_at, email_status, webhook_status, webhook_attempts and webhook_last_error (the last delivery error, up to 200 characters, secrets masked). A network error, timeout or 5xx from the webhook is retried after 30 s, 2 min, 10 min and 30 min; webhook_status stays pending until delivery and becomes failed only after the fifth attempt or on a non-5xx error response. Filter by form name or paginate with before_id. Maximum 100 submissions per request.

Exact inputs

```
{
  "properties": {
    "app_id": {
      "description": "The app's UUID",
      "format": "uuid",
      "title": "App Id",
      "type": "string"
    },
    "before_id": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Return submissions before this ID for pagination",
      "title": "Before Id"
    },
    "limit": {
      "default": 20,
      "description": "Number of submissions to return (default 20)",
      "maximum": 100,
      "minimum": 1,
      "title": "Limit",
      "type": "integer"
    },
    "name": {
      "anyOf": [
        {
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Filter by form name (omit to list all forms)",
      "title": "Name"
    }
  },
  "required": [
    "app_id"
  ],
  "title": "list_form_submissionsArguments",
  "type": "object"
}
```

### [`delete_form`](#tool-delete_form)

Delete a form and all its submissions. This is a soft delete - the data is marked as deleted but retained for a period before permanent removal.

Exact inputs

```
{
  "properties": {
    "app_id": {
      "description": "The app's UUID",
      "format": "uuid",
      "title": "App Id",
      "type": "string"
    },
    "name": {
      "description": "Name of the form to delete",
      "pattern": "^[a-z0-9][a-z0-9-]{0,39}$",
      "title": "Name",
      "type": "string"
    }
  },
  "required": [
    "app_id",
    "name"
  ],
  "title": "delete_formArguments",
  "type": "object"
}
```

### [`lock_app`](#tool-lock_app)

Lock an app behind a password. Visitors to the app's address and its custom domains see an ammo.host password page; after the right password their browser stays unlocked for cookie_days (default 30). Works for every app type without code changes. Suggest it for personal data, client previews, and anything not meant for the public. Use the password the owner gives you. Never make one up yourself: if the owner asks to lock the app without giving a password, omit password and ammo.host generates a strong one, returned once in password. Show it to the owner right away and tell them it won't be shown again. Calling lock_app on a locked app replaces the password (a new generated one if omitted) and signs out every visitor. state is active once the edge enforces the lock, or applying for up to a minute. Forms posting to the platform /f/ endpoint keep working.

Exact inputs

```
{
  "properties": {
    "app_id": {
      "format": "uuid",
      "title": "App Id",
      "type": "string"
    },
    "cookie_days": {
      "anyOf": [
        {
          "maximum": 365,
          "minimum": 1,
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "Days a browser stays unlocked after the password.",
      "title": "Cookie Days"
    },
    "password": {
      "anyOf": [
        {
          "maxLength": 128,
          "minLength": 1,
          "type": "string"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "description": "The password the owner chose. Omit it to have ammo.host generate one; never invent one.",
      "title": "Password"
    }
  },
  "required": [
    "app_id"
  ],
  "title": "lock_appArguments",
  "type": "object"
}
```

### [`unlock_app`](#tool-unlock_app)

Remove an app's password lock. The app is public again, and every unlock link stops working. Ask the owner before you call it.

Exact inputs

```
{
  "properties": {
    "app_id": {
      "format": "uuid",
      "title": "App Id",
      "type": "string"
    }
  },
  "required": [
    "app_id"
  ],
  "title": "unlock_appArguments",
  "type": "object"
}
```

### [`create_unlock_link`](#tool-create_unlock_link)

Create an unlock link for a locked app, to send to friends or a client. Opening it unlocks the app in that browser without the password. url is shown once; ammo.host keeps only a hash. custom_domain_urls hold the same link on the app's own domains. Omit expires_in_days for a link that works until revoke_unlock_links.

Exact inputs

```
{
  "properties": {
    "app_id": {
      "format": "uuid",
      "title": "App Id",
      "type": "string"
    },
    "expires_in_days": {
      "anyOf": [
        {
          "maximum": 365,
          "minimum": 1,
          "type": "integer"
        },
        {
          "type": "null"
        }
      ],
      "default": null,
      "title": "Expires In Days"
    }
  },
  "required": [
    "app_id"
  ],
  "title": "create_unlock_linkArguments",
  "type": "object"
}
```

### [`revoke_unlock_links`](#tool-revoke_unlock_links)

Revoke every unlock link of an app. Visitors who came in through a link need the password again. The password itself stays.

Exact inputs

```
{
  "properties": {
    "app_id": {
      "format": "uuid",
      "title": "App Id",
      "type": "string"
    }
  },
  "required": [
    "app_id"
  ],
  "title": "revoke_unlock_linksArguments",
  "type": "object"
}
```