Quickstart
Three ways to run the same software. Pick one, then make the first calls at the bottom of this page.
Hosted by us
Section titled “Hosted by us”We run the same software for you on our own servers, free. Pro, at $10, €10 or £9 a month, taxes included, is there if you want it. You set it up from your website’s check result, and it goes live at a hostname on your own domain, such as inbox.yourbusiness.com, once you add one DNS record. Your inbox and its backups are stored in the European Union. Get your agent inbox, or read the step-by-step guide first.
Deploy to Cloudflare
Section titled “Deploy to Cloudflare”Deploy to Cloudflare copies this repository to your GitHub or GitLab account and deploys it to your Cloudflare account. It creates a D1 database and a queue, and a cron runs every five minutes. The free plan runs it. Sending email needs Workers Paid.
Before you press it
Section titled “Before you press it”- A Cloudflare account.
- A secret key. Run
openssl rand -base64 32and keep the result somewhere safe. The database needs it. - For email, optional: a domain on Cloudflare, onboarded to Email Sending (dashboard → Email Service → Email Sending → Onboard Domain), on Workers Paid. Pick the address the inbox sends from, like
inbox@yourdomain.com. You can do this later.
What it asks
Section titled “What it asks”| Value | What to put |
|---|---|
INBOX_OWNER_EMAIL |
Your email address. Only this address can sign in the first time. |
INBOX_SECRET_KEY |
The key from above. It locks stored secrets and signs receipts. Empty, the inbox runs but stores no secret and issues no receipt. |
MAIL_FROM |
The address to send from. Leave it empty if email isn’t set up yet. |
Leave the build and deploy commands as they are. The build makes the owner app; the deploy is npx wrangler deploy.
After it deploys
Section titled “After it deploys”- Open
https://<worker>.<your-subdomain>.workers.dev/healthz. Expect{"ok":true,…}. Migrations run on the first request. - Open
/loginand enter your address. IfMAIL_FROMworks, an email comes with a link and a code: open the link and press Sign in, or type the code on the sign-in page. Each works once, for 15 minutes. WithoutMAIL_FROM, the email is in the Worker’s logs instead: open the Worker in the dashboard, go to Logs and search forsign-in email, or runnpx wrangler tail <worker>and ask again. If sending fails, for example because the domain is not onboarded yet, the logs say why and hold no link: onboard the domain, or deleteMAIL_FROMwhile you set up. If the log says your address is not inINBOX_OWNER_EMAIL, fix that value under Settings → Variables and Secrets. - The setup wizard opens. Your first sign-in saves the address you signed in at as the Inbox address (Settings). Links in emails use it, and receipts name it as their issuer. The wizard asks what you run (eight quick picks, hundreds of kinds to find by name in five languages, or your own words), who you are (name, time zone, your currency and any others you take, the languages you speak), what agents can do (book, order, ask for a price, suggest their own price, change what was agreed, write to you, and how customers pay you), what you offer, your hours, and the rules to start from. Products, a menu or rooms can be added a few now, brought from the shop, till or property system you already use, or added later; services are named in the wizard, and a calendar or booking tool you connect keeps their times in step. Ordering opens with your first product. Only the name is required; everything else can wait, and all of it is in Settings. Run again, the wizard starts from what Settings already say.
- Your own subdomain: Worker → Settings → Domains & Routes → Add → Custom domain. Then change the Inbox address in Settings, or add
INBOX_PUBLIC_URLas a secret under Settings → Variables and Secrets. - Email later: onboard the domain, then add
MAIL_FROMas a secret there. Or addRESEND_API_KEYto send through Resend. Add these as secrets, not text: the next deploy from your repository removes a plain-text variable. - Email in arrives through Email Routing once you route an address to the Worker and write it in Settings → Email: then each conversation has its own reply address, and you can forward your existing address in (Email).
Every push to your copy of the repository deploys again. To update, pull this repository into your copy, and keep your own database_id in wrangler.jsonc.
Your own server
Section titled “Your own server”On a fresh Ubuntu or Debian server, one command installs it with HTTPS, a nightly backup, and updates that undo themselves if they fail:
curl -fsSL https://surfingdog.ai/install.sh | sudo shRun it yourself has what it does, the inbox command it leaves behind, and the other ways: the Docker image on a platform you use, or Compose by hand.
The rest of this section is the server itself, for running it without the installer. Node 22.16, 24 or 26 (node:sqlite with FTS5), one process, one SQLite file. Every release has the built server as inbox-server.tar.gz, with SHA256SUMS; or build the owner app and the server bundle from the repository:
git clone https://github.com/surfingdogai/inbox && cd inboxpnpm installpnpm --filter @surfingdog/inbox build # apps/inbox/dist/client and apps/inbox/dist/server.mjsnode apps/inbox/dist/server.mjs # http://127.0.0.1:8787, database in ./data/inbox.dbThe server serves the owner app from client next to the bundle, where the build and the release’s inbox-server.tar.gz both put it; any path that is neither a door nor a file gets the app shell. To sign in the first time, start it with INBOX_OWNER_EMAIL set to your address: with no email set up, the sign-in email is printed where the server runs. Environment variables, all optional:
| Variable | Default | Meaning |
|---|---|---|
PORT |
8787 |
Port to listen on. |
HOST |
127.0.0.1 |
Interface to bind. Loopback, for a server behind a proxy that terminates TLS; the Docker image sets 0.0.0.0 inside its container. |
INBOX_DB |
./data/inbox.db |
The SQLite file. Created, and migrated, at boot. |
INBOX_STATIC |
client beside the bundle |
The built owner app to serve. |
INBOX_PUBLIC_URL |
derived from the request | The https URL people and agents reach you at. Set it behind a proxy, or forward X-Forwarded-Proto; it feeds the manifest, the links in emails and the iss of every receipt. Background jobs have no request to read it from, so without it they use the Inbox address in Settings, which your first sign-in over https fills in. The links in sign-in and address-change emails use the same address once there is one, never the host a request names. |
INBOX_OWNER_EMAIL |
unset | Comma-separated addresses allowed to create the first account by email link. Without it, the first sign-in needs an owner API key. |
INBOX_SECRET_KEY |
unset | Seals connector credentials, webhook secrets and the receipt-signing key (AES-256-GCM), and is the source of the pseudonym pepper in receipts. One long random string, or several comma-separated and newest first, so a key can be rotated without downtime. Without it the instance runs as normal, refuses to store a secret rather than store it in the clear, and issues no receipts. |
CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_EMAIL_TOKEN, MAIL_FROM |
unset | Real email through Cloudflare Email Service (sign-in links, notifications), from an address on a domain onboarded there. MAIL_FROM_NAME is optional. |
RESEND_API_KEY |
unset | Real email through Resend instead, from MAIL_FROM. With neither, mail is printed to the console, sign-in emails included. |
GOOGLE_OAUTH_CLIENT_ID, GOOGLE_OAUTH_CLIENT_SECRET |
unset | Your own Google OAuth client, so owners can connect Google Calendar (see Calendars). Both or neither; without them Google shows as “set up” in Settings → Integrations. iCloud, Fastmail, Nextcloud, any CalDAV calendar, a calendar link and Cal.com need nothing here. |
INBOX_OUTBOUND_DENY |
unset | Comma-separated addresses and ranges no outbound call reaches, like 203.0.114.7, 2a01:4f8::/32: the server’s own public addresses, or a neighbour’s. Loopback, private, link-local and the other non-public ranges are always refused (see Security and privacy). An entry that is not an address or a range stops the server at boot. |
INBOX_UCP_LODGING |
off | on turns on UCP Lodging booking sessions, for rooms that confirm at once. A draft protocol, so off by default. Needs INBOX_SECRET_KEY. |
A few commands run against the same database and exit. Run them from the directory the server runs in, or with INBOX_DB set (node --env-file-if-exists=.env … reads a .env when there is one), so they use its database: each says on stderr which database it used. Without INBOX_DB a command uses ./data/inbox.db when that file is there, and refuses to run rather than create a new one.
node apps/inbox/dist/server.mjs create-owner-key laptop # prints a new owner API key (sdi_own_…) oncenode apps/inbox/dist/server.mjs set-owner-email new@x.com # the owner signs in with this address now (lost the old one)node apps/inbox/dist/server.mjs seed-demo # adds a demo bike shop if the instance is emptynode apps/inbox/dist/server.mjs seed-showcase # the same shop with a week of items, rules and hoursnode apps/inbox/dist/server.mjs network-ping # reports to every network that is on now, not at the next hournode apps/inbox/dist/server.mjs apply-plan plan.json # applies a setup plan (see the API); --dry-run says what it would donode apps/inbox/dist/server.mjs backup backup.db # a full copy of the database while it serves; never over a filenode apps/inbox/dist/server.mjs check-db backup.db # whether a file is a sound inbox database, before a restoreOpen /login: enter an address from INBOX_OWNER_EMAIL, then open the link you receive and press Sign in, or type its code; or paste an owner key. Put Caddy or nginx in front for TLS. Jobs run on a one-second loop inside the process. Email in arrives at POST /v1/email/inbound as raw MIME, with the shared secret from settings (email.inboundSecret) in the X-Inbox-Email-Secret header; point a Mailgun route, a Postmark or SES inbound webhook, or a forwarder at it. The machine-readable install guide at /install.md has a systemd unit and a Caddy site block to copy.
With Docker
Section titled “With Docker”The image is ghcr.io/surfingdogai/inbox, for amd64 and arm64, published on every release. It does the same build and runs the one file as an unprivileged user (uid 10001):
docker run -d --name inbox -p 127.0.0.1:8787:8787 -v inbox-data:/opt/inbox/data ghcr.io/surfingdogai/inbox:0.2.0curl -s localhost:8787/healthzThe SQLite file lives in the inbox-data volume, so the container can be replaced without losing anything. With no variables at all it answers the public API and the MCP server at /mcp. It answers on the server itself only (127.0.0.1), over plain HTTP: put a proxy with TLS in front, one that drops a CF-Connecting-IP the caller sent, as the inbox limits callers by it. Pass the variables from the table above with -e or a file, for a real instance at least these:
(umask 077; echo "INBOX_SECRET_KEY=$(openssl rand -base64 32)" > inbox.env) # keep a copy somewhere safedocker run -d --name inbox --restart unless-stopped -p 127.0.0.1:8787:8787 \ -v inbox-data:/opt/inbox/data --env-file inbox.env \ -e INBOX_OWNER_EMAIL=you@yourbusiness.com -e INBOX_PUBLIC_URL=https://inbox.yourbusiness.com \ ghcr.io/surfingdogai/inbox:0.2.0The commands above run inside the container, for example docker exec inbox node /opt/inbox/server.mjs create-owner-key laptop, and two more for backups: backup <file> and check-db <file> (Backups). A folder of the host mounted as the data folder must belong to uid 10001 (sudo chown 10001:10001 <folder>); started with --user 0, the image gives it to that user itself, then runs as it. deploy/docker-compose.yml adds Caddy with HTTPS in front. docker build -t surfingdog-inbox . builds the same image from the repository.
A public demo
Section titled “A public demo”INBOX_DEMO=1 turns an instance into a demo shop that anyone’s AI can book with, like the one on Try it. Start it on an empty database: it fills itself with a bike workshop and a week of history. It never emails a customer. The only email it sends is a sign-in link or one summary a night, only to INBOX_OWNER_EMAIL, from MAIL_FROM. It never contacts a network or sends a webhook. Its rules confirm a small booking in a free slot inside the opening hours and accept a small order on their own; anything else waits for a person, and /demo/live says why. It limits what each caller, and everyone together, can send, and at 03:00 UTC it wipes itself and starts again, with its week of history dated as if it came in over the past days and marked example. /demo/live shows what arrives, with no names, addresses or messages. On a database that already holds anything it does nothing: no live view, no wipe, and the Node server won’t start.
First calls
Section titled “First calls”The examples hit demo.surfingdog.ai, our demo bike shop, the one on Try it. It emails no one who uses it, is wiped every night, and its rules answer a booking in a free slot in a second or two. The dates below were worked out when this page was built. On your own instance, use its address.
Read the manifest, then the services:
curl https://demo.surfingdog.ai/.well-known/agent-inbox.jsoncurl https://demo.surfingdog.ai/v1/servicesCheck availability for a service, here the id of Full service from that list (the window may span at most 14 days):
curl "https://demo.surfingdog.ai/v1/availability?service_id=<id>&from=2026-11-14T00:00:00Z&to=2026-11-21T00:00:00Z"Request a booking at one of those times. endTime may be left out: it is the start plus the service’s length. Generate a random idempotency key (a UUID) and keep it: the same key with the same body returns the same item (200 with Idempotent-Replayed: true) instead of a second booking.
KEY=$(uuidgen) # keep it for a retrycurl -X POST https://demo.surfingdog.ai/v1/bookings \ -H 'content-type: application/json' -H "idempotency-key: $KEY" \ -d '{ "payload": { "reservationFor": { "serviceId": "<id>" }, "startTime": "2026-11-14T10:00:00Z" }, "contact": { "name": "Rita", "email": "rita@example.com" } }'Full service has a price, so the first answer is 409 confirm_terms: what your person confirms before it binds them (details.summary) and its fingerprint (details.terms_sha). Send the same request again, with the same key and "terms_sha" added, as an assistant does on its person’s yes.
The answer is 201 with the item view (view.item.state is requested, and a second or two later the demo’s rules have confirmed it) and, because you have no account here, an accessToken. Keep it: it is the only way to read or cancel that item later. If the answer is lost, send the same request with the same key: a random key gets the same token back.
curl "https://demo.surfingdog.ai/v1/items/<item id>" -H 'x-access-token: <token>'curl -X POST "https://demo.surfingdog.ai/v1/items/<item id>/cancel" \ -H 'content-type: application/json' -H 'x-access-token: <token>' -H "idempotency-key: $(uuidgen)" \ -d '{ "reason": "Found a closer workshop" }'service_id and access_token may be spelt serviceId and accessToken too, in a body, a query or an MCP tool’s arguments.
Send something wrong and the refusal is a problem document that names the fields:
{ "type": "https://surfingdog.ai/problems/invalid_input", "title": "Invalid input", "status": 422, "code": "invalid_input", "detail": "Missing: payload.startTime. Add them and retry with the same idempotency key.", "fields": [ { "path": "payload.startTime", "problem": "missing", "message": "Invalid input: expected string, received undefined" } ]}On your own instance, the owner side of the same booking is one call with the owner key:
curl -X POST https://<your-instance>/v1/owner/items/<item id>/transitions \ -H "authorization: Bearer sdi_own_…" -H 'content-type: application/json' \ -d '{ "event": "confirm", "expected_version": 1 }'The same operations are MCP tools at /mcp (public) and /mcp/owner (owner). See Connect your AI and the API page.
Once your inbox answers at its own address and your website names it, check your website for AI agents: the check reads your site the way a customer’s assistant would, and shows the doors it finds.