# Harakumo documentation

One account and one API key for hosting, databases, storage, auth, payments, email, domains and AI — through the SDK, the CLI, plain HTTPS or an AI assistant over MCP.

---

# Getting started

Everything you create lives in a project, projects live in a workspace, and the workspace holds the plan, the Balance and the team. Every service has a REST API over plain HTTPS (`https://harakumo.com/api/…`), and most are also in the dashboard, the JavaScript SDK and the CLI: each topic's Reference says which. An AI assistant connected over MCP uses the same API, with your permissions.

New here? The Quickstart builds a working app with a database, browser uploads and payments, step by step. Every step works on the free plan: the app is one worker.js file on the edge.

## The pieces

| Level | What it is |
| --- | --- |
| Account | You: an email address, a password and optional two-factor sign-in. |
| Workspace | Where people live: one plan, one Balance, one team with roles. A plan is paid for by one workspace and can cover several of its owner's workspaces (Pro 3, Team 10 and Scale 25). Signing up creates a personal workspace; you can create more and be invited to others. |
| Project | One app. It has its own address (`https://<slug>.harakumo.app`), environment variables and resources. |
| Resource | A database, bucket, function, auth pool, payment account, vector collection, memory store, agent or media asset inside a project. |

## Install

**SDK (Node 18 or newer)**

```bash
npm install @harakumo/sdk
```

**CLI**

```bash
npm install -g @harakumo/cli
harakumo login --key hk_live_…     # saved to ~/.harakumo/config.json
harakumo docs                      # these docs, in the terminal
```

## Make your first call

1. Create a key in Dashboard → Settings → API access (owners and admins can). It is shown once; store it as `HARAKUMO_API_KEY`.
2. Send it as a Bearer token. The same key works for every service and every interface.

**curl**

```bash
curl https://harakumo.com/api/projects \
  -H "Authorization: Bearer $HARAKUMO_API_KEY"
```

**SDK**

```js
import Harakumo from '@harakumo/sdk';

const hk = new Harakumo({ apiKey: process.env.HARAKUMO_API_KEY });
const { projects, org } = await hk.projects.list();
```

> Keys are secrets: use them from servers, scripts and CI, never in a browser or a mobile app. For sign-in from a browser, auth pools have a publishable client id instead (see Auth pools).

## Where to go next

- Quickstart — signup to a live app with a database, storage and payments, on the free plan.
- API keys & authentication — full, read-only and project keys, and which to give an app.
- Limits & scaling — every number, and how each service behaves under load.
- Errors & rate limits — what each status and code means.
- AI connector (MCP) — run all of it from Claude or ChatGPT.

---

# Quickstart: your first app

You will build a small notes app that stores notes in an edge SQLite database, lets the browser upload attachments straight to object storage, and sells an upgrade through a hosted checkout. The whole app is one file, `worker.js`: it serves the page and its API from the edge at `https://<slug>.harakumo.app`, with nothing to keep awake.

Every step works on the free Hobby plan. An app that needs a long-running Node.js server (Express, Next.js with a server) deploys the same way as a server app, which is on Pro and up.

Replace the values in angle brackets with the ones each step prints.

## 1. Sign up and create a setup key

1. Sign up at harakumo.com/signup and enter the 6-digit code emailed to you. Verify now: live payment links need a verified workspace-owner email.
2. Open Dashboard → Settings → API access and create a key with full access to the whole workspace. It stays on your machine for setup.

**Terminal**

```bash
npm install -g @harakumo/cli
export HARAKUMO_API_KEY=hk_live_…          # the key you just created
harakumo login --key $HARAKUMO_API_KEY
```

## 2. Create the project, a database, a bucket and a payment account

**Terminal**

```bash
harakumo projects create notes-app       # ✓ Created project notes-app #<projectId>
harakumo db create <projectId> notes     # ✓ Creating notes (sqlite, #<dbId>)
harakumo db query <dbId> "CREATE TABLE notes (id INTEGER PRIMARY KEY, body TEXT NOT NULL, attachment TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP)"
harakumo storage create <projectId> uploads
harakumo storage <projectId>             # lists buckets with their ids: note <bucketId>

# Payments are live as soon as they are enabled:
curl -X POST https://harakumo.com/api/projects/<projectId>/payments \
  -H "Authorization: Bearer $HARAKUMO_API_KEY"
# → { "account": { "id": <accountId>, … } }
```

## 3. Give the app its own key

Your setup key can do anything in the workspace; the deployed app should not. In Settings → API access create a second key with full access and Project set to notes-app. It works on this project's database, buckets and payment account and is refused everywhere else.

Store it and the resource ids as the project's environment variables — in the dashboard (project → Environment) or with the API:

| Variable | Value |
| --- | --- |
| HARAKUMO_API_KEY | the project key (hk_live_…) |
| NOTES_DB_ID | `<dbId>` |
| UPLOADS_BUCKET_ID | `<bucketId>` |
| PAYMENTS_ACCOUNT_ID | `<accountId>` |

**One variable with curl**

```bash
curl -X POST https://harakumo.com/api/projects/<projectId>/env \
  -H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
  -d '{"key":"NOTES_DB_ID","value":"<dbId>"}'
```

> Environment variables are copied into the app when it deploys. After changing one, redeploy.

## 4. Write the app

One file in a folder called notes-app. A `worker.js` at the root deploys as an edge program: it answers every request to the app, reads the project's environment variables as `env.NAME`, and calls the Harakumo API with plain `fetch`. It needs no npm install and no build.

**worker.js**

```js
// The whole notes app: the page and its API, on the edge.
const API = 'https://harakumo.com/api';

// One call to the Harakumo API with the project's key (step 3).
async function hk(env, path, body) {
  const res = await fetch(API + path, {
    method: body === undefined ? 'GET' : 'POST',
    headers: { authorization: 'Bearer ' + env.HARAKUMO_API_KEY, 'content-type': 'application/json' },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data.error || 'Harakumo answered ' + res.status);
  return data;
}

const json = (data, status = 200) => Response.json(data, { status });

export default {
  async fetch(request, env) {
    const url = new URL(request.url);
    const route = request.method + ' ' + url.pathname;
    try {
      if (route === 'GET /') {
        return new Response(PAGE, { headers: { 'content-type': 'text/html; charset=utf-8' } });
      }

      // Notes live in the edge SQLite database; every query is one HTTPS call.
      if (route === 'GET /api/notes') {
        const { results } = await hk(env, '/databases/' + env.NOTES_DB_ID, {
          sql: 'SELECT id, body, attachment, created_at FROM notes ORDER BY id DESC LIMIT 50',
        });
        return json(results);
      }
      if (route === 'POST /api/notes') {
        const { body, attachment = null } = await request.json();
        await hk(env, '/databases/' + env.NOTES_DB_ID, {
          sql: 'INSERT INTO notes (body, attachment) VALUES (?, ?)',
          params: [String(body), attachment],
        });
        return json({ ok: true }, 201);
      }

      // The browser uploads straight to storage with a signed URL; the bytes never pass through here.
      // size locks the link to the file's exact length, so nobody holding it can send more; it lasts at most 15 minutes.
      if (route === 'POST /api/upload-url') {
        const { filename, contentType, size } = await request.json();
        const key = 'attachments/' + Date.now() + '-' + String(filename).replace(/[^a-zA-Z0-9._-]/g, '_');
        const signed = await hk(env, '/storage/' + env.UPLOADS_BUCKET_ID, { key, method: 'put', contentType, size });
        return json({ url: signed.url, key });
      }

      // A hosted checkout. The buyer comes back to /thanks?session_id=…
      if (route === 'POST /api/checkout') {
        const checkout = await hk(env, '/payments/' + env.PAYMENTS_ACCOUNT_ID, {
          amountCents: 500,
          description: 'Notes Pro',
          successUrl: url.origin + '/thanks',
          cancelUrl: url.origin + '/',
        });
        return json({ url: checkout.url });
      }

      // Confirm the payment here; never trust the redirect alone.
      if (route === 'GET /thanks') {
        const { paid, payment } = await hk(env, '/payments/' + env.PAYMENTS_ACCOUNT_ID + '/sessions/'
          + encodeURIComponent(url.searchParams.get('session_id') || ''));
        return new Response(paid
          ? 'Paid $' + (payment.amount_cents / 100).toFixed(2) + ', thank you!'
          : 'Payment not completed yet.');
      }

      return new Response('Not found', { status: 404 });
    } catch (err) {
      return json({ error: err.message }, 502);
    }
  },
};

// The page: a tab icon and a phone layout from the start.
const PAGE = `<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Notes</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%234F46E5'/%3E%3Ctext x='32' y='44' font-family='system-ui' font-size='36' font-weight='700' fill='white' text-anchor='middle'%3EN%3C/text%3E%3C/svg%3E">
<style>body { font: 16px/1.5 system-ui, sans-serif; max-width: 560px; margin: 40px auto; padding: 0 16px; }</style>
<form id="note"><input name="body" placeholder="Write a note" required> <input type="file" name="file"> <button>Save</button></form>
<ul id="list"></ul>
<button id="buy">Upgrade for $5</button>
<script type="module">
const json = (url, body) => fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }).then(r => r.json());

async function load() {
  const notes = await (await fetch('/api/notes')).json();
  document.getElementById('list').replaceChildren(...notes.map(n => {
    const li = document.createElement('li');
    li.textContent = n.body + (n.attachment ? ' (attachment)' : '');
    return li;
  }));
}

document.getElementById('note').onsubmit = async (e) => {
  e.preventDefault();
  const form = new FormData(e.target);
  const file = form.get('file');
  let attachment = null;
  if (file && file.size) {
    const type = file.type || 'application/octet-stream';
    const { url, key } = await json('/api/upload-url', { filename: file.name, contentType: type, size: file.size });
    await fetch(url, { method: 'PUT', headers: { 'content-type': type }, body: file });  // same Content-Type as signed
    attachment = key;
  }
  await json('/api/notes', { body: form.get('body'), attachment });
  e.target.reset();
  load();
};

document.getElementById('buy').onclick = async () => {
  location.href = (await json('/api/checkout', {})).url;
};
load();
</script>
`;
```

## 5. Deploy

**Terminal**

```bash
harakumo deploy <projectId> ./notes-app    # uploads the folder
harakumo deploys <projectId>               # queued → deploying → ready, in seconds
```

> Keep secrets in environment variables, never in the file. After changing a variable, deploy again: an edge program reads them when it deploys.

## What happens

The pipeline finds `worker.js` at the root and deploys it as an edge program at `https://<slug>.harakumo.app`, with the project's production environment variables. Nothing builds and nothing sleeps: it answers from the edge location nearest each visitor. When the status is ready, open the address.

- Notes are saved through the database API, so they survive every deploy.
- Attachments go from the browser straight to the bucket with a signed link.
- Need a full Node.js server instead (Express, Next.js)? The same deploy runs it as a server app, which is on Pro and up.

## 6. Take a payment

Press Upgrade on the live app. The hosted checkout charges a real card: to try the flow, pay yourself and refund it from Dashboard → Payments (refunds are for the workspace owner). You return to /thanks?session_id=…, and the app confirms the payment with the session lookup.

The payment appears in Dashboard → Payments as Earnings: $5.00 less the 3.9% + 30¢ fee. Each payment is held 7 days before the workspace owner can withdraw it.

## Next

- Databases — batch statements, export, limits and errors.
- Storage — download links, listings and folders.
- Payments — webhooks, metadata and refunds.
- Auth pools — add sign-in for your users.
- Limits & scaling — what this app can handle, and where the walls are.

---

# Build standard

An assistant building on Harakumo follows this standard without being asked, so nobody has to request a tab icon, a password eye button, a phone layout or a terms page on every project. The connector tells assistants to read it first; get_build_standard returns it with the extras for the kind of app being built.

## How to work

- Decide the platform first. This standard is for websites; an iPhone or Android app follows the Mobile Build Standard (get_build_standard with platform 'mobile'). When the person says only "app", ask: "A website, a phone app, or both?"
- Understand first: who uses the app, what they do in it, and what the owner must manage. Ask at most three short questions when something essential is unclear; otherwise state your assumptions and go on.
- Create the tab icon first, before any page: it is the item assistants forget most (see "Identity").
- Roadmap before code: write the project's Roadmap first. A goal of 1-3 sentences (who it is for, what it does, where it is going) and 8-20 items written as outcomes ("Customers can book a time slot"), each with 2-4 "Done when" checks in its detail, covering the pages, the data (tables and fields), the roles (visitor, user, admin) and the Harakumo services each part uses. Show it as Now / Next / Later in about 10 lines and ask once; on yes, call set_roadmap_goal and add_roadmap_items (approved: true). Without an answer, add them as ideas and keep building: naming the items you finish with ships on the deploy is the go-ahead for those, and the person reviews the rest. Then call get_roadmap before each piece of work, and name what each deploy finishes with ships ("R4"), so the item is marked done when the deploy is live.
- Set up the backend in one call with setup_app_backend (the kind of app decides whether it needs a database, storage and sign-in), then read every setting from the environment variables it stores.
- Build every item of this standard without being asked. The person should never have to request a tab icon, an eye button, a phone layout, pagination or a terms page.
- If the app has users, orders, bookings or content, call suggest_agents and propose the agents that fit (a shopping or booking assistant, a support helper, a report someone asks for…); create the ones the person picks with create_suggested_agents. An agent can also run on a schedule (a cron line in UTC, at most every 10 minutes, set on the agent in the dashboard's Agents tab or with update_agent and schedule), so a daily report or a reminder the day before is a scheduled agent's job; functions have no scheduled runs yet and run only when something calls them. When no blueprint fits, design the agent yourself with create_agent (instructions, the fewest tools that do the job, what starts it, its purpose), test it with run_agent, read its steps with get_agent and improve it with update_agent.
- Propose what the app clearly needs and was not asked for (an admin dashboard for an app with users or orders, email receipts for an app that takes payments), then build it.
- Start from get_starter_files for the tab icon, sign-in pages, footer and legal pages, instead of writing them from memory.
- Working on an app made earlier from the starter files (it has their AGENTS.md or app/api/auth/[action]/route.js)? An app keeps the copy it was given, so first apply each fix in get_starter_files' updates list that the app still needs; each one says how to tell an old copy.
- Write the project docs (see "Project docs in Nucleus") when you create the project, and update them in the same deploy as every major change. Never wait to be asked.
- After every deploy, run check_launch (with testSignIn when the app has accounts) and fix every "must" it lists.
- Before saying it is done, walk the Done checklist at the end of this standard and fix every miss.

## Identity: tab icon, titles and sharing

- A tab icon on every page, created first: icon.svg (scales everywhere), favicon.ico (32×32) for old browsers, apple-touch-icon.png (180×180) for phones, and a web manifest with 192×192 and 512×512 icons. Link them in the shared <head> (or the framework's icon convention, like app/icon.svg in Next.js) so no page is left out. No logo given? Make a clean mark from the app's initial on the brand color, for example <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#4F46E5"/><text x="32" y="44" font-family="system-ui,sans-serif" font-size="36" font-weight="700" fill="#fff" text-anchor="middle">F</text></svg>. A missing tab icon is the most common miss: never ship without one, and check it shows in the browser tab.
- A unique, descriptive <title> and meta description on every page ("Orders · Fruitly", not "App").
- Link previews: og:title, og:description and a 1200×630 og:image, so a shared link shows a card.
- A theme-color meta tag matching the brand color.

## Design and icons

- One icon set for the whole app, consistent in stroke and size (for example Lucide, Heroicons or Phosphor). Never emoji as icons, never mixed sets, never clip-art.
- Design tokens: a small palette (one brand color, neutrals, success, warning, danger), one or two fonts, a spacing scale. No one-off colors.
- Clear hierarchy: one main action per screen, readable line length, generous spacing, consistent buttons.
- Text contrast of at least 4.5:1. Visible focus rings. Every image has alt text; every icon-only button has an aria-label.
- Respect the system's light or dark mode when the app has a dashboard.

## Motion: a landing page that feels alive

Motion makes a landing page feel crafted. Use it with intent: to guide the eye, show how the product works and reward interaction. Never as decoration that slows the page down.

- Hero: one signature moment on load, such as the headline and product visual arriving in a short stagger (300–600 ms), an animated product demo, or a subtle moving gradient or illustration behind the headline.
- Scroll: sections and cards fade and rise in as they enter view (once, not on every scroll), numbers count up, and a feature walkthrough can pin and step through as the visitor scrolls.
- Micro-interactions: buttons and cards respond on hover and press (a lift, a shadow or a color shift in 150–200 ms), links underline smoothly, and form success has a small check animation.
- Product visuals: show the app working (an animated screenshot, a short looping clip or a Lottie illustration) rather than a static picture where possible.
- Easing: ease-out for things entering, ease-in for leaving, never linear for UI. Keep durations short; nothing blocks reading or clicking.
- Performance: animate transform and opacity only (never width, height or top), lazy-start animations below the fold, and keep the page fast (Largest Contentful Paint under 2.5 s). Start with CSS transitions, the Web Animations API or IntersectionObserver; reach for a library (GSAP, Motion, Lottie) only for scroll sequences or complex timelines.
- Respect prefers-reduced-motion: with it on, show the end state with no movement. Nothing flashes more than three times a second.
- Dashboards and admin pages stay calm: quick transitions and skeletons, no showpiece motion.

## Every screen size, from the first build

- Phone (375 px), tablet (768 px) and desktop (1280 px and up) are built together, never as a later pass. The person should never have to ask for a mobile version.
- No sideways scrolling at any width. Navigation collapses into a menu button on phones. Wide tables become stacked cards or scroll inside their own box.
- Tap targets at least 44×44 px. Inputs use the right type (email, tel, number) so phones show the right keyboard.
- Check the layout at phone width before calling a page done.

## Lists, loading and performance

- Any list that can grow past about 20 items is paginated without being asked: numbered pages for tables and admin lists, "Load more" or infinite scroll for feeds and galleries. Fetch one page at a time from the server (LIMIT and a cursor or offset), never everything at once.
- Search, filter and sort run on the server for large data, and are kept in the URL so a refreshed or shared link shows the same view.
- Every screen that loads data has three states designed: loading (skeleton placeholders the size of the content, so nothing jumps), empty (a friendly line and the next action, like "No orders yet. Share your store link."), and error (what went wrong and a Try again button). Never a blank screen.
- Images: the right size for where they show, modern formats, lazy-loaded below the fold. Uploads go straight to storage with signed URLs.
- Buttons show progress while they work and cannot be double-submitted.

## Pages every public app has

- Home (landing): what it is, who it is for, the main action, and proof (features, screenshots or testimonials the owner provides; never invented customers or numbers).
- About, Contact (a form that sends with Harakumo Mail, plus an email address), Privacy Policy and Terms of Service.
- A footer on every public page: links to About, Contact, Privacy and Terms, the copyright line with the current year, and social links when given.
- A designed 404 page with a way back home, and a friendly error page.
- Privacy and Terms are starting templates written for this app (what data it collects, which services process it, how to ask for deletion), with a visible note that the owner should review them for their country. They are not legal advice.

## Sign-in and accounts

Use a Harakumo auth pool for every app with accounts: it already handles hashing, codes and tokens.

- Sign up asks only for what the app needs. Default: full name, email, password and confirm password. Add a username only when users see each other (communities, profiles); add phone only when the app texts or calls.
- Every password field has a show/hide eye button (an icon button with aria-label "Show password" / "Hide password"). Confirm password must match before submit, with the message under the field.
- A password strength hint and the rule stated up front (for example "At least 8 characters"). Correct autocomplete attributes: name, email, new-password, current-password, one-time-code.
- Log in, Forgot password (email a code), Reset password, and email verification. After a reset, say so on the login page.
- Errors are specific and kind ("That email already has an account. Log in instead?"), next to the field, and never reveal whether an email exists on the forgot-password screen.
- Signed-in users get an account page: change name, email and password, sign out, and delete their account.
- Pages that need sign-in redirect to login and back again afterwards.

## Admin dashboard

Propose and build one whenever the app has users, orders, bookings, posts or any data the owner must manage. Do not wait to be asked.

- Behind sign-in, for an admin role only, checked on the server for every request (not only by hiding links).
- An overview: the few numbers that matter for this app (new users, orders, revenue, bookings) for today, 7 days and 30 days, with a simple chart.
- A page per thing the owner manages (users, orders, products, bookings, messages) as a paginated, searchable table with filters, a detail view, and the actions the owner needs (cancel, ban, mark as done). Destructive actions ask to confirm.
- Refunds are made by the owner in the Harakumo dashboard (the project's Payments page), never by the app: the API refuses them to app keys (403 payouts_require_owner). Show each payment's refund status and link the owner there instead of building a Refund button.
- Settings the owner changes without code (business name, contact email, prices) stored in the database.
- An activity log of admin actions for anything that changes money or accounts.

## Forms and feedback

- Labels above inputs (never placeholder-only), required fields marked, validation on blur and on submit, messages next to the field.
- Success is confirmed (a toast or a clear next screen). Every destructive action asks first and says what will be lost.
- Times shown in the viewer's time zone, money with its currency, numbers formatted for the locale.

## Project docs in Nucleus

Every project has a private Nucleus repo, its code home, and every deploy_files upload is saved there as a commit (a deploy from a GitHub repository is not). The person reads the project in Nucleus: its code, its history, its Roadmap and these documents. Write them when the project is created, from what you are actually building, and keep them true after every major change (a new page, table, service, role or flow), in the same deploy, without being asked. They are kept out of the public site, and deploys that leave them out keep them.

docs/ROADMAP.md is the one you never write: Harakumo writes it into every commit from the project's Roadmap, and replaces any copy you send. Keep the Roadmap itself current instead, with get_roadmap, add_roadmap_items, update_roadmap_items and ships on each deploy.

- README.md: What the app is, who it is for, how to run it locally, how to deploy it, and where everything lives.
- docs/ARCHITECTURE.md: The parts of the system and how they talk: pages, API routes or functions, database tables, storage, sign-in, payments and outside services, with at least one diagram as a ```mermaid block (a flowchart of the parts, a sequence diagram of the main request).
- docs/SYSTEM-DESIGN.md: The data model (every table and its fields), the main flows step by step, roles and permissions, limits, failure handling, security and how it scales.
- docs/ROADMAP.md: Harakumo writes it from the project's Roadmap (the goal, every item and what is done) into every commit, once the Roadmap has a goal or an item. Assistants never write it: change the Roadmap itself, in Nucleus or through the roadmap tools.
- docs/DECISIONS.md: One short entry per real decision: the date, what was decided, the options weighed and why.
- docs/presentation.html: A single-file HTML slide deck about the project (16:9, 1280×720 scaled to fit, keyboard and click navigation, a progress bar). Slides: title, the problem, the solution, how it works with an architecture diagram in inline SVG, the tech stack, status and next steps. CSS design tokens in :root, one clear point per slide, motion only where it explains. No external scripts.

## Engineering

- The API key lives on the server only, in an environment variable (Harakumo project env vars). The browser gets signed URLs, checkout links or the auth pool's publishable id, never the key.
- Payments: Harakumo checkout takes one-off card payments in US dollars, with no subscriptions yet. It fits physical goods and real-world services (orders, bookings, deposits, fees) and digital goods sold on the web. Inside an App Store or Google Play app, digital goods (credits, premium features, subscriptions) need the store's own billing. Refunds are the owner's, in the Harakumo dashboard.
- Validate every input on the server. Check permissions on the server for every read and write. Parameterized SQL only.
- Check an email address in one pass with a length limit (at most 254 characters, one @, a dot after it), never with a regular expression that can backtrack: one long, hand-made input must not hold a public route busy.
- A clear structure: pages, components, server routes and data access kept apart; no file that does everything.
- Database: a schema with migrations, indexes for what lists sort and filter by, created_at and updated_at on every table.
- Tests for the money, sign-in and permission paths at least. Run them before deploying.
- A README: what the app is, how to run it, its environment variables and how to deploy.
- Errors are logged with enough detail to debug; people see a friendly message.

## Kinds of app

Each kind adds its own pages, data, services and admin dashboard. An assistant asks for one with get_build_standard.

| Kind | Adds |
| --- | --- |
| Online store (store) | Product list with search, category filters and pagination; Product page with images, price, stock and Add to cart; Cart and checkout |
| SaaS product (saas) | Landing with pricing; Onboarding after sign up (first useful result in a minute); The app itself |
| Booking or services business (booking) | Services with prices and durations; Pick a date and an open time slot; Book and pay (or pay later) |
| School or education (education) | Public site: about the school, admissions, contact, notices; Student and parent portal: timetable, attendance, results, fees; Teacher portal: classes, attendance, marks |
| Portfolio or marketing site (portfolio) | Home with a clear headline and call to action; Work or services with case studies; About |
| Community, content or social app (community) | Feed with infinite scroll; Post page with comments; Create and edit post |
| AI-powered app (ai-app) | The main AI screen with streaming answers and a stop button; History of past chats or results; Usage and limits for the signed-in user |
| Internal tool or dashboard (internal-tool) | Overview with the key numbers; A table page per record type with search, filters, pagination and CSV export; Detail and edit forms |

## Done checklist

- Tab icon shows in the browser tab (svg, ico, apple-touch-icon, manifest); page titles on every page; a link preview card.
- The landing page has purposeful motion (hero, scroll reveals, hover states) and stays still with reduced motion on.
- Works at 375, 768 and 1280 px with no sideways scroll.
- Every list that can grow is paginated; loading, empty and error states exist.
- Home, About, Contact, Privacy, Terms, 404, and a footer linking them.
- Password fields have the eye button; sign up has confirm password; forgot and reset password work.
- An admin dashboard exists if the app has users or data to manage, and its checks run on the server.
- No API key or secret in browser code; inputs validated on the server.
- One icon set, no emoji icons, consistent colors and spacing.
- Tests for money, sign-in and permissions pass.
- The project docs in Nucleus are written and match what was built: README, architecture with diagrams, system design, decisions and the slide deck.
- The Roadmap is current: every item that shipped was named in a deploy (ships "R4") and shows Done, and what is left is in Next up, Later or Ideas to review.

## Update the email check in an app from an older starter kit

The sign-in route that older starter kits wrote into apps checks email addresses with a pattern that can take seconds on one long, made-up address. It is a public route, so anyone can send one, and many of them can slow the whole app. Starter files made now have the fixed check.

To see whether your app has the old one, open the file app/api/auth/[action]/route.js in it. If it has a line that starts `const EMAIL = /^` and checks the address with `EMAIL.test(email)`, delete that line, add the function below, change `EMAIL.test(email)` to `isEmail(email)`, and deploy the app again. The AI assistant that works on your code can make the change: ask it to update the email check in that file to the one in the Harakumo build standard.

**app/api/auth/[action]/route.js**

```js
// An email address, checked in one pass and at most 254 characters.
function isEmail(v) {
  if (v.length > 254) return false;
  const at = v.indexOf('@');
  if (at < 1 || at !== v.lastIndexOf('@') || /\s/.test(v)) return false;
  const dot = v.indexOf('.', at + 2);
  return dot !== -1 && dot < v.length - 1;
}

// Where the route checks the address:
if (!isEmail(email)) return fail('Enter a valid email address.');
```

---

# Mobile build standard

An assistant building a phone app on Harakumo follows this standard by default, as it follows the Build standard for a website. The AI connector's get_build_standard and get_starter_files with platform 'mobile' give it this standard, the extras for the kind of app, and the Expo starter kit; check_launch with platform 'mobile' checks the app's config before it goes to the stores.

Harakumo is the app's backend and its companion website: the app ships through the App Store and Google Play, built with Expo (see Use Harakumo from a phone app). The last sections say what the starter kit does on Harakumo today, and what is not on Harakumo yet.

## Stack

- Expo with Expo Router and TypeScript, shipped through the App Store and Google Play. Checked 2026-10-04: Expo SDK 57, React Native 0.86, iOS 16.4+ and Android 7+, target API 36. Use the latest stable SDK.
- The backend is one Harakumo project per app: its edge program is the app's API under /v1/, its store pages and its deep-link files, and it holds every secret. The app bundle knows only EXPO_PUBLIC_API_URL.

## How to work

- Decide the platform first. It is a phone app when the idea says: app store, play store, Google Play, iPhone, iPad, Android, iOS, phone app, mobile app, Expo, React Native, Flutter, SwiftUI, Kotlin, TestFlight or APK. 'Mobile-friendly website' is a website. A bare 'app' with no other hint: ask 'A website, a phone app, or both?'. Build both only when the person asks for both. Why: A phone app and a website have different meanings of done. A web page in a wrapper is rejected by both stores. Check: get_build_standard returns platform 'mobile'. The plan names iOS, Android or both. On Harakumo: get_build_standard picks the platform from the description when none is given.
- Know how you are working. With a terminal: run Expo, simulators and EAS yourself. Chat-only connector: put the app source in a Harakumo repo or the starter tgz, deploy only the api folder, give the owner a short checklist to build and submit with EAS. Never deploy the Expo folder as a site. Why: A chat-only assistant can't build a phone app. An Expo folder deployed as a site turns into a broken server app. Check: The api folder has worker.js at its root, and only that folder was deployed. On Harakumo: push_files, get_starter_files, then deploy_files on the api folder. A deploy of the Expo app folder itself is refused, with a note that phone apps ship through the stores.
- Plan before writing code, in a few lines: who uses the app and the app type; the style preset, then the tabs and each screen with its archetype; the data (tables and fields) and the roles; what is pushed and what is emailed; each paid item's payment class; which Harakumo service each part uses. Ask at most three short questions when something essential is unclear. Otherwise write down your assumptions and go on. Why: Generated apps lose track of shared state and screens after the first five. Check: The plan lists every tab, every screen with its archetype and data source, and every paid item's payment class, before the first file is written.
- Make it this business's app. The starter is scaffolding, never the product. Each app gets its own brand, icon, copy, screenshots, and at least one feature built for this business. Remove the starter's sample names, strings and comments. A business with several locations gets one app with a location picker, never one app per branch. The owner submits from their own developer account. Harakumo never submits for them. Why: Apple rejects apps that look like other apps (4.3 spam) or that come out of a template or app generator (4.2.6). Check: check_launch with appSource finds no starter default color, onboarding text or empty-state text left unchanged.
- Create the app icon and launch screen first, before any screen (see 'First run'). Why: They are what assistants forget most, and no build can be submitted without them. Check: The expo config has icon, ios.icon, android.adaptiveIcon and the expo-splash-screen plugin before the first screen file exists.
- Set up the backend in one call: create_project, then setup_app_backend with the app type and platform 'mobile'. Then deploy the api folder from the starter kit, so every key stays on the server. Confirm the workspace is on a paid plan before store review. Why: A phone app can't keep secrets, so its server side must exist before any screen calls it. Hobby stops at its limits, and a stopped backend fails review. Check: list_env_vars shows HARAKUMO_API_KEY, HARAKUMO_AUTH_* and APP_SESSION_SECRET. GET https://<slug>.harakumo.app/v1/app-config answers 200. check_launch reports no plan-room warnings. On Harakumo: setup_app_backend (platform 'mobile'), deploy_files.
- Start from get_starter_files with platform 'mobile'. Fetch the index first, then each part: shell, kit, auth, states, settings, push, api, store-kit. Don't write these from memory. Why: The kit already passes the store rules and this standard, and fetching part by part keeps each tool result small. Check: The app folder has src/components/PasswordInput.tsx and src/app/(tabs)/settings/delete-account.tsx. The api folder has worker.js. On Harakumo: get_starter_files (platform 'mobile', part).
- Build every item of this standard without being asked. Then propose what the app clearly needs and wasn't asked for, and build it: push for status changes, email receipts, an admin dashboard whenever the app has users or orders. Why: Nobody should have to ask for an eye button, an offline state or a delete-account screen. Check: Walk the Done checklist at the end.
- Real data only. Never ship mock data, lorem ipsum, 'coming soon' tabs, dead buttons or links that go nowhere. Seed two separate data sets: a store-review demo account with realistic data; a worst-case set for testing: 40-character names, 7-digit prices, missing images, emoji, and a right-to-left sample. Why: Placeholder content and a backend that is switched off cause most app-completeness rejections (Apple 2.1). Worst-case data is where generated screens fall apart. Check: A search for 'lorem', 'TODO', 'mock', 'coming soon' and 'example.com' in the app finds nothing. Every button has a handler.
- Do a visual pass before calling the app done. Screenshot every screen: on an iOS simulator and an Android emulator; on the smallest and largest phones, and the iPhone build on an iPad; in light and dark mode; at the largest text size; with worst-case data and with a brand-new empty account. Review each shot: margins line up and spacing follows the scale; nothing is cut off or hidden under a bar; icons share one weight and contrast holds. Fix every issue. Why: Misaligned margins, cut-off labels and overlapping bars only show up when someone looks. Searching the code can't find them. Check: The shots are saved under qa/, and qa/visual-pass.md lists each issue and its fix.
- Test every main flow on a development build in these conditions: a small phone, a large phone, and an iPad running the iPhone build; light and dark mode; the largest text and display size; airplane mode and a slow network; every permission denied; gesture navigation and Android 3-button navigation. Why: Reviewers test iPhone-only apps on iPads (Apple 2.4.1), and real users hit these cases first. Check: The README's test section records which checks passed on iOS and on Android.
- After every deploy of the app's API, run check_launch with platform 'mobile' and the output of `npx expo config --json --type introspect`. Fix every 'must' before saying the app is done. Why: It catches missing store pages, broken deep-link files, permission strings added by plugins, and missing settings before a reviewer does. Check: check_launch reports mustFix 0. On Harakumo: check_launch (platform 'mobile', appConfig, optional appSource).

## First run: icon, launch screen, onboarding and permissions

- App icon. iOS: prefer an Icon Composer file with default, dark, clear and tinted variants; Otherwise use a 1024×1024 PNG with no alpha channel and no rounded corners, plus dark and tinted variants. Only the main icon must be a full square; dark has a transparent background and tinted is grayscale; Android: an adaptive icon, with a 108 dp foreground whose mark sits inside the central 66 dp, a background color, and a monochrome layer. Why: Any alpha in the main PNG fails upload (ITMS-90717). Android crops icons that aren't adaptive, and themed icons look broken without a monochrome layer. Check: Each config has the right icon field set: ios.icon (an .icon path or {light, dark, tinted}) and android.adaptiveIcon with foregroundImage, monochromeImage and backgroundColor. The main PNG has no alpha. On Harakumo: The starter kit ships the SVG layers and `npm run icons`, which renders the PNGs and flattens the main icon's alpha.
- No logo given? Build the mark from the app type's symbol (leaf, calendar check, dumbbell, ticket), alone or with the initial. Use a subtle two-stop gradient of the brand color, with the glyph at about 60% of the canvas. Keep the foreground and background as separate layers. Show the owner three options before building. Never use SF Symbols in the icon itself. Why: A flat letter on a flat color looks unfinished next to real apps. Separate layers feed the glass, dark, tinted and adaptive variants. Check: assets/brand has separate foreground and background SVGs, and the owner picked one of three options.
- Home-screen name: at most 12 characters. The full name, up to 30 characters, lives only in the store listing. Why: The label under the icon is cut off at about 12 characters. Check: check_launch with appConfig warns above 12 characters and fails above 15.
- Launch screen: the expo-splash-screen plugin. The mark is a 1024×1024 transparent PNG, imageWidth 200. On Android, the mark fits inside the inner circle: 192 dp across within the 288 dp icon area. The brand background, with a dark variant. No text, slogan or ads. Keep it up with preventAutoHideAsync until fonts and the saved session load. Then fade it out with SplashScreen.setOptions({ fade: true }) and hideAsync. Why: Both platforms show the launch screen while the app starts, and Android 12+ always draws the icon, masked to a circle, so a wide logo gets cut. Holding the launch screen until the session loads stops a signed-in person from seeing the sign-in screen flash. Check: The plugin config has image, backgroundColor and dark. The root _layout calls hideAsync after the session check. Test a cold start in a preview build.
- Onboarding: shown on first launch only, never again once skipped or finished. At most three slides, each showing what the person gets: a crop of the real app or a symbol composition. A headline of at most 6 words and at most one line of body text. Page dots, Skip at the top right, and the main button at the bottom. No auto-advance. The last button leads to the app ('Get started'), not to a sign-up wall. Why: Apple's guidance is that onboarding is fast and optional. Long tours make people uninstall, and auto-advance loses screen-reader users. Check: An onboarding-done flag is stored. Every slide has Skip. A second launch skips onboarding.
- Design the first day, not only the demo account. Content, community and fitness apps get a personalize step: pick interests or goals, follow suggestions. Home shows a setup checklist card until it's done ('Add your first expense'). Every empty state has a symbol illustration and one main action. Why: A brand-new account that is empty everywhere feels broken. Check: The visual pass includes a brand-new account.
- Ask for camera, photos, location, microphone, contacts, calendar or notifications only when a feature needs them, never at launch. Before the system prompt, show one screen that explains the benefit, with a single 'Continue' button. No cancel and no reward. After a denial, the rest of the app still works, and the feature shows 'Turn on in Settings' (Linking.openSettings()). AI data consent is different: it needs a real 'Not now' (see Settings). Why: Apple forbids pressuring people into consent (5.1.1(iv)). Play requires a prominent disclosure first. iOS asks only once. Check: No request*PermissionsAsync call in the root layout, app start or onboarding. Every request goes through PermissionPrimer.
- Purpose sentences and unused permissions: Every permission the app uses has a purpose sentence that names the feature and ends with a period: 'Fruitly uses your camera to scan product barcodes.'. Override every default string that a plugin adds. Turn off what plugins add but the app doesn't use: expo-image-picker cameraPermission: false and microphonePermission: false; no 'Always' location strings unless the app needs background location. Block unused Android permissions (android.blockedPermissions). Why: Plugin defaults such as 'Allow $(PRODUCT_NAME) to access your camera' get rejected (5.1.1(ii)). A missing key crashes iOS. Unused Android permissions trigger Play declarations. Check: Run on the introspected config, not the raw app.json: each NS…UsageDescription has at least 30 characters, ends with '.', and names the app or feature; the merged Android permission list matches the features used.
- Pick photos with the system photo picker (expo-image-picker), which needs no library permission. Ask for full library access only in a gallery app. Why: Google Play requires the photo picker for apps that aren't galleries. On iOS the picker needs no permission. Check: No READ_MEDIA_IMAGES or READ_MEDIA_VIDEO in the merged manifest unless the app is a gallery.
- Calendar: add events through the system add-event sheet, which needs no permission, or with write-only access. Ask for full access only when the app reads the calendar. Why: Full calendar access just to add one event asks for more than the app needs (Apple 5.1.1(iii)). Check: No NSCalendarsFullAccessUsageDescription unless the app reads events.
- Ask for a rating only after a real success (third completed order, a finished workout), at most once per version, with expo-store-review. No 'Enjoying the app?' question before it. Never reward a rating. Never ask after an error. Why: Custom review prompts (Apple 5.6.1) and rating gating or incentives (Play policy) are forbidden. Check: requestReview is called only from a success handler, behind a counter, with no dialog before it.

## Sign-in and accounts

- The first screen for a signed-out person is a welcome screen: the brand mark and a one-line value statement; 'Create account' as the main button and 'Sign in' as the second; 'Continue as guest' when browsing is allowed. Once social sign-in exists, those buttons go above email, all the same size, with Apple first on iOS. Why: Opening straight into an email form looks unfinished. Check: src/app/(auth)/welcome.tsx exists and is the signed-out entry.
- Delay sign-in. Let people browse or try the app first when it can work that way. Ask for an account when one is needed (to save, buy, book or post), then return them to what they were doing. Why: Apple's account guidance. A sign-in wall on first launch loses most new users. Check: The first tab renders signed out, unless the app is private by nature (internal tool, finance, health).
- Use the project's sign-in pool through the app's own API. The app posts to {API}/v1/auth/<action>, and the API calls the pool with the client secret. Why: The pool already handles password hashing, codes and tokens. Going through the API keeps the secret off the phone and avoids per-IP limits on carrier networks. Trade-off: until the platform takes the end user's address from the API, that path has no per-IP abuse limit, so the API limits sign-ups and codes itself. Check: The app folder never contains the client secret. Every sign-in request goes to EXPO_PUBLIC_API_URL. On Harakumo: POST https://harakumo.com/api/auth-pools/<poolId> with Bearer HARAKUMO_AUTH_CLIENT_SECRET.
- Sign-up asks only for what the app needs. Default: full name, email, password and confirm password. Add a username only when users see each other, and a phone number only when the app texts or calls. Show the password rule up front ('At least 8 characters') with a strength hint. Show 'By continuing you agree to the Terms and Privacy Policy', with links. Marketing email is a separate switch, off by default. Why: Every extra field loses sign-ups. Hidden password rules cause repeated errors. Check: The sign-up screen has exactly the fields in the plan, the visible rule text, and the Terms line.
- Every password field has a show/hide eye button: an IconButton from the kit with accessibilityRole 'button'; accessibilityLabel 'Show password' or 'Hide password'; a target of at least 44×44; it toggles secureTextEntry. Confirm password must match before submitting, with 'Passwords do not match.' under the field. Why: Typing a password on a phone keyboard is error-prone. Builders asked for this on every project. Check: Every password field uses PasswordInput. Submit stays disabled while the two passwords differ.
- Fields help the keyboard and the password manager. Every field sets textContentType and autoComplete: emailAddress/email, password/current-password, newPassword/new-password, oneTimeCode/one-time-code, name. Email uses keyboardType email-address and autoCapitalize none, and suggests fixes for common domain typos (gmial.com → gmail.com). Return moves to the next field, and Done submits. Why: It turns on Password AutoFill, strong-password suggestions and code autofill, and later makes passkeys possible. Check: Every TextInput on the sign-in screens has textContentType, autoComplete and returnKeyType.
- One rule for what errors reveal: Sign-up may say 'That email already has an account. Sign in instead?'. This is accepted for usability, and the API limits it per device and address. Sign-in always says 'Email or password is incorrect.'. Forgot password shows the same message for known and unknown emails. No connection, too many tries (429) and a server error each have their own kind message. Why: The rules must not contradict each other. A generic 'Something went wrong' makes people give up. Check: The API client maps network failures, 401, 402, 409 and 429 to different messages.
- Forgot password happens inside the app: Ask for the email and mail a 6-digit code. Ask for the code, a new password and confirm password. Sign the person in with a short 'Password changed' note. Why: A code needs no link that has to open the app, so reset works with every phone and email app. Check: forgot-password and reset-password screens exist. On Harakumo: Pool actions forgot-password and reset-password.
- Verify the email with a 6-digit code after sign-up. The screen says 'We sent a code to a…@gmail.com' and offers 'Open mail app' and 'Change email'. The code field is one hidden TextInput behind six boxes, with one-time-code autofill. It accepts a pasted code and submits on its own at the sixth digit. 'Resend code' has a 60-second countdown. Why: Verified emails keep receipts and resets deliverable. Leaving the app to find a code must not lose the person's place. Check: verify-email screen exists. Resend is disabled while the countdown runs. VoiceOver announces one field. On Harakumo: Pool actions send-verification and verify-email.
- When an email must carry a link (an invite, a shared item), use an https link on the app's own domain. It opens the app through universal links and app links, and falls back to the website. Never put a custom scheme (myapp://) in an email. Why: Custom schemes do nothing in many email apps and on computers. Check: ios.associatedDomains and android.intentFilters autoVerify are set. check_launch finds both /.well-known files.
- Sessions that last. After a pool login, the app's API starts its own device session: a 1-hour access token signed with APP_SESSION_SECRET, plus a random refresh token stored hashed in a sessions table (device name, created, last used, revoked_at). The app refreshes silently through {API}/v1/auth/refresh, which rotates the refresh token. Sign-out, password change, deletion and a ban revoke the session rows. Settings shows 'Signed-in devices', with sign out one or all. Why: A token that runs out with no refresh means a weekly forced sign-out, one of the most visible signs of an amateur app. Check: An access token expires within an hour. Refresh rotates the refresh token, and reusing an old one revokes the session. On Harakumo: In the kit's worker.js today. Sign-in pools also have refresh tokens of their own (refresh: true at sign-in, rotated, ending after 60 idle days or a year), which the kit does not use yet.
- Face ID unlock. After the first password sign-in, offer once: 'Unlock with Face ID' (or Touch ID, or fingerprint, naming what the phone has). It guards the refresh token saved on the phone; the server never sees it. Check hasHardwareAsync and isEnrolledAsync, fall back to the passcode, and don't ask again after a no. Settings shows the status with a way to turn it off. Money and health apps also lock on reopen. Why: Apple asks apps to name the method, and to avoid making people opt into biometrics in a separate setting. Check: faceIDPermission is set in the expo-local-authentication plugin. The offer appears only after a successful password sign-in.
- Sign in with Apple. An app with only its own email and password needs nothing else. If any Google or other social sign-in is added, Sign in with Apple is also offered on iOS: the system AppleAuthenticationButton, the same size and position as the others, visible without scrolling, never ask for a name or email after an Apple sign-in; save them from the first one, since Apple sends them only once, accept relay addresses (@privaterelay.appleid.com, @private.icloud.com), test on an iPad too. Why: Apple 4.8 requires an equal private option, and Apple 4.0 rejects asking again for data Apple already gave. Check: If any Google sign-in package is installed: ios.usesAppleSignIn is true and the Apple button is on the sign-in screen. On Harakumo: Sign-in pools take Apple and Google ID tokens (the sign-in-with action, once the app's ids are listed on the pool), which the kit's worker.js does not call yet.
- Keep the tokens only in expo-secure-store (Keychain or Keystore), never in AsyncStorage or files. Clear them on the first launch after a reinstall, since iOS keeps the Keychain across uninstalls. Keep them out of Android backups (android.allowBackup false, or the secure-store backup option). On a 401 that refresh can't fix: keep any unsaved form, go to sign-in with 'Please sign in again.', then return to the same screen with the form restored. Why: Tokens in plain storage leak from backups and rooted phones. A stale token after a reinstall can sign in the wrong person. Check: No AsyncStorage import touches a token. lib/session.tsx clears tokens when its first-launch flag is missing.
- Protect signed-in screens with Stack.Protected guard={!!session} in the root layout, and keep the launch screen up while the session loads. Why: Without the guard, signed-out users can deep-link into private screens. Check: The root _layout.tsx wraps the private groups in Stack.Protected.
- Change password and profile through the app's API. Password: the API checks the current password with a pool login, sets the new one through the admin reset-password action with the project key, signs in again, and revokes the other sessions. The display name and photo live in the app's profiles table and are read from there, not from the token. Don't offer email change yet. Why: The kit's own sessions keep no pool token, and the pool's account actions for end users (change-password, update-profile, change-email with a code) each take the user's own pool token, so the kit's API makes these changes with the project key and leaves email change out. Check: change-password.tsx calls {API}/v1/account/password. The profile screen has no email field to edit. On Harakumo: PATCH https://harakumo.com/api/auth-pools/<poolId>/users/<id> (reset-password) with HARAKUMO_API_KEY. The pool's change-email action exists; the kit adds email change once its sessions carry the pool token.

## Every screen: states, lists, keyboard and bars

- Every screen that loads data has designed loading, empty and error states. Loading: skeletons the size of the final layout, shown at once. Empty: a friendly line and the next action ('No bookings yet. Book your first visit.'). Error: what went wrong, plus Try again. Requests time out after about 15 seconds into the error state. Never a blank screen or an endless spinner. Why: Blank and frozen screens are the commonest sign of an unfinished app (Apple 2.1). Check: Each data screen renders Skeleton, EmptyState and ErrorState. Test with the API stopped.
- Partial failure, staleness and timing: A dashboard section that fails shows its own inline error with Try again. The rest stays. Data refreshes when the app comes back to the front and when the connection returns: TanStack Query's focusManager is wired to AppState and onlineManager to NetInfo. Relative times tick every minute while on screen. Spinners wait about 300 ms before appearing and then stay at least 500 ms. A 402 (plan limit) gets a kind message, never a blank screen. Why: One failed section must not blank the whole screen. Stale data after resume, and spinners that flash, feel broken. Check: lib/query.ts wires focusManager and onlineManager.
- Offline: a slim banner says 'You're offline. Showing saved data.' Keep the last loaded data on screen from the persisted cache, with 'Updated 10 min ago'. Disable or queue actions that need the network, and retry when the connection returns. Why: Phones lose signal all the time. An app that wipes its screen offline feels broken. Check: OfflineBanner is in the root layout. Airplane mode shows cached lists, not errors.
- Pull to refresh on every list, and on detail screens that show server data, with the platform's spinner. Why: People expect it on both platforms. It is also the manual retry. Check: Every FlashList and ScrollView with server data has a RefreshControl.
- Lists that can grow past about 20 items load page by page: PagedList (FlashList); 20 items per request with a cursor, the next page on onEndReached; a footer spinner, and 'You're all caught up' at the end; the scroll position kept when coming back; each list row memoized, with a FlashList item type for each kind of row. Never fetch everything. Why: Loading thousands of rows freezes phones and costs data. Check: API list routes take cursor and limit (max 50). Every list uses PagedList. On Harakumo: WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20, with an index on (user_id, created_at).
- Bottom space on every scrolling screen equals the sum of: the tab bar height; the bottom safe-area inset; the height of any floating bar or floating button; 16. The last item, the submit button and 'You're all caught up' are never hidden. On iOS, use contentInsetAdjustmentBehavior 'automatic'. Why: Content hidden under the tab bar, a floating button or the home indicator is the commonest layout bug in generated apps. Translucent iOS 26 bars make it worse. Check: Screen and PagedList compute the bottom space themselves. Scroll every list to the end on the smallest phone, with the floating button or sticky bar showing.
- Keyboard: use react-native-keyboard-controller. KeyboardAwareScrollView for forms. KeyboardStickyView for chat composers and sticky submit bars. Every field sets returnKeyType. The iOS number, decimal and phone pads get a toolbar with Done (and previous/next). Tapping outside closes the keyboard. Why: Android 15+ is edge-to-edge, so the window no longer shrinks for the keyboard. The iOS number pads have no return key, so the keyboard can't be closed. Check: Test every numeric field on an iPhone. Test sign-up and chat on Android 15+ with gesture and with 3-button navigation.
- Safe areas and bars. Text and controls stay inside the safe area: the notch or Dynamic Island, the home indicator, the Android navigation bar. Backgrounds, images and scroll content run under the bars. Use native stack headers and native tabs, so iOS 26 draws its glass bars and Android draws Material bars. Never pass a custom tabBar or paint header backgrounds on iOS. Never set UIDesignRequiresCompatibility. Why: Android 15+ forces edge-to-edge. Glass bars need content running under them. Custom opaque bars look dated and template-made. Apple has said the compatibility opt-out is temporary. Check: The root layout has a SafeAreaProvider. Nothing tappable sits under the status bar. An iOS 26 screenshot shows glass bars.
- Sizes: From the smallest phone (375×667 pt, 360 dp wide) to the largest, plus Android tablets and foldables, where Android 16 ignores orientation locks; Center content at a 640 pt maximum width on wide screens; iPad support stays off by default, but the iPhone build must work on an iPad; Turn iPad support on for education, productivity, content and internal tools. Then layouts resize and work in every orientation, with two columns where useful, and iPad screenshots are needed. Why: Apple reviews iPhone-only apps on iPads (2.4.1). Android 16 ignores resize locks on large screens. Check: ios.supportsTablet is set on purpose. Each screen is checked at 360 dp and 840 dp wide, and on an iPad simulator.
- Buttons that change data: Show progress while working, can't be pressed twice, and keep their width while loading. Orders, bookings and payments send an idempotency key, so a retry never doubles them. Likes, toggles and checkmarks update at once, and roll back with a message if the server refuses. Why: Double orders come from double taps and retries on slow networks. Check: Submit buttons are disabled while the mutation is pending. The create routes for orders, bookings and payments read an Idempotency-Key header.
- Back always works. The Android back gesture, with predictive back turned on in app.json, and the iOS swipe-back both go to the previous screen. On a tab other than the first, Back goes to the first tab, then exits. Never 'press back again to exit'. Each stack _layout sets unstable_settings anchor to its list screen, so a detail screen opened from a push or a link has its list below it. A form with unsaved changes asks before leaving. Why: A detail screen opened cold from a push otherwise closes the app on Back. Check: Kill the app, tap a push for /orders/123, press Back: the Orders list appears. Forms use usePreventRemove.
- Search: The field is focused on open. Before typing: recent searches stored on the phone (with clear) and suggestions. Requests wait about 300 ms after typing stops. The return key says Search, and iOS shows Cancel. Results are grouped by type with matches highlighted. No results: a spelling hint and a way to clear filters. The query and results survive going back. On iOS 26, a Search tab uses the native search-role tab. Why: Generated search is usually blank on open, sends a request on every keystroke, and goes blank when nothing matches. Check: The search screen has the recent, debounced, no-results and kept-state behaviors.
- Toasts and Undo: One Toast component sits above the tab bar and home indicator, never over a sticky button. It shows for about 4 seconds with at most one action, queues rather than stacks, can be swiped away, and is announced to screen readers. Reversible actions (archive, remove from cart, complete) use Undo. Irreversible actions confirm with a native alert in the destructive style, with named buttons ('Cancel booking' / 'Keep booking'), never Yes, No or OK. Why: A stream of alerts feels amateur. Mature apps offer Undo. Check: A search for 'OK' or 'Yes' buttons in src/app finds none on destructive alerts.
- Sheets: Quick tasks use presentation 'formSheet' with sheetAllowedDetents (for example [0.5, 1]) and a grabber. Full flows use 'modal'. A sheet with unsaved changes blocks swipe-down and asks 'Discard changes?'. SheetHeader places buttons per platform. iOS: Cancel on the left, the action on the right. Android: a close X on the left and Save on the right, or a full-width button at the bottom. Why: Losing input on a swipe-down, or Save on the wrong side, looks amateur. Check: Every sheet uses SheetHeader.

## Design: component kit, styles, layouts and motion

- Build screens only from the component kit: Button (primary, secondary, tertiary, destructive; small, medium, large; loading; disabled). TextField, FormField, PasswordInput, CodeInput. ListRow (icon, title, subtitle, value, chevron or Switch), SectionHeader, SectionFooter. Card, Avatar, Badge, Chip, SegmentedControl, SearchBar. SheetHeader, Toast, Divider, StatTile, Screen. No raw Pressable or TouchableOpacity with inline styles outside src/components. Why: Consistency is most of what makes an app look professional. Hand-built buttons and rows drift in height, radius and padding. Check: A search for Pressable or TouchableOpacity in src/app finds none.
- Pick one style preset from the app type and the brand, as a token set: Clean: system font, radius 12, color only on actions. Bold: large display type, full-bleed color headers, radius 20. Editorial: serif headings, warm surfaces, image-led. Utility: compact rows, radius 8, dense; for internal and finance apps. Each preset sets density, radius, type pairing, image treatment and elevation. Why: Without presets, every app comes out in the same generic white-cards-and-blue-button look. Check: src/theme/tokens.ts names its preset, and screens read only tokens.
- Compose each screen from a named archetype: Home dashboard: greeting, one main card, one or two horizontal carousels, a recent list. Catalog grid: two columns, fixed 4:5 images. Feed. Detail with hero: stretchy image, sticky bottom action bar. Form sheet. Settings: grouped list. Profile: header, stats, segmented content. Chat thread. Map and list toggle. Agenda: calendar and list. Flow stepper: checkout, booking. Player: full-screen media or timer. The app type lists which archetypes its screens use. Why: The founder asked for different layouts and templates. Named compositions stop the AI inventing a new layout for every screen. Check: The plan names the archetype for every screen.
- Navigation by app type: A tab bar for 3 to 5 top-level sections, with labeled icons that are never hidden, and navigation only (never a '+' action tab). Stacks to drill down, sheets for create, edit and quick tasks, and one stack for single-flow apps. Each tab keeps its own stack and scroll position. Tapping the active tab scrolls to the top or goes back to its root (useScrollToTop). Why: Apple's tab bar rules and Material 3 navigation. Familiar layouts read as professional. Check: The tabs _layout has 3 to 5 tabs, each with a title and an icon. Create actions live in the header or a floating button.
- Design tokens live in one theme file: brand, neutrals, success, warning and danger colors, each with light, dark and high-contrast values; pressed, selected and disabled overlay colors; surfaces: base, elevated, grouped; spacing (4, 8, 12, 16, 24, 32), radii and a type scale. Screens use only tokens. Why: Tokens make the app consistent and make dark mode and Increase Contrast cheap. Check: A search finds no '#' color literals or raw font sizes outside src/theme.
- Brand color: With no color given, pick from a vetted palette for the app type. Work out the text color on the brand fill (white or near-black) by contrast. When the brand color is below 4.5:1 against the background, add a darker brand-text token for links and tinted text, and keep the brand color for fills. Why: Bright brand colors (yellow, lime, light teal) fail as text. Check: The theme unit test checks every text and fill pair.
- Typography follows each platform. iOS text styles: Large Title 34, Title 1 28, Title 2 22, Title 3 20, Headline 17 semibold, Body 17, Callout 16, Subhead 15, Footnote 13, Caption 12; Android: the Material 3 scale, with body at 16 sp; A brand font for display and headlines only. Body text and controls use the system font; Line height about 1.3× for body, at most two weights per screen; Prices, timers and counters use tabular numbers. Why: Body text in a brand font loses the native feel, and numbers that change width jitter. Check: Text components take a variant, never a raw size.
- Text that scales: It grows with the phone's text size up to 200% without clipping. No fixed heights on text, and maxFontSizeMultiplier only on tab labels and badges. At accessibility sizes, side-by-side label and value rows stack vertically. Long text uses numberOfLines with an ellipsis, and long bodies get 'Read more'. Why: Many people use larger text. Clipped labels fail accessibility review. Check: Test at the largest accessibility size. allowFontScaling is never false on body text.
- One icon set for the whole app, in one weight: expo-symbols (SF Symbols on iOS, Material Symbols on Android) or one vector set. Never emoji as icons, never mixed sets. Why: Mixed or emoji icons look amateur. Check: Every icon goes through components/Icon.tsx.
- Every touchable has visible feedback: iOS: opacity of about 0.6 on buttons and a background highlight on rows; Android: android_ripple clipped to the shape, borderless on icon buttons; Chips, segments and rows have a selected state; Disabled keeps label contrast at 3:1 or better. Why: Taps with no response feel dead. Check: Every kit Pressable uses the style({pressed}) callback or android_ripple.
- Platform conventions: iOS: large titles, swipe actions, separators inset to where the text starts; Android: a top app bar, Material components, rows that run edge to edge; Item actions go in a long-press context menu (with link preview on iOS where available), always mirrored in a visible ⋯ button; Each screen sets its StatusBar style ('light' over dark hero images); System pickers, alerts and the share sheet stay native; Section headers stick while scrolling. Why: Apps that ignore platform habits feel foreign. Dark status-bar text over a dark image is a classic bug. Check: Date and time pickers, alerts and sharing use the system components. Hero screens set StatusBar.
- Dark mode done right: userInterfaceStyle 'automatic', following the system; Surfaces show elevation with lighter grays, not shadows; The brand color gets a lighter, less saturated dark value; Separators are faint hairlines, and illustrations have dark variants; An in-app Light / Dark / System choice only on Android. Why: Inverting the light theme gives glowing brand colors and cards that merge into the background. Apple advises against an app-only appearance setting. Check: The config has userInterfaceStyle 'automatic'. Screenshots are taken in both modes.
- Touch targets are at least 44×44 pt on iOS and 48×48 dp on Android, using hitSlop for small icons, and at least 8 pt apart. Why: Small targets cause mis-taps and fail both platforms' guidelines. Check: IconButton enforces a minimum size or hitSlop.
- Images and people: Avatars fall back to initials on a token color derived from the user id. Avatar photos are cropped square (allowsEditing, aspect [1, 1]). Each card type has a fixed aspect ratio (products 4:5 or 1:1, banners 16:9) with contentFit 'cover'. A broken image becomes a neutral tile with the type's symbol. Empty-state illustrations come from the icon set: no stock clip art, no AI-drawn people. Why: Grey circles, mixed ratios and broken images are where generated screens fall apart. Check: The worst-case seed passes the visual pass.
- Haptics with expo-haptics only for moments that mean something: success after a payment or booking, a selection change, a warning before a destructive confirm. On Android, use performAndroidHapticsAsync, which respects the user's settings. Never on every tap. Why: Both platforms ask for haptics used sparingly and consistently. Check: Haptics calls appear only in success, selection and warning handlers.
- Motion: Native screen transitions. Short (150–300 ms) ease-out Reanimated animations on transform and opacity. Layout animations on list inserts and removals. A small scale bump on badges and likes. With Reduce Motion on (useReducedMotion), these become fades or no motion. Nothing flashes more than three times a second, and no animation runs on the JS thread. Why: Motion should guide, not slow down. Items that pop in without a transition feel unfinished. Check: Animated components read useReducedMotion. No animation in the main flows runs longer than 400 ms.
- Writing: Buttons are specific verbs ('Book visit', 'Place order'), not 'Submit'. An alert's title is the question, and its buttons name the actions. No 'Oops' and no exclamation marks in errors. Capitalization is consistent. No developer words on screen: null, undefined, 500, timeout, 'fetch failed'. Why: Developer leakage makes an app feel amateur faster than any visual flaw. Check: A search for 'Submit', 'Oops', '>OK<' and 'Error:' in src/app finds none.

## Notifications: push, inbox and email

- Push for the events that matter to this app type (order shipped, booking tomorrow, new message, reply). After sign-in, the app registers its push token with {API}/v1/push/register. Remove it on sign-out, and when the push service reports the device gone. Why: Push brings people back at the moment that matters. Stale tokens can reach the wrong person after sign-out. Check: lib/push.ts registers after sign-in and deletes the token on sign-out. On Harakumo: Harakumo's push API delivers with the owner's own keys: the app registers each phone's device token with the sign-in pool (the register-push action, with the user's token), the server sends to users by id (POST /api/push/<poolId>/send with the client secret), and a device the service reports gone is switched off. The kit's worker.js instead keeps a devices table and sends through the Expo push service with Expo push tokens, since its own sessions hold no pool token.
- Push credentials belong to the owner and are set up before the first push test: iOS: the APNs key (.p8) in EAS credentials for the Expo push service, or saved on the pool's Auth page for Harakumo's push API; Android: your Google project's push key (FCM V1, a service account file) uploaded to EAS or saved on the pool's Auth page, and google-services.json referenced from android.googleServicesFile either way. Why: Without them, Android push fails silently. Check: check_launch with appConfig: android.googleServicesFile is set whenever expo-notifications is used.
- Ask for notification permission after a meaningful action, never at launch. For example, after the first order: 'Get a message when your order ships?', with one Continue button first. On Android 13+, create the channel before asking; iOS provisional (quiet) delivery suits apps that send many updates. Why: Notifications are off by default on Android 13+, and iOS asks only once. Check: requestPermissionsAsync is called only from PermissionPrimer after an action. setNotificationChannelAsync runs before the token is fetched.
- The app works fully with push off. Every pushed event also lands in an in-app Notifications screen: a bell with an unread badge; a paged list with mark as read; each row opens its screen. Why: Push can't be required (Apple 4.5.4). An inbox means nothing is lost when permission is denied. Check: An inbox tab or screen exists, backed by a notifications table.
- Tapping a notification opens the exact screen through the payload's route (data.url, handled by expo-router), even from a closed app. A signed-out person goes to sign-in, then to that screen. Why: A push that only opens the home screen feels broken. Check: lib/push.ts handles both the response listener and getLastNotificationResponseAsync on a cold start.
- Notifications while the app is open, plus badges and grouping: setNotificationHandler shows an in-app banner, and nothing at all for the chat or order on screen; The app icon badge equals the inbox unread count: set from the payload, and updated with setBadgeCountAsync as items are read; Group notifications per conversation or order (iOS threadIdentifier, Android group); Opening an item clears its delivered notifications. Why: A badge stuck at 7 forever, or a banner for the chat being read, are classic amateur bugs. Check: Read the inbox to zero: the icon badge clears.
- Notification settings: One toggle per category (orders, reminders, messages, news and offers), stored on the server and honored by every send. A link to the phone's settings when push is denied. Marketing pushes need their own opt-in and are off by default. Why: Marketing needs opt-in plus an in-app opt-out (Apple 4.5.4). People keep notifications on when they can tune them. Check: Send code checks notification_prefs before every send.
- Push text carries no sensitive data: no health values, balances, codes, full addresses, or private message bodies unless previews are on. Use 'Time Sensitive' only for things happening within the hour. Why: Lock screens are public (Apple 4.5.4). Check: Push templates for the health, finance, chat and dating types use generic bodies.
- Reminders about the person's own items (a booking, a habit, an event) are local notifications, scheduled on the phone when the item is saved and cancelled when it changes. Schedule inexactly: no SCHEDULE_EXACT_ALARM or USE_EXACT_ALARM unless the app is an alarm or calendar app; iOS keeps at most 64 pending notifications, so reminder-heavy apps schedule in rolling windows. Why: Reminders work offline and need no server timer: the kit's edge program has none, and an agent's schedule runs at most every ten minutes and up to ten minutes late. Android 14+ denies exact alarms by default. Check: Each reminder keeps its id so it can be cancelled. The merged manifest has no exact-alarm permission.
- Email the important events through Harakumo Mail: receipts and booking confirmations; security notices: password changed, account deleted, new sign-in on a new device. When Sign in with Apple is used, send from the app's own domain, registered with Apple's private email relay. Why: Email is the record people keep. Mail to relay addresses bounces from senders that aren't registered. Check: The API mails a receipt for every paid order, and notices on password change and deletion. No mail goes to example.com, .test or .invalid addresses. On Harakumo: POST https://harakumo.com/api/mail/send with HARAKUMO_API_KEY, the key setup_app_backend pinned to the project. Own domain via add_mail_domain and set_mail_from.
- Android: a 96×96 white-on-transparent notification icon and the brand color in the expo-notifications plugin, and one channel per settings category, with a clear name. Why: Without the icon, Android shows a grey square. Channels let people mute one kind of notification. Check: The plugin has icon and color, and the channels match the settings categories.

## Settings and account

- A Settings screen, reached from a tab or the profile picture, with: profile (name, photo) and change password; notification settings, Face ID unlock status, and appearance (Android only); signed-in devices; Privacy Policy, Terms, Help and Support, Send feedback; Rate the app, Share the app, open-source licenses; the app version and build number; Sign out; Delete account. Why: Reviewers look for privacy, support and deletion inside the app, and people look here for every account task. Check: src/app/(tabs)/settings/index.tsx lists every row.
- How Settings looks: iOS: an inset-grouped list on the grouped background, 44 pt rows, optional icons in colored rounded squares, the value on the right, chevrons to drill down, Switch for toggles (never checkboxes), explanations in section footers; Android: full-width rows, section titles in the brand color, no chevrons, Material switches; Sign out sits in its own section. Delete account comes last, in red text, never as a filled button; The version is footer text that copies on long-press. Why: Settings built from stacked custom buttons look amateur, and a big filled red button is alarming. Check: Settings uses ListRow and SectionFooter from the kit.
- Privacy Policy, Terms and Help open the pages hosted on the app's Harakumo site in an in-app browser (expo-web-browser), with ?app=1. That version has no pricing or purchase links. Support offers a real way to reach the owner: a form and an email address. Why: Both stores need the privacy policy inside the app (Apple 5.1.1(i)) and real support contact (Apple 1.5). Purchase links inside the iOS app break Apple 3.1.1. Check: lib/links.ts points to https://<host>/privacy?app=1, /terms?app=1 and /support?app=1. check_launch finds all three live. On Harakumo: The app's worker.js serves the pages.
- Delete account inside the app, at most two levels deep (Settings → Account → Delete account). Say what will be deleted, and what is kept: order records required by law, stripped of identity, with how long they are kept. Confirm with the password or a fresh 6-digit email code ('Use a code instead'). Face ID is only a local gate on top. The server deletes the account and all its data. It is not a deactivation. Sign out and say when deletion is complete. People with a store subscription are told that billing continues until they cancel it in the store, with a button to the store's subscription page. Why: Apple 5.1.1(v) and Google Play require full in-app deletion. People who sign in with Face ID often don't know their password. Check: delete-account.tsx calls {API}/v1/account/delete. Signing in again with the same email fails. On Harakumo: The API deletes the user's rows, files and sessions, then removes the pool user with DELETE https://harakumo.com/api/auth-pools/<poolId>/users/<id> and HARAKUMO_API_KEY (the pinned full key has resource:update).
- A public web page at https://<host>/delete-account, reachable without the app. It: shows the app or developer name exactly as on the store listing; lists the steps; says which data is deleted and which is kept, and how long kept data is kept; lets the person ask by email code. Why: Play's account-deletion policy requires exactly these, and the link goes in the Data safety form. Check: check_launch delete-account-page passes. On Harakumo: Served by worker.js, running the same deletion flow verified by an email code.
- Sign out clears the tokens, the session row, the push token on the server, cached personal data and the query cache. Why: Shared and resold phones must not show the last person's data. Check: signOut() calls {API}/v1/auth/logout, deletes the secure-store tokens and clears the query cache.
- If the app sends personal data to AI (chat, summaries, photo analysis), show a one-time screen before first use. It: says what is sent, why, and who receives it: Harakumo as the processor, plus its AI model providers; has 'Allow' and 'Not now', and the app still works after 'Not now'. Consent is stored per user with a time, and a switch in Settings turns it off. Every generative feature has a Report action. Why: Apple 5.1.2(i) requires naming where personal data goes before asking. Play's AI-generated content policy requires in-app reporting. Check: An ai_consent row with a timestamp exists. AI routes refuse without it. Report exists on AI output. On Harakumo: The AI gateway is called only from the app's API, after the consent check.
- Apps that keep personal records (finance, health, journals) offer 'Download my data' as a file, emailed or shared from the phone. Why: It builds trust, and data-access requests in many countries expect it. Check: Settings has an export row for these types.
- Keep people informed about the app itself: A 'What's New' sheet once after a big update, skippable. A soft-update banner when app-config's latestVersion is newer. A maintenance screen when app-config says so, instead of errors everywhere. Help is an FAQ page on the app's site. Send feedback attaches the app version, the OS and an optional screenshot. Why: These are the small touches that make an app feel looked after. Check: {API}/v1/app-config returns minVersion, latestVersion, message and maintenance.

## Accessibility

- Every touchable element has an accessibilityRole and an accessibilityLabel that says what it does ('Add to cart'), and icon-only buttons always have one. On Android, the label doesn't include the word 'button', because TalkBack adds the role. Meaningful images get labels. Decorative ones are hidden. Why: Otherwise VoiceOver and TalkBack read nothing useful. Check: Every IconButton, and every row without a text child, has accessibilityLabel.
- Set accessibilityState on tabs, chips, switches, rows and loading buttons: selected, checked, disabled, expanded or busy. Why: Without it, toggles and tabs never announce their state. Check: Kit components pass accessibilityState from their props.
- Every gesture-only action (swipe, long-press, drag to reorder, double-tap to like, pinch to zoom) is also offered: as accessibilityActions; in a visible menu or button. Why: Screen-reader users can't reach gesture-only actions. Check: Complete every swipe or long-press action with VoiceOver alone.
- Sheets and dialogs set accessibilityViewIsModal. After navigating, focus moves to the screen's header. Why: Otherwise focus leaks behind sheets and lands in random places. Check: SheetHeader sets the modal and focus behavior.
- Reading order matches what is seen. A card's content is one element with a combined label (title, price, status). Section titles have accessibilityRole 'header'. Why: Screen-reader users move by headers and elements. Scattered pieces make lists unusable. Check: Cards set accessible plus a joined accessibilityLabel.
- Contrast is at least 4.5:1 for text and 3:1 for large text, icons and disabled labels, in light, dark and high-contrast modes, including pressed states. Never color alone: errors also have text and an icon, and statuses have words. Why: Low contrast fails outdoors and for low vision. Check: A theme unit test checks every token pair and variant.
- Errors, successes and loading are announced (AccessibilityInfo.announceForAccessibility). Loading indicators are labeled 'Loading'. Why: Otherwise screen-reader users never learn that a form failed. Check: FormField errors and Toast call announceForAccessibility.
- Respect display settings: Reduce Transparency: blurred surfaces become solid. Increase Contrast: high-contrast tokens. Bold Text. Smart Invert: photos and avatars set accessibilityIgnoresInvertColors. Reduce Motion (see Design). Android display size. Why: Glass and blur are unreadable for some people without these. Check: Test with each setting on, at the largest text and display size.
- Before calling it done, walk the main flow with VoiceOver and with TalkBack: sign up, the app's main task, settings, delete account. Answer Apple's Accessibility Nutrition Labels honestly, claiming only features that work across the main tasks. Why: Only a real pass finds the unlabeled button that blocks the flow. Check: The pass is noted in the README's test section.

## Data and files

- Uploads go straight from the phone to Harakumo storage: The app asks {API}/v1/uploads for a signed URL, sending the file's type and size; the API checks the user and their quota and signs a link under users/<id>/ with that size. It uploads with progress, cancel and retry. It posts {API}/v1/uploads/complete, and the API records the file. Shrink photos first (longest side about 2048 px, JPEG or WebP at about 0.8), and remove location data unless the app needs it. Why: Sending files through the API is slow. Full-size phone photos are 5–15 MB. A link signed with the file's size takes exactly that many bytes. Location in photos reveals where people live. Check: lib/upload.ts shows progress, cancel and retry. Keys are under users/<id>/. On Harakumo: The storage API's signed PUT links, asked for by the app's API with the file's type and size (the same links create_upload_url gives).
- Images use expo-image: memory and disk cache, blurhash or thumbhash placeholders, recyclingKey in lists, and thumbnails requested at the size they show at. Why: It stops lists from flickering, downloading again, or using too much memory. Check: No React Native Image component in list items.
- All server data goes through one TanStack Query cache, persisted so the last data shows offline. Every change invalidates the lists it affects. Why: One cache keeps screens in sync, and makes offline and pull to refresh simple. Check: lib/query.ts configures persistence. Every mutation calls invalidateQueries.
- Language and formats: All strings live in one locale file (expo-localization plus a small i18n helper), even with one language. Plurals use Intl.PluralRules. Styles use start and end, not left and right. Times are in the phone's time zone. Money uses Intl.NumberFormat with its currency. Dates are relative where it helps ('Tomorrow, 10:30'). Why: Hard-coded strings and left/right layouts make translation and right-to-left a rewrite. A booking in the wrong time zone is a missed appointment. Check: No string literals in JSX outside the locale file. Test with a pseudo-locale 40% longer, and with right-to-left when Arabic, Urdu or Hebrew is planned.
- Forms: Labels above inputs, never placeholder only. Required fields marked. Checks on blur and on submit, with messages next to the field. Drafts kept if the person leaves or the app closes, never for sensitive fields. Why: Lost drafts are among the most hated app behaviors. Check: Long forms save a draft locally.
- The server checks every input again, whatever the app sent, and uses parameterized SQL. Why: Anyone can call the API without the app. Check: Every /v1 route validates its body before touching the database.

## Security and privacy

- No secret in the app, ever. The bundle may hold only EXPO_PUBLIC_API_URL, and the pool's public client id when needed. Never HARAKUMO_API_KEY, the client secret, the JWT secret, APP_SESSION_SECRET, or payment or AI keys. Every EXPO_PUBLIC_ value is readable by anyone who downloads the app. Why: App bundles are easy to unpack, and a leaked project key exposes every user's data. Check: A search of the app folder for HARAKUMO_API_KEY, CLIENT_SECRET, JWT_SECRET, SESSION_SECRET, 'as_' and 'sk_' finds nothing. check_launch scans extra and EXPO_PUBLIC_ values. On Harakumo: Secrets live only in project environment variables, read by worker.js as env.NAME.
- Every sensitive call goes through the app's API. Every request: checks the access token (issuer, audience, expiry); checks the user owns what they read or change; validates the input. Sensitive routes (delete account, payments, admin) also check that the session row isn't revoked. Why: Checks done only in the app are bypassed with one curl command. Deleted or banned users must lose access quickly. Check: Every /v1 route except auth, app-config and webhooks starts with requireUser(). Every query filters by user_id.
- The API limits requests per user and per device: sign-ups, codes, sign-ins, writes and uploads. It returns 429 with a retry time, which the app shows kindly. Why: It protects the owner's bill, the mail quota and real users' accounts from bots. Check: The worker has per-route limits, kept in a small counter table until a platform limiter exists. On Harakumo: Not on Harakumo yet: the server sign-in path has no per-IP limit on logins or code requests (sign-ups are limited per pool, and per address when the server passes it), and edge programs have no counter store.
- Network and logs: Only HTTPS. Use hostnames, never IPv4 literals, so IPv6-only networks work. Never log tokens, passwords, codes or personal data. Error reports carry the app version and screen, not the person. Why: Apple tests on IPv6-only networks. Logs end up in places nobody protects. Check: A search for console.log with token, password or code finds nothing.
- Links into the app (universal links, app links, notification routes) are untrusted input. Check their parameters, and never pay, delete or send from a link without the person confirming. Why: Anyone can craft a link that opens the app. Check: [id] screens fetch by id and handle a 404. Nothing happens on open.
- Over-the-air updates: They fix bugs and wording only. New features go through store review. Never download and run code, and never run other apps inside this one (Apple 2.5.2 and 4.7). Freeze updates to the production channel while a build is in review. Why: Changing features through downloaded code is forbidden, and an update during review changes what the reviewer sees. Check: The README's release notes mark each change as a store build or an update.
- No tracking by default: no ad, attribution or third-party analytics packages, so no tracking prompt. If the owner adds one: show the App Tracking Transparency prompt in context (expo-tracking-transparency) with a purpose sentence, and no incentive before it; answer Tracking in the privacy labels; add Play's Advertising ID declaration. Otherwise block com.google.android.gms.permission.AD_ID. Why: Tracking without the prompt is rejected (Apple 5.1.2), and a missing usage string crashes the app. Check: check_launch with appConfig flags NSUserTrackingUsageDescription when there is no tracking package, and AD_ID when it isn't blocked.
- Export compliance: set ios.config.usesNonExemptEncryption to false for apps that only use HTTPS. A chat app that really uses end-to-end encryption needs the export filing instead. Why: Without it, every build waits in 'Missing Compliance'. Check: check_launch with appConfig checks the key.

## Payments

- Classify every paid item in the plan. Harakumo checkout is allowed for: physical goods; in-person services; live one-to-one services (tutoring, consultations, personal training); external purchase links on the US App Store only. The store's in-app purchase is required for: live group or online classes and streamed events; recorded content, premium features, credits, in-app currency, and tips on content. Add a Restore purchases button. Why: Apple 3.1.1 and 3.1.3(d), and Play's billing policy. Selling digital goods through web checkout gets the app rejected or removed. Check: The plan marks each item physical, in-person, one-to-one or digital. Digital items never call checkout. On Harakumo: create_checkout_link from the app's API. Harakumo can't check store purchases yet (not on Harakumo yet).
- Donations: Approved nonprofits may collect in the app with Apple Pay. Anyone else opens Safari with Linking.openURL, never an in-app browser, and the app is free. Why: Apple 3.2.2(iv). Check: Donation buttons use Linking.openURL unless the owner is an approved nonprofit.
- Paywall: The title, the length, and the price per period, with the billed amount as the most prominent price. Free-trial terms, and how renewal and cancelling work. Restore purchases. Working links to the Terms of Use (EULA) and the Privacy Policy. The App Store listing also links a Terms of Use. Why: Apple 3.1.2 and Play's subscriptions policy are among the commonest rejection causes. Check: check_launch with appSource finds the Terms and Privacy links and Restore on the paywall. store/listing.md has the EULA line.
- Checkout for physical goods and services: A native order summary first, then 'Opening secure checkout…'; Checkout opens with WebBrowser.openAuthSessionAsync and the https return URL, with the browser colored to the brand; /return/checkout hands off to the app (see web pages); The app refetches the order when the browser closes and when the app comes to the front; A native success screen with a success haptic. Why: Universal links don't fire reliably from inside an in-app browser, and https callbacks need iOS 17.4+. A buyer stuck on a web page after paying fails review. Check: Pay, close the browser at each step, and the order shows its true status.
- Store setup before the first paid build: Apple's Paid Apps Agreement, banking and tax are active. The first in-app purchases are submitted with the build (Ready to Submit, with a review screenshot). The Play merchant profile is set up. Why: Otherwise products don't load for the reviewer, which is a 2.1 rejection. Check: store/review-notes.md lists the products and their state.

## Engineering and releases

- Folder structure: src/app for routes (expo-router), src/components (the kit), src/lib (api, session, push, query, upload, i18n), src/theme, src/locales; TypeScript strict; Navigation imports only from expo-router. Why: Since Router v56, app code can't import @react-navigation/*, and assistants trained on older code still write those imports. Check: A search for '@react-navigation/' imports finds none.
- Use the latest stable Expo SDK, add packages with npx expo install, and commit the lockfile. Test on development builds. Why: Mismatched versions are the top build failure. Check: `npx expo-doctor` passes. expo-dev-client is installed.
- eas.json has development, preview and production profiles, with autoIncrement on production, the fingerprint runtimeVersion, and channels that match the profiles. The app id (com.brand.appname) is chosen with the owner once, and never changed after the first upload: lowercase segments of letters and digits, at least two; no hyphens or underscores, no Java keywords; at most 155 characters. Why: A new id creates a new app in the store. Hyphens break Android and underscores break Apple. Check: ios.bundleIdentifier and android.package match the store listings and pass the id check.
- Forced-update check: {API}/v1/app-config returns minVersion and latestVersion. Below minVersion, the app shows 'Update required' with a store button. minVersion is never set above the version live in the store. Why: Old app versions keep running for months. A minVersion above the approved build leaves the reviewer stuck. Check: The admin refuses a minVersion above the live store version.
- Crashes and errors: Each route group exports an ErrorBoundary that shows 'Something went wrong. Try again.'. Errors go to {API}/v1/client-errors with the app version, OS and screen, and no personal data. The admin lists them. Why: Without reports, the owner hears about crashes only from one-star reviews. Check: The _layout files export ErrorBoundary.
- Performance budget: Cold start to first content in under 2 seconds on a mid-range Android phone. Lists scroll at 60 fps. Images sized to how they display. No layout shift when data arrives. Why: Jank and slow starts read as amateur before anyone notices the design. Check: Test a release build on a low-end Android phone with a slow-network profile.
- Tests: the API's sign-in, deletion, permission and money paths at least, plus a smoke run on iOS and Android before each store build. Why: These are the paths where bugs cost money or leak data. Check: npm test passes before eas build.
- README sections: what the app is; how to run it on a development build, and its settings; how to build and submit, and the review account; 'What this app collects': every field and every service, including Harakumo, the Expo push service and EAS Update. Why: The owner fills in the stores' privacy forms from this list, and wrong answers get apps removed. Check: README.md has those sections.

## Store submission

- The owner's accounts come first, and the AI can't create them: the Apple Developer Program ($99 a year) and a Google Play Console account ($25 once); healthcare, financial and other regulated apps submit from an organization account, which needs a D-U-N-S number that takes days to weeks; new personal Play accounts need 12 testers opted in for 14 days of closed testing before production; Play developer identity checks, and Android developer verification for test APKs sideloaded in some countries (from late September 2026); Apple's EU trader status: a trader's address, phone and email become public. Reserve the App Store name early, since names are unique. Why: These are the longest waits in a launch, and they surprise individual owners. Check: The plan's launch section names both accounts, the account type, the closed-test start date and the trader choice.
- Listing text rules: name 30 characters, subtitle 30, keywords 100, promotional text 170, descriptions 4000; Play short description 80; in the name, icon and developer name: no 'best', '#1', 'top', 'free', 'new', prices, emoji, all caps or other apps' names; never mention the other platform. Why: Apple 2.3.7 and 2.3.10, and Play's metadata policy. AI-written listings often break these. Check: store/listing.md has every field within its limit, and passes the banned-word check.
- Listing images: a Play icon 512×512 (32-bit PNG, at most 1024 KB) and a 1024×500 feature graphic; framed screenshots: a headline of at most 6 words, a device frame, a clean status bar (9:41, full signal), seeded data, with the first three telling the core story; iPhone 6.9-inch 1320×2868 or 1290×2796; iPad 13-inch 2064×2752 when iPad is on; Play: at least 2 phone shots, long side at most twice the short side; no sign-in or splash screens. Why: Raw screen grabs look amateur, and wrong sizes or ratios block the upload. Check: The store/screenshots folder passes the size and ratio check.
- The privacy policy is generated from 'What this app collects'. It covers: what is collected, how, and every use; each third party that receives data (Harakumo, the Expo push service, the AI providers, the payment processor) and that they protect it the same way; retention and deletion, and how to withdraw consent; the developer's legal name and a privacy contact. It is a public HTML page labeled Privacy Policy: not a PDF, not geo-blocked, and it matches the Data safety answers. Why: Apple 5.1.1(i) and Play's User Data policy. Boilerplate AI-written policies are a frequent Play rejection. Check: check_launch privacy-page finds the app name, the developer name, a contact, and third-party and retention sections.
- Privacy answers and manifest: Fill Apple's App Privacy answers and Play's Data safety form from the README, including the deletion web link. Ship a default ios.privacyManifests block: UserDefaults CA92.1, FileTimestamp C617.1, SystemBootTime 35F9.1, DiskSpace E174.1, NSPrivacyTracking false and NSPrivacyCollectedDataTypes. Extend it for any other required-reason API. Why: An incomplete manifest fails with ITMS-91053. Answers that don't match the app are grounds for removal. Check: store/privacy-answers.md matches the README. check_launch with appConfig checks the manifest's content.
- Answer every declaration. Play App content: content rating, target audience, ads, Data safety, financial features, health apps, Advertising ID, news apps, government apps. Apple: the age rating (13+, 16+ and 18+ tiers, plus the social-media questions), Content Rights, Accessibility Nutrition Labels, and App Review contact details. Use defaults worked out from the app type. Why: Play won't send an app to review until every declaration is done, and an unrated app isn't shown. Check: store/listing.md records every answer.
- Review access from anywhere: a demo account with realistic data, put in App Store Connect review notes and in Play's App access, with steps past any email code; a demo address inside the delivery zone, a provider with open slots for the next 30 days, upcoming events, and a test payment path; the API exempts the review account from location limits; role-gated tabs (admin, organizer, staff) are described, with a demo account for each; reviewers are invited to create a new account to test deletion, and the demo account is recreated automatically after deletion. The backend stays on through review. Why: Apple 2.1(a) and Play's App access. Zones, empty calendars, invite-only sign-up and hidden features (2.3.1) are top rejections. Check: store/review-notes.md has every account and step. Sign in to every demo account yourself; check_launch with testSignIn proves sign-up and sign-in work. On Harakumo: A seeded review user in the pool, sample rows, and a review flag on the profile row.
- Apps where people post or message: terms with zero tolerance for objectionable content, accepted before the first post; a filter for words and images; Report on every post, comment, profile and chat; Block takes effect at once, hides the blocked user's content, and notifies the owner; reports are handled within 24 hours, by removing the content and the user, from a moderation queue sorted by age; published contact details. Why: Apple 1.2 and Play's user-generated content rules. A missing report or block is an automatic rejection. Check: Report and Block exist everywhere listed. /admin/reports exists.
- Age: Apps minors may use get the declared age range (Apple's Declared Age Range API, Google Play Age Signals), ask for parental consent where state laws require it, and handle significant changes and withdrawn consent. Check the current list of states. Apps for children follow the Kids rules: a parental gate before links out and purchases (Apple 1.3), no third-party ads or analytics, and Play's Families policy with only self-certified SDKs. Why: US state laws now require age assurance, and Kids violations get apps removed. Check: The community, chat, education, dating and AI types record their age handling in store/listing.md.
- Minimum functionality and other rules: Real native screens, not a website in a WebView. No crashes on the latest iOS and Android. Never require ratings, watching ads or installing other apps to unlock features (3.2.2). Show the odds for any random paid item. Contests need official rules and must say Apple isn't a sponsor (5.3). Ask before large downloads, showing the size (4.2.3). Content is owned or licensed (5.2), and store badges follow their brand guides. Why: Apple 4.2 rejects web wrappers and thin apps, and the others are common rejections. Check: No WebView renders main content.
- Builds meet the current store minimums: Xcode 26 and the iOS 26 SDK (the iOS 27 SDK from April 2027); Android target API 36; 16 KB memory page support. The latest stable Expo SDK meets all three. Why: Uploads that miss them are refused, and Play hides apps that target an old API. Check: The EAS build log shows targetSdkVersion 36.
- Test tracks: TestFlight internal testers first. External testers need Beta App Review, plus test information: a feedback email and the privacy policy. Play internal testing, then the closed test. Put test sign-in details in the settings of Play's pre-launch report. Why: Missing beta information blocks external testers, and the pre-launch crawler stops at sign-in. Check: The README's release section lists the tester groups.
- Release flow: EAS Build (production). EAS Submit to TestFlight and Play internal testing. Real-phone tests. Submit for review. A staged rollout on Play, watching client errors in the admin. Leave time for a rejection and resubmission, and for Play's first review. Why: Testing first catches crashes before a reviewer does. Check: The README lists the commands and the rollout steps.

## The app's web pages on Harakumo

- The same worker.js serves the app's web side: a landing page with store badges once live; /privacy, /terms, and /support with a contact form and email; /delete-account; /return/checkout; an ?app=1 version of the legal pages, without purchase links. They follow the website standard: tab icon, phone layout, footer. Why: Both stores need privacy and support URLs, and Play needs a deletion URL. Check: check_launch passes privacy-page, terms-page, support-page and delete-account-page. On Harakumo: https://<slug>.harakumo.app or a connected domain.
- Deep-link files at /.well-known/apple-app-site-association (no extension) and /.well-known/assetlinks.json: application/json, over HTTPS, with no redirect; built from the Apple team id and bundle id, and from the Android package plus the SHA-256 of the Play App Signing key and the EAS test key; the Apple file includes webcredentials for Password AutoFill. Why: Universal links, app links, password autofill and checkout returns all depend on them. A wrong content type or a redirect breaks them silently. Check: check_launch aasa and assetlinks are valid. On Harakumo: Built by worker.js from env.APPLE_TEAM_ID, IOS_BUNDLE_ID, ANDROID_PACKAGE and ANDROID_SHA256.
- If the app's domain is the owner's own (connect_domain), the associated domains use that domain, and the files are served there. Why: iOS checks the file on the exact domain, and only at install and update. Check: The ios.associatedDomains hosts equal the project's live host.
- /return/checkout redirects to the app's scheme and shows a visible 'Back to the app' button. Only the checkout's return URL has to be https. Why: Universal links often don't fire on a redirect inside an in-app browser. Check: Opening /return/checkout?order=1 on a phone opens the app.

## Admin dashboard

- The owner manages the app's data in a web admin on the same Harakumo project, built with the website standard's admin rules: the same database and pool, with the admin role checked on the server for every request. Build it whenever the app has users, orders, bookings, posts or messages. Why: Owners run the business from a computer. Check: /admin answers 403 for a normal user and 200 for an admin.
- Phone-specific admin pages: users, with sign-up and active counts; push opt-in rates; send a notification to a group: preview, confirm, and a count of who receives it; deletion requests and their status; client errors by app version; minVersion and the maintenance message. Why: These are the questions an app owner asks in the first week. Check: The overview shows today, 7 days and 30 days.
- A moderation queue (reported posts, users and messages, with hide, ban and restore) for any app where people post or message. Why: The stores require reports to be acted on within 24 hours. Check: Reports appear in /admin/reports within a minute.
- When the owner works from a phone, add an admin tab shown only to admins, with the same server checks. Describe it in the review notes and give an admin demo account. Why: Hiding a tab isn't security, and an undisclosed feature breaks Apple 2.3.1. Check: The admin API routes check the role. store/review-notes.md lists the tab.

## Kinds of phone app

Each kind adds its own layout, screens, features and store notes. An assistant asks for one with get_build_standard (platform 'mobile').

| Kind | Layout | First screens |
| --- | --- | --- |
| Bookings and services (booking) | Style: Clean. Tabs: Explore, Bookings, Inbox, Account. Booking is a flow stepper (service → time → confirm). Reschedule and cancel open as sheets. Archetypes: Home dashboard, Detail with hero, Agenda, Flow stepper, Settings. | Services with prices, durations and photos; Provider profile with reviews; Date strip and open slots in the person's time zone, with the venue's zone shown when it differs |
| Shopping and online store (store) | Style: Clean or Bold. Tabs: Shop, Search, Cart (with a badge), Account (orders inside). Browsing is a stack. Filters and variants are sheets. Checkout is a modal flow stepper. Archetypes: Home dashboard, Catalog grid, Detail with hero, Search, Flow stepper. | Home with categories, featured items and recently viewed; Catalog grid with a filter and sort sheet that shows the result count ('Show 124 results'); Product detail: a pinch-zoom gallery, variants, stock, reviews, a size guide, and a sticky Add to cart bar |
| Food and delivery (food-delivery) | Style: Bold. Tabs: Home, Search, Orders, Account. Menu and cart are a stack. Item options are a sheet. A floating cart bar sits above the tab bar. Live status is a full screen that opens from a push. Archetypes: Home dashboard, Catalog grid, Detail with hero, Flow stepper, Map and list. | Address picker (saved addresses, current location in use); Stores nearby, with open, closed and out-of-zone states; Menu with categories and diet and allergen tags |
| Social and community (community) | Style: Clean or Editorial. Tabs: Feed, Discover, Notifications, Profile. Compose is a header or floating button that opens a sheet, never a tab. Archetypes: Feed, Detail, Profile, Form sheet, Search. | Feed with paging, pull to refresh and a 'New posts' pill; Post detail with comments and replies; Compose: photo picker with crop and reorder, an alt text prompt, a character count, mention autocomplete and drafts |
| Chat and messaging (chat) | Style: Clean. Tabs: Chats, People, Settings. A conversation is a full-screen stack with an inverted list and a composer that sticks to the keyboard. Group info and the media viewer are sheets. Archetypes: Chat thread, Search, Settings. | Conversation list with unread badges, last message and time; Chat: bubbles grouped by sender and time, day separators, a 'New messages' divider, a jump-to-latest button with the unread count, sending, sent and failed states with retry, older messages loaded on scroll up; New chat and people search |
| Fitness and wellness tracker (fitness) | Style: Bold. Tabs: Today, Progress, Library, Profile. Logging is a quick sheet. Timers and players are full screen. Archetypes: Home dashboard, Player, Form sheet, Agenda. | Today with goals and a setup checklist; Log entry with fast inputs; Progress charts by week and month, with a 'log more to see trends' state |
| Education and courses (education) | Style: Clean or Editorial. Tabs: Learn, Courses, Progress, Account; schools add Timetable and Notices. The lesson player is full screen. Quizzes are a stack. iPad support on. Archetypes: Home dashboard, Catalog grid, Detail, Player, Agenda. | Continue learning; Catalog with search and filters; Course detail with its lessons and progress |
| Budget and expense tracker (finance) | Style: Utility. Tabs: Overview, Transactions, Budgets, Settings. Add transaction is a sheet with an amount keypad. Archetypes: Home dashboard, Feed (list), Form sheet, Settings. | Overview: balance, spending by category (chart) and recent items; Transactions with search, filters and paging; Add or edit: amount keypad with the currency, category, date, note, receipt photo |
| Events and tickets (events) | Style: Bold. Tabs: Discover, Tickets, Saved, Account. Organizers get a Scan tab, disclosed in the review notes. Archetypes: Catalog grid, Detail with hero, Flow stepper, Map and list. | Events by date and place, with filters; Event detail: venue, lineup, price and refund policy; Ticket selection and checkout |
| Notes, tasks, habits and journals (productivity) | Style: Clean. Tabs: Today, Lists, Search, Settings. The editor is full screen and saves as you type. Quick add is a floating button. iPad support on, with a sidebar. Archetypes: Home dashboard, Feed (list), Form sheet, Search, Agenda. | Today: due tasks and habit check-offs; Lists or notebooks; Editor that saves as you type |
| Content, brand or creator app (portfolio) | Style: Editorial. Tabs: Home, Explore, Saved, About. Content detail is a stack. Audio uses a mini player above the tab bar. Archetypes: Feed, Detail with hero, Player, Search. | Home feed of the newest content; Article, video or episode detail with reader text size and progress; Categories and search |
| Internal team tool (internal-tool) | Style: Utility. One tab per main job (Tasks, Scan, Records) plus Profile, or a single stack. Invite-only sign-in. Archetypes: Feed (list), Detail, Form sheet, Search. | My assigned tasks; Records with search and filters; Record detail and edit form |
| AI assistant app (ai-app) | Style: Clean. Tabs: Chat, History, Account, or one chat screen with a history drawer. Archetypes: Chat thread, Search, Settings. | Chat with streaming answers in markdown (code blocks with Copy, lists, tables), Stop and Regenerate; History of past chats with search; AI data consent before first use |
| Companion app for a web product (saas) | Style: Utility or Clean. Tabs for the product's main objects (Projects, Inbox, Reports) plus Account. Billing stays on the web. Archetypes: Home dashboard, Feed (list), Detail, Settings. | The product's main lists and details; Quick create; Notifications inbox |
| Two-sided marketplace (marketplace) | Style: Clean. Tabs: Browse, Search, Inbox, Account. Sell is a floating button that opens a listing sheet. Archetypes: Catalog grid, Detail with hero, Form sheet, Chat thread, Search. | Listings grid with filters and saved searches; Listing detail with photos, price, seller rating and Make offer; Create listing: photos, title, price, category, area |
| Dating and meeting people (18+) (dating) | Style: Bold. Tabs: Discover, Matches, Chats, Profile. Archetypes: Detail with hero (cards), Chat thread, Profile, Form sheet. | Profile setup with photos and prompts; Discover cards with Like and Pass buttons (swipe as a shortcut); Matches |
| Listings and rentals (rentals) | Style: Editorial or Clean. Tabs: Explore, Saved, Bookings, Inbox, Account. Archetypes: Map and list, Catalog grid, Detail with hero, Flow stepper, Agenda. | Search with a map and list toggle; Listing detail: gallery, amenities, map preview, availability calendar, reviews; Request or book with dates and a price breakdown |
| Travel and itineraries (travel) | Style: Editorial. Tabs: Trips, Explore, Saved, Account. Archetypes: Agenda, Detail with hero, Map and list, Home dashboard. | Trips list; Itinerary by day; Place detail with a map preview, hours and notes |
| Music and audio player (audio) | Style: Bold or Editorial. Tabs: Home, Search, Library, with a mini player above the tab bar and a full-screen player. Archetypes: Home dashboard, Catalog grid, Player, Search. | Home with continue listening; Album, playlist, show or book detail; Full player: scrubber, speed, sleep timer, queue |
| Loyalty and rewards (loyalty) | Style: Bold. Tabs: Home (stamp card), Rewards, My code, Account. Staff get a Scan screen, disclosed in the review notes. Archetypes: Home dashboard, Detail, Catalog grid. | Stamp card or points balance; My QR code for the counter (bright, works offline); Rewards and coupons with expiry |
| Clinic patient app (telehealth) | Style: Clean. Tabs: Home, Appointments, Messages, Records, Account. Face ID lock on reopen. Archetypes: Home dashboard, Agenda, Flow stepper, Chat thread, Detail. | Next appointment card; Book with a doctor (open slots); Visit detail with the clinic's meeting link |
| Driver or courier companion app (driver) | Style: Utility. Tabs: Jobs, Active, Earnings, Account. The active job is a full screen that hands off to the maps app. Archetypes: Feed (list), Detail, Map and list. | Available and assigned jobs; Active job: pickup and drop-off steps, call and message; Proof of delivery (photo or code) |

## Style presets

- Clean: system font, radius 12, color only on actions.
- Bold: large display type, full-bleed color headers, radius 20.
- Editorial: serif headings, warm surfaces, image-led.
- Utility: compact rows, radius 8, dense; for internal and finance apps.

## Screen archetypes

- Home dashboard: greeting, one main card, one or two horizontal carousels, a recent list.
- Catalog grid: two columns, fixed 4:5 images.
- Feed: a paged list of posts or items, pull to refresh.
- Detail with hero: stretchy image, sticky bottom action bar.
- Form sheet: a sheet with detents and a SheetHeader.
- Settings: grouped list.
- Profile: header, stats, segmented content.
- Chat thread: inverted list, composer stuck to the keyboard.
- Map and list: a map and a list that mirror each other, with a toggle.
- Agenda: calendar strip and the day's list.
- Flow stepper: checkout or booking, one step per screen, progress at the top.
- Player: full-screen media or timer.

## The backend on Harakumo

- One Harakumo project per app. Its edge program is one worker.js at https://<slug>.harakumo.app: the only server the app calls (under /v1/) and the app's web side. Edge programs upload one module with no bundling, so worker.js imports nothing.
- Settings arrive as env.NAME and take effect on the next deploy. setup_app_backend with platform 'mobile' stores HARAKUMO_API_KEY (pinned to the project), the HARAKUMO_AUTH_* settings, HARAKUMO_DATABASE_ID, HARAKUMO_BUCKET_ID and a random APP_SESSION_SECRET. The owner adds APPLE_TEAM_ID, IOS_BUNDLE_ID, ANDROID_PACKAGE and ANDROID_SHA256. The app bundle knows only EXPO_PUBLIC_API_URL.
- Sign-in: the app posts to {API}/v1/auth/<action>; worker.js calls the project's sign-in pool with the client secret, then starts its own device session (a 1-hour access token signed with APP_SESSION_SECRET and a rotating refresh token stored hashed).
- Change password: worker.js checks the current password with a pool login, sets the new one with the pool's admin reset-password action and the project key, and revokes the other sessions. The name and photo live in the app's profiles table.
- Account deletion: the app posts {API}/v1/account/delete with the password or a 6-digit code mailed by {API}/v1/account/delete-code. worker.js deletes the user's rows, files under users/<id>/ and sessions, removes the pool user with the project key, and mails a confirmation. The /delete-account web page runs the same flow, verified by an email code.
- Database: every row carries user_id from the session and every query filters on it. Lists page by cursor (created_at plus id, 20 at a time) with an index on (user_id, created_at).
- Files: worker.js signs an upload link for users/<id>/… with the file's type and size, so it takes exactly that file; the phone uploads straight to storage with progress.
- Mail: receipts, notices, support messages and deletion codes go through Harakumo Mail with the project key. Sign-in codes come from the pool under the project's sender name. Reserved test addresses (example.com, .test, .invalid) are never mailed.
- Push: the app registers its push token with {API}/v1/push/register; worker.js keeps it in devices, checks notification_prefs, writes the inbox row and sends. Two ways to deliver: Harakumo's push API, where the app registers each phone's own device token with the sign-in pool (the register-push action, with the user's token) and the server sends to users by id (POST /api/push/<poolId>/send with the client secret) using the owner's APNs key and Firebase service account, saved once on the pool's Auth page; or the Expo push service with an Expo push token, which this kit's worker.js uses, since its own sessions hold no pool token.
- Payments: worker.js creates a Harakumo checkout link for physical goods, in-person and live one-to-one services, returning to https://<host>/return/checkout, which hands back to the app. Digital goods use the store's in-app purchase.
- AI: the app never calls AI directly. worker.js checks the user's consent, then calls the Harakumo AI gateway with the project key.
- Web pages: /, /privacy, /terms and /support (each also with ?app=1), /delete-account, /return/checkout, and the two /.well-known deep-link files served as application/json with no redirect.
- Setup order: create_project; setup_app_backend (platform 'mobile'); get_starter_files (platform 'mobile': the index, then the parts); set the four app-link settings; deploy_files on the api folder only; check_launch (platform 'mobile' with the introspected config); the owner sets push credentials (in EAS for the Expo push service, or on the pool's Auth page for Harakumo's push API), then builds and submits.

## Not on Harakumo yet

- Push: Harakumo's push service has no topics, no send-to-everyone, no scheduled sends, no delivery receipts and no per-category opt-outs yet; the kit's API keeps the categories, each person's choice and the inbox itself.
- Sign in with Apple or Google in the kit: sign-in pools take Apple and Google ID tokens (the sign-in-with action), but the kit's API does not call it yet; wire it there when the app offers either.
- Refresh tokens from pools in the kit: the kit's API does not use them yet, and pool tokens last 7 days by default, so it runs its own sessions (1-hour access tokens and rotating refresh tokens).
- Pool account actions in the kit: pools let people change their password and email and delete their account themselves, with a hosted deletion page, but the kit's API still does these with the project key and has no email change yet.
- Abuse limits on the server sign-in path: pools limit sign-ups from a server (600 an hour per pool, and per address when the server passes it), send at most 5 codes of each kind an hour to one email address, and lock an account for 15 minutes after 10 wrong passwords, but put no per-IP limit on server logins or code requests, so the app's API limits sign-ups, logins and codes per IP itself.
- Checking App Store or Google Play purchases on the server.
- Scheduled runs for the app's API: an agent can run on a schedule, but the kit's edge program cannot, so reminders are scheduled on the phone.
- Realtime channels: chat polls while a conversation is open.
- Deleting a folder in storage: the app's API deletes a person's files one call each.
- Database calls from the app's API share the Harakumo API's rate limits; on a 429, wait as long as it says.

## Done checklist

- The icon (no alpha; dark, clear and tinted variants; Android adaptive with monochrome), a home-screen name of 12 characters or fewer, and a launch screen with a dark variant. Why: Required by both stores. Check: Install a preview build and check the home screen and a cold start.
- The app is clearly this business's own: its brand, copy, screenshots and one tailored feature, with no starter defaults left. Why: Apple 4.3 and 4.2.6. Check: check_launch with appSource reports no starter defaults.
- Onboarding is skippable and shown once. Permissions are asked in context, with specific purpose sentences. Unused permissions are removed, checked on the introspected config. Why: Store privacy rules. Check: check_launch with appConfig shows no permission warnings.
- Sign-in: the welcome screen, confirm password, eye buttons, verification and reset by code, Face ID unlock, sessions that refresh, tokens in secure storage, and Sign in with Apple when any social sign-in exists. Why: Complete sign-in is what builders keep asking for. Check: check_launch with testSignIn passes. No token is in AsyncStorage.
- Every data screen has skeleton, empty, error and offline states, pull to refresh and paging. Bottom space, the keyboard and the safe areas are right on the smallest phone and on an iPad. Why: Apple 2.1 and 2.4.1. Check: Airplane mode, the API stopped, a 1000-item list, and an iPad all tested.
- Kit components, the style preset and the archetypes are used throughout. Dark mode, 200% text, 44 pt and 48 dp targets, Reduce Motion and Reduce Transparency all work, and VoiceOver and TalkBack labels and states are set. Why: Professional design and accessibility. Check: The visual pass in qa/ is complete.
- Push is asked in context, the credentials are set, a tap opens the right screen with Back working, the inbox and badge match, settings per category work, and the important events are emailed. Why: Both stores' notification rules. Check: Send a test push to the review account on both platforms.
- Settings has every row. Delete account really deletes, accepts the password or a code, and explains what is kept. The /delete-account page has all four elements. Why: Apple 5.1.1(v) and Play account deletion. Check: Delete a test account, then sign in again: it is refused.
- No secret is in the app. Every sensitive call goes through the API with token, session and ownership checks, and rate limits. Why: Security. Check: The secret search finds nothing, and the API tests pass.
- Payments follow the classes in the plan, and the paywall is complete. Why: Apple 3.1 and Play billing. Check: No digital item calls checkout.
- The store pages and both deep-link files are live. The privacy policy matches the README. Every declaration is answered. The review accounts work from anywhere. The workspace is on a paid plan. Why: Store submission. Check: check_launch with platform 'mobile' reports mustFix 0.
- The web admin exists whenever the app has users or data, with server-side role checks and a moderation queue for posting apps. Why: Owners must run the app without code. Check: /admin answers 403 for a normal user and 200 for an admin.

---

# API keys & authentication

Send the credential in the Authorization header: `Authorization: Bearer hk_live_…`. The same header works for REST, the SDK, the CLI and the MCP connector.

Owners and admins create keys in Dashboard → Settings → API access. A key is shown once and stored only as a hash. It works until it is revoked, unless you give it an expiry date, and an owner or admin can pause it without revoking it (below). Revoking takes effect immediately.

## Kinds of key

| Key | Acts as | Can |
| --- | --- | --- |
| Full, whole workspace | developer | Create, change and delete resources in every project, deploy, send mail, use AI, buy domains from the Balance. It can also delete projects. |
| Full, one project | developer | The same, but only on that project's endpoints, plus sending mail as that project and calling AI for it. Workspace-wide endpoints refuse it. |
| Read-only, whole workspace | viewer | GET requests, plus reads sent as POST (vector query, memory search, storage download links). No environment variables, no SQL, no uploads. |
| Read-only, one project | viewer | Reads on that project only. |

> No API key can manage billing, members or other keys, or move money out: withdrawals, refunds and payout setup need the workspace owner signed in to the dashboard.

## Which key to give what

| Use | Key |
| --- | --- |
| Your laptop, CI, setup scripts | Full, whole workspace |
| A deployed app that uses its database, storage, payments, vectors, memory or auth pool | Full, one project |
| A deployed app that also sends mail or calls the AI Gateway | Full, one project: it sends mail and calls AI for its own project, paid for by the workspace |
| Dashboards, monitoring, read-only AI assistants | Read-only |

## Where a project key works

A project key passes every route that names its project: `/api/projects/<id>/…`, and item routes whose resource belongs to it — `/api/databases/:id`, `/api/storage/:id`, `/api/functions/:id`, `/api/vectors/:id`, `/api/memory/:id`, `/api/agents/:id`, `/api/payments/:accountId`, `/api/auth-pools/:id`, `/api/deployments/:id`.

It also sends mail (`POST /api/mail/send`) and calls the AI Gateway (`/api/ai/chat`, `/api/ai/playground`, `/api/ai/v1/chat/completions` and `/api/ai/embeddings`) for its own project. Leave `projectId` out (for AI, also the X-Harakumo-Project header) and the call acts for the key's project; naming another project is refused. The email quota and the AI spend are the workspace's, as with any key, and a read-only key can neither send mail nor spend on AI.

It is refused, with 403 "This API key only works on project N", on workspace-wide routes: listing projects, the rest of `/api/mail/*` (messages, settings, suppressions and sending domains), `/api/ai/requests`, `/api/domains/*`, `/api/repos/*`, `/api/usage` and billing.

Its mail goes out only as its own project. It sends from an address on the project's own sending domain (the domain of the project's saved From address) or from the shared no-reply; another domain of the workspace answers 403 `from_not_project_domain`. It never sends under another project's name or sender name, or a close variant of one (a name that contains one of five letters or more, or is one letter off one of six or more): 403 `sender_name_other_project`. A variant of a name its own project's name already contains is its own ("Fruitly API" may send as "Fruitly Receipts"), but another project's exact name never is. The same rules apply when it changes its project's `mailFrom` or `mailSenderName` with `PATCH /api/projects/:id`. To move a project to a new sending domain, a person sets its From address on the Mail page.

## Create a key with the API

**curl (an owner or admin session or key)**

```bash
curl -X POST https://harakumo.com/api/orgs/<workspaceId>/api-keys \
  -H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
  -d '{ "name": "notes-app production", "access": "full", "projectId": 12 }'
# 201 → { "key": { "id", "prefix", "access", "project_id" }, "secret": "hk_live_…" }   (secret shown once)
```

> Creating keys needs the admin role, so an API key (developer or viewer) cannot mint more keys. Use the dashboard, or an admin's connector.

## Expiry and pause

- Give a key an end date: `PUT /api/orgs/:id/api-keys/:keyId/expiry { days }` (1 to 730, counted from now) or `{ expiresAt }` (a date within 730 days); `null`, or `DELETE` on the same path, means it never expires. Past the date every request with it is refused with 401 `key_expired`. An expired key given a new date works again.
- Pause a key that may have leaked but that an app you cannot update yet still holds: `POST /api/orgs/:id/api-keys/:keyId/pause`. Every request with it is refused with 403 `key_paused` until `DELETE` on the same path resumes it. Nothing else about the key changes.
- Both need an owner or admin, answer the key with its state (active, expired, paused or revoked), and are written in the workspace's audit log. The same controls are in Settings → API access and in an admin's AI connector (set_api_key_expiry, pause_api_key).
- A key made by a person whose account Harakumo has suspended is refused too (403 `key_creator_suspended`) until the account is restored. Create a new key for the app.

## Other credentials

| Credential | Where it comes from | Acts as |
| --- | --- | --- |
| Browser session | Signing in to the dashboard; an httpOnly cookie, 30 days | You, with your role in the chosen workspace |
| Connector token (hk_mcp_…) | Connecting Claude, ChatGPT or another MCP client (OAuth) | You, read-only or read-and-manage, in one workspace or all of them |
| Auth-pool client secret (as_…) | Enabling an auth pool | Only that pool's sign-in actions — never anything else |
| Auth-pool client id (ap_…) | Enabling an auth pool | Publishable: browser sign-in for that pool, when turned on |

## Choosing a workspace

A key belongs to one workspace. A session or an all-workspaces connector acts in the workspace named by `?org=<id>` or the `x-harakumo-org` header, else the one last chosen in the dashboard.

## When a key is refused

| Status | Message | What to do |
| --- | --- | --- |
| 401 | Unauthorized | The header is missing, or the key was revoked or mistyped. |
| 401 `key_expired` | This API key expired on … | Give the key a new date, or create a new key. |
| 403 `key_paused` | This API key is paused | An owner or admin resumes it in Settings → API access. |
| 403 `key_creator_suspended` | This API key was created by an account that is suspended | Create a new key; nothing has been deleted. |
| 403 | This API key is read-only | Use a full key for writes. |
| 403 | This API key only works on project N | Use a key for that project, or a workspace key for workspace-wide routes. |
| 403 | Your role (developer) does not allow this action | The action needs an admin or owner (members, keys, billing). |
| 404 | Not found | The id does not exist or belongs to another workspace — the two look the same on purpose. |

## Reference

**SDK**

```js
import Harakumo from '@harakumo/sdk';
const hk = new Harakumo({ apiKey: process.env.HARAKUMO_API_KEY });   // baseUrl defaults to https://harakumo.com
// Errors throw HarakumoError with .status and .body (the JSON the API sent).
```

**CLI**

```bash
harakumo login --key hk_live_…     # or set HARAKUMO_API_KEY; --key and --url work on every command
harakumo whoami
```

**REST**

```http
Authorization: Bearer hk_live_…
GET    /api/orgs/:id/api-keys                 # list (admin+); secrets are never returned
POST   /api/orgs/:id/api-keys                 { "name", "access": "full" | "read", "projectId"? }
PUT    /api/orgs/:id/api-keys/:keyId/expiry   { "days": 90 } | { "expiresAt": "2027-01-31" } | { "days": null }
DELETE /api/orgs/:id/api-keys/:keyId/expiry   # never expires
POST   /api/orgs/:id/api-keys/:keyId/pause    # 403 key_paused until DELETE resumes it
DELETE /api/orgs/:id/api-keys/:keyId          # revoke
```

---

# Limits & scaling

A plan is bought by one workspace, can cover several of its owner's workspaces (Pro 3, Team 10 and Scale 25), and limits how many things you create and the monthly allowances. Free limits are per person, across all your free workspaces. A paid plan's limits are shared across the workspaces it covers. A Pro subscription from before these plans keeps what Pro included then (100 GB of files, 10 GB of database storage, 5,000 emails a month, $10 of AI usage a month included and server apps with no hourly charge), shared across the workspaces it covers, until we move it to Pro as listed here; we email the owner first. The technical limits below apply on every plan. File storage, database storage and build minutes have an amount included per plan; bandwidth and function traffic have no plan quota yet — they are listed as not metered.

## Plans

|  | Hobby | Pro | Team | Scale |
| --- | --- | --- | --- | --- |
| Price (per subscription) | Free | $20 a month | $100 a month | $500 a month |
| Workspaces covered (limits shared) | Per person | Up to 3 | Up to 10 | Up to 25 |
| Usage credit | None | None | $20 a month | None |
| AI usage | Pay as you go from your balance | Pay as you go from your balance | Pay as you go from your balance | Pay as you go from your balance |
| Emails (rolling 30 days) | 100 | 2,000 | 10,000 | 100,000 |
| Custom sending domains | 1 | 10 | 25 | 50 |
| Apps and sites (projects) | 3 | 20 | 100 | 500 |
| Server apps | Pro and up | Included | Included | Included |
| Functions | 5 | 50 | 100 | 100 |
| Databases (10 GB each) | 1 | 10 | 50 | 100 |
| Storage buckets | 2 | 20 | 100 | 500 |
| Vector collections | 1 | 10 | 25 | 100 |
| Memory stores | 2 | 20 | 100 | 500 |
| Agents | 2 | 20 | 100 | 500 |
| Auth pools | 1 | 10 | 50 | 100 |
| Payment accounts | 1 | 10 | 50 | 100 |
| Media assets (videos and live channels) | Pro and up | 100 | 500 | 1,000 |
| Repos | 5 | 50 | 200 | 500 |
| Your own domains connected | 2 | 20 | 100 | 500 |
| Team members | 3 | 10 | 25 | 100 |
| Workspaces you can own | 2 | 10 | 20 | 50 |
| AI requests a minute | 60 | 600 | 2,000 | 6,000 |
| Agent runs at once | 1 | 5 | 10 | 20 |

> Past a count the create call answers 402 with code plan_limit, the limit and the current count. On Hobby the count is the total across every free workspace the owner owns (the answer carries shared_workspaces); on a paid plan it is the total across the workspaces its subscription covers (the answer says how many share it), except team members, which are counted per workspace. Upgrading a free workspace, or covering it with a plan you pay for, moves it to that plan's limits. Server apps are on Pro and up, and videos and live channels on Pro and up: on a plan without them the deploy or create answers 402 plan_limit with required_plan, before anything is built or created. Server apps that were already live on a free workspace when server apps moved to Pro keep running and can be redeployed. Nothing existing is deleted when a plan changes; only new creates are refused. Enterprise is arranged with us, from $2,000 a month, and is not bought online (`POST /api/billing` and checkout answer 409 `contact_sales` with `contact_url`); workspaces already on Enterprise keep it, at the price they pay.

## Usage included per plan

Measured daily (sizes, and server-app awake time as the runner counts it) and per deploy (build minutes), and shown on Billing → Usage, workspace by workspace on Billing → Workspaces. On a plan that covers several workspaces their use adds up against one amount, and the paying workspace's owner is emailed at 80% and at 100%.

| Usage | Hobby | Pro | Team | Scale | Past it |
| --- | --- | --- | --- | --- | --- |
| File storage | 1 GB | 50 GB | 250 GB | 1,000 GB | Hobby: new uploads are refused (402 usage_limit). Pro, Team and Scale: $0.05 per GB a month, charged daily from the paying workspace's Balance (Team's $20 monthly usage credit pays first); new uploads paused (402 usage_paused) only while a charge cannot be paid: the Balance is empty or smaller than the charge, or the spending limit is reached. |
| Database storage | 0.5 GB | 5 GB | 25 GB | 100 GB | Hobby: database writes that add data (reads and deletes still work) are refused (402 usage_limit). Pro, Team and Scale: $1.00 per GB a month, charged daily from the paying workspace's Balance (Team's $20 monthly usage credit pays first); database writes that add data (reads and deletes still work) paused (402 usage_paused) only while a charge cannot be paid: the Balance is empty or smaller than the charge, or the spending limit is reached. |
| Build minutes (per calendar month, UTC) | 100 minutes | 1,000 minutes | 5,000 minutes | 10,000 minutes | Hobby: new deploys are refused (402 usage_limit). Pro, Team and Scale: $0.01 per minute, charged daily from the paying workspace's Balance (Team's $20 monthly usage credit pays first); new deploys paused (402 usage_paused) only while a charge cannot be paid: the Balance is empty or smaller than the charge, or the spending limit is reached. |
| Server-app awake time (per calendar month, UTC) | not charged | none included | none included | none included | Hobby: not charged. Pro, Team and Scale: $0.05 per awake hour, charged daily from the paying workspace's Balance (Team's $20 monthly usage credit pays first); new deploys paused (402 usage_paused) only while a charge cannot be paid: the Balance is empty or smaller than the charge, or the spending limit is reached. |

> Database storage past the limit holds only statements that add data (INSERT, UPDATE, REPLACE, UPSERT, CREATE, ALTER), and a batch that contains one is held whole: reads and DROP sent on their own always run, and so does DELETE unless the database has triggers (a trigger can add data when rows are deleted: drop it first), so you can clean up. Database sizes are measured again daily, so room you free counts from the next measurement. Each single database still stops at the engine's 10 GB.

## Not metered or capped yet

- Requests and bandwidth to sites and server apps, and traffic to function URLs.
- Errors and latency are not measured.

## Databases

| Limit | Value |
| --- | --- |
| Size of one database | 10 GB |
| One row or value | 2 MB |
| One SQL statement | 100 KB |
| Bound parameters per statement | 100 |
| Time per query | 30 seconds |
| Columns per table | 100 |
| Statements in one batch | 100 |
| Query requests per workspace | 600 per 5 minutes (a batch counts once) |

## Deploys and server apps

| Limit | Value |
| --- | --- |
| One upload (folder, tar.gz or JSON files) | 25 MB compressed, 40 MB unpacked |
| One file | 10 MB |
| Files in one upload | 5,000 |
| Files a static site serves | 500 |
| Git repository download | 20 MB compressed, no file-count cap |
| Build time | about 13 minutes |
| Server app | one container per app (about a quarter of a CPU and 1 GB of memory), no extra copies |
| Server app sleep | after 15 idle minutes; disk wiped on sleep and on every deploy |
| Awake server apps | 40 across the whole platform today; sleeping apps do not count |

## Functions, storage and repos

| Limit | Value |
| --- | --- |
| Function code | one ES module, at most 200 KB, no npm install |
| CPU time per request (functions and worker.js programs) | 50 ms on Hobby, 1 second on Pro, 2 seconds on Team and 10 seconds on Scale; waiting on the network or a database does not count. A plan change applies on the next deploy |
| Function deploys | one deploy, rollback or delete of a function at a time: another sent meanwhile answers 409 `deploy_in_progress` |
| Test invoke | first 16 KB of the response, 10-second timeout |
| One storage upload | 5 GB (one signed PUT); a link with `size` is at most 1 GB on Hobby, all the file storage it includes |
| Object key | 1,024 characters |
| Download link lifetime | 60 seconds to 7 days (default 15 minutes); viewers and read-only keys at most 15 minutes |
| Upload link lifetime | at most 15 minutes (a longer `expiresIn` is shortened); `size`, optional on every plan, locks the link to the file's exact length |
| Objects per listing page | 1,000 (default 100), with a cursor for the next page |
| Repo push over REST | 500 files, 10 MB per file, 25 MB per push |

## AI, vectors, memory and agents

| Limit | Value |
| --- | --- |
| Gateway input | 200,000 characters (system prompt plus messages), up to 500 messages |
| Gateway output | max_tokens up to 8,192 (2,048 on the Llama edge models); default 1,024 |
| AI requests a minute | 60 hobby, 600 pro, 2,000 team, 6,000 scale — chat and embeddings counted separately |
| What one AI request may reserve | $2 hobby, $10 pro, $10 team, $10 scale (402 `ai_request_too_large`) |
| Embeddings per call | 100 inputs |
| Vector dimensions | 384, 768 (default), 1,024 or 1,536 |
| Vectors per upsert | 1,000 (100 when sending text to embed) |
| Vector query topK | 100, or 50 with full metadata or values |
| Memory value / key | 1 MB / 512 characters |
| Conversation window | 1,000 most recent messages per scope |
| Memory quotas | hobby: 10,000 items per store, 25 MB in all per workspace; pro: 100,000 items per store, 250 MB in all shared by the workspaces it covers; team: 250,000 items per store, 500 MB in all shared by the workspaces it covers; scale: 1,000,000 items per store, 1 GB in all shared by the workspaces it covers; every kind of store counts, and a write past them gets 402 `plan_limit` |
| Agent steps | 1–24 per run (default 8) |
| Agent runs | 6 a minute per workspace; input up to 32,000 characters |
| Agent schedules and events | a schedule runs at most every 10 minutes; a scheduled, event or background run starts within about 10 minutes and has 5 minutes to finish; a visitor's chat message has 60 seconds |
| Media upload | video only, up to 1 hour |

## Mail, payments and auth pools

| Limit | Value |
| --- | --- |
| Email per call | one recipient; text and/or HTML |
| Email quota | 100 hobby, 2,000 pro, 10,000 team, 100,000 scale per rolling 30 days; a Pro subscription from before these plans keeps 5,000; optional daily cap of your own |
| Custom sending domains | 1 hobby, 10 pro, 25 team, 50 scale (pending and failed domains count until removed) |
| Checkout amount | $0.50 to $1,000,000, USD, one-time |
| Checkout link | single use, expires after 24 hours |
| Earnings hold | 7 days per payment before it can be withdrawn |
| End-user password | 8–256 characters; 10 wrong tries lock the account for 15 minutes |
| End-user token | HS256 JWT, valid 7 days unless the pool sets a shorter `tokenTtl` (5 minutes to 7 days) |
| End-user refresh token | ends after 60 days without a refresh, and a year after sign-in; 20 signed-in devices per user |
| Browser sign-up | 10 per IP an hour (an IPv6 address counts by its /64), 600 per pool an hour |
| Sign-up from your server | 600 per pool an hour, counted apart from the browser's; with `ip`, that address is also held to the browser's 10 an hour |
| Push notifications | 100 users and 500 devices per send, 600 sends an hour per pool; 20 devices per user |
| Codes asked for from the browser | 50 an hour and 300 a day per pool; at most 20% of the email quota per workspace (20 / 400 / 2,000 / 20,000 per 30 days) |

## Rate limits

Every 429 carries `code`, a Retry-After header and `retry_after` seconds in the body. API quotas also send X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. Back off for Retry-After seconds and try again.

| What | Limit | Code |
| --- | --- | --- |
| AI chat | 60 / 600 / 2,000 / 6,000 a minute (hobby / pro / team / scale) | ai_rate_limited |
| Embeddings, memory search, text sent to vectors | a separate bucket of the same size | ai_rate_limited |
| Database queries, vector calls, memory calls, video status checks | 600 calls per 5 minutes per workspace, shared (a batch of statements is one call, and every memory call counts, key-value and conversation ones included). All workspaces together also share a platform ceiling lower than that on the calls that reach the edge, which can refuse calls first | rate_limited |
| Agent runs | 6 a minute per workspace | rate_limited |
| Push sends | 600 an hour per sign-in pool | rate_limited |
| Balance top-ups | 10 an hour | rate_limited |
| New workspaces | 5 a day per person | rate_limited |
| Email | your optional daily cap | daily_cap |
| Auth-pool codes asked for from the browser | 50 an hour and 300 a day per pool, and 20% of the email quota per workspace | code_limit |

## How it scales

| Service | At millions of users today |
| --- | --- |
| Static sites | Scale: served from the edge cache close to each visitor, the rest from object storage. |
| Functions | Scale: each request runs at the edge location nearest the caller, with no servers to size. |
| Storage | Scale: uploads and downloads go straight to object storage, never through Harakumo. Stored bytes count toward the plan's file storage (1 GB hobby, 50 GB pro, 250 GB team, 1,000 GB scale); past it Hobby stops and Pro, Team and Scale pay $0.05 per GB a month. Each signed link is one API call to Harakumo, so sign download links with a longer expiresIn and reuse them rather than signing one per page view. |
| Auth pools | Verify tokens in your app with the JWT secret (no call per request). Sign-ups and logins run on the shared platform and suit apps with thousands of active users. |
| Databases | Each database has one writer and stops at 10 GB, and each query is an HTTPS call through Harakumo capped at 600 requests per 5 minutes per workspace. Good for development, internal tools and low-traffic apps; not yet for a high-traffic public app. Batch statements, cache reads and split data across databases. |
| Server apps | One small container per app with no extra copies, a cold start after it sleeps, and a platform-wide cap on awake apps. For large traffic, put the front end on a static site and the server logic in functions. |
| AI | Model capacity scales with the vendors; your workspace is capped by the per-minute limits above. |
| Mail | Up to the plan's 30-day quota, one recipient per call. |

> Coming: databases reached directly from your functions and apps (no HTTPS hop, no shared limit), read replicas, a Postgres engine for datasets over 10 GB, extra copies and larger sizes for server apps, and metered traffic.

---

# Errors & rate limits

Errors come back as `{ "error": "…", "code"?: "…", …details }`. The sentence says what happened and what to do; the code is stable for programs. Nothing is charged for a refused request; the one exception is an AI model that used its whole token budget without answering (`budget_exhausted`, see AI Gateway).

## Status codes

| Status | Meaning |
| --- | --- |
| 400 | The request is wrong: a missing field, a bad value, or a SQL mistake (the database's own message, cleaned). |
| 401 | No credential, or it is invalid, revoked or past its expiry date. |
| 402 | Money or plan: a plan count or usage allowance reached, the Balance or AI credit too low, or the spending limit reached. |
| 403 | The credential may not do this: role, read-only key, project key, a paused key, owner-only money action, a suspended or unverified account, or a workspace that requires two-factor sign-in of a member who has not turned it on. |
| 404 | Not found — or it belongs to another workspace. |
| 409 | The resource is in the wrong state: still being created, disabled, another deploy of it running, or a delete blocked by money or work in flight. |
| 410 | A database that no longer exists on the edge, or a resumable upload that expired. |
| 413 | Too large: an upload over the size cap, or AI input over 200,000 characters. |
| 422 | Refused on purpose: a suppressed email address, a phone app sent to deploy, or an AI answer that used the whole token budget with nothing to show. |
| 429 | Too many requests. Wait Retry-After seconds. |
| 501 | Not available yet (for example, a Postgres database, an image media asset, or a test checkout before test mode is on). |
| 502 | Something upstream failed: a delivery provider refused, or a delete could not finish (it is safe to retry). |
| 503 | Temporarily unavailable. Retry after the Retry-After header when there is one. |

## Codes

| Code | Status | Meaning and fields |
| --- | --- | --- |
| plan_limit | 402 / 429 | A plan count, the email quota, a memory quota, the users of a sign-in pool or the vectors in a collection is reached, or the plan does not include the service (`required_plan` names the plan that does). `limit`, `current`; a memory quota adds `limit_name` (`memory_items_per_store` or `memory_bytes_per_workspace`), and the users or vectors `feature` (`pool_users` or `vectors_per_collection`); the public sign-in endpoints answer `pool_users` without `limit` or `current`. As a 429: more agent runs at once than the plan allows. |
| usage_limit | 402 | Hobby: the plan's file storage, database storage or build minutes are used up, so uploads, writes that add data or deploys are refused. `metric`, `limit`, `current`, `required_plan`. |
| usage_paused | 402 | A paid plan past its included usage that cannot pay for more: the paying workspace's Balance is empty, or the last charge did not fit that Balance or its monthly spending limit. `metric`, `limit`, `current`, `balance_cents` (the paying workspace's), `add_funds_url`, and `shared_workspaces` when the plan covers several workspaces. |
| insufficient_balance | 402 | The Balance does not cover a purchase. `price_usd`, `balance_usd`, `shortfall_usd`, `add_funds_url`, `card_on_file`. In a workspace another workspace's plan covers, the Balance short is that workspace's own, which pays for its domains (`balance_scope: "own"`): `add_funds_url` and `add_funds_api` add to it. |
| ai_credits_exhausted | 402 | Included AI and the Balance are used up. `available_usd`, `resets_on`, `buy_url`. |
| ai_credits_insufficient | 402 | Some AI credit is left, but less than this request's estimate. Lower `max_tokens`, shorten the prompt, choose a cheaper model or add funds. |
| ai_request_too_large | 402 | One AI request may reserve at most $2 on hobby, $10 on pro, $10 on team, $10 on scale. Shorten it, cap the output or split the work. |
| spend_limit_reached | 402 | The monthly spending limit of the workspace whose Balance pays (on a paid plan, the paying workspace's; for a domain, the buying workspace's own, `limit_scope: "own"` where another workspace's plan covers it) would be passed. `spend_limit_usd`, `spent_this_month_usd`, `limits_url`. |
| email_unverified | 402 / 403 | The workspace owner's email must be verified (Hobby vendor AI models, live payment links). |
| payouts_require_owner | 403 | Withdrawals, refunds, payout setup and adding or removing the card on file need the owner signed in to the dashboard. |
| dashboard_only | 403 / 409 | Only the person signed in to the dashboard can make this change: changing, cancelling or resuming a paid plan, the billing portal, the spending limit and alerts, adding funds with the card on file, switching auto-reload on, making someone an owner, leaving a workspace and deleting one. An AI connector or another app whose role could make the change gets this instead (one whose role could not is told so), and nothing was changed. `dashboard_url` is the page to do it on: hand it to the person. As a 409: a checkout link asked for a workspace that already pays for a plan, since changing that plan charges the card on file at once. Every call is listed in Billing → What needs the dashboard. |
| card_requires_owner | 402 / 403 | Paying with the card on file (`payWithCard` on a domain purchase, renewal or transfer) or letting renewals fall back to it (`autoRenewCard`, `cardBackup`) needs the workspace owner signed in to the dashboard. An AI connector or API key gets this instead (403 for `cardBackup`), and nothing was charged or changed. `dashboard_url` is the Domains page, where the owner can do it; or pay from the Balance (a purchase, renewal or transfer that asked for the card also carries `add_funds_url`; in a workspace another workspace's plan covers, that is this workspace's own balance, which pays for its domains, with `balance_scope` `own`). See Domains → Who pays, and how. |
| key_expired | 401 | The API key is past the expiry date an owner or admin gave it. Give it a new date, or use a new key. |
| key_paused | 403 | An owner or admin paused the API key. Resuming it in Settings → API access makes it work again. |
| account_suspended / key_creator_suspended | 403 | Harakumo suspended the account making the request, or the account that created the API key. Nothing has been deleted; write to support. |
| workspace_requires_mfa | 403 | The workspace requires two-factor sign-in of every member, and yours is off. Turn it on in Settings → Account; your own account pages stay open. API keys are not affected. |
| ai_gateway_off / ai_model_off | 403 | The AI Gateway, or this model, is switched off for the workspace (Dashboard → AI Gateway). |
| origin_not_allowed | 403 | An auth pool refused a browser Origin that is not on its list. |
| browser_access_off | 403 | An auth pool's browser sign-in is turned off. |
| mail_paused / mail_suspended | 403 | Sending is paused by an admin, or suspended by the platform. |
| invalid_domain | 400 | A sending domain that is not a real domain you could own (a single word, a test domain, a Harakumo name). |
| invalid_from / invalid_reply_to | 400 | A From or Reply-To address that is not allowed; the sentence says which rule. |
| sender_name / reserved_subject | 400 | A sender name or subject that breaks the rules in Mail → Sender names; the sentence says which. |
| from_domain_not_added | 400 | A `from` address on a domain that is not a sending domain of this workspace. Add it first. |
| from_not_project_domain | 403 | A key limited to one project asked to send from, or to set its project's From address on, a domain that is not its project's own. It sends from its project's sending domain or the shared no-reply; a person moves the project to a new domain on the Mail page. |
| sender_name_other_project | 403 | A key limited to one project asked to send under, or to save as its project's sender name, another project's name or sender name, or a close variant of one. Leave `fromName` out to use its own project's name. |
| domain_claimed_elsewhere / domain_taken | 409 | The domain belongs to another workspace on Harakumo. |
| dns_hosting_off | 409 | A sending domain bought on Harakumo whose DNS hosting is off. `platform_option` says how to turn it on. |
| mail_not_ready | 409 | An auth pool cannot email codes: the app has no sender name recipients may see. Set one on the Mail page. |
| payments_disabled | 409 | The payment account is disabled; re-enable it on the project. |
| deletion_blocked | 409 | A delete is refused. `blockers[]` has one sentence per reason (earnings, a withdrawal, an open checkout, a running deploy, bought domains). |
| deployment_serving | 409 | The deployment cannot be deleted on its own: the project serves it, or it still answers a custom domain. Deploy a new version (or connect the domain) first, or delete the project. |
| deploy_in_progress | 409 | Another deploy, rollback or delete of this function is running. Try again when it finishes. |
| not_a_static_site | 409 | A site password or the app-link files were asked for on a project that is not a static site: a server app or an edge program serves its own paths. |
| confirm_price / price_moved | 409 | A domain renewal above $100 a year needs `expectedPriceCents` at or above its price (`confirm_price`), or the price is now above the one you sent (`price_moved`). `price_cents` is the price; nothing was charged. |
| over_cap | 403 | A domain renewal above $100 a year asked for by anyone but the workspace owner in the dashboard: the API, an AI connector or another role. `dashboard_url` is the Domains page where the owner renews it; nothing was charged. |
| reserved_name | 400 | A project name whose address would start with hk- or harakumo, which Harakumo keeps for its own services. Choose another name. |
| test_mode_off | 501 | A test checkout was asked for, and payments test mode is not on yet. |
| upload_expired | 410 | A resumable upload more than six days old. Start a new one. |
| confirm_forfeit_required | 409 | Deleting a workspace would give up a positive Balance; send `confirmForfeit: true` to accept. |
| size_required | 400 | An upload link asked for `maxBytes` without `size`, the file's exact length: a link can only be locked to one exact length, so `maxBytes` alone would limit nothing. Send both. `max_bytes` is the limit that applies; no link was made. |
| file_too_large | 413 | An upload link's `size` is over `maxBytes` or the plan's per-file cap (1 GB on Hobby, 5 GB on the other plans). `max_bytes` is the limit that refused it; no link was made. |
| suppressed | 422 | The recipient is on the suppression list. |
| phone_app | 422 | A deploy of an Expo, React Native, Flutter, iOS or Android app with nothing else to deploy (no website, no web build) and no start script that runs a server (only a phone development server such as `expo start`, or none). The app ships through the App Store and Google Play instead. Nothing was built. `app` names the kind; `docs` links Use Harakumo from a phone app; the sentence says how to deploy the app's companion website. |
| flutter_web_build | 422 | A deploy whose build script runs `flutter build web`. The build machines have no Flutter toolchain, so it was not started and nothing was built. Run `flutter build web` on your machine and upload the `build/web` folder, which deploys as a static site. |
| budget_exhausted | 422 | The model used its whole `max_tokens` budget without an answer. Those tokens are charged; raise `max_tokens`. |
| ai_request_refused | 400 / 413 | The model refused the request itself (a parameter, or its size). It was not sent to other models; nothing was charged. |
| rate_limited | 429 | A rate limit. `retry_after`; API quotas add `limit` and `window_seconds`. |
| ai_rate_limited | 429 | The AI per-minute limit. `limit`, `window_seconds`, `retry_after_seconds`. |
| daily_cap | 429 | The workspace's own daily email cap. |
| code_limit | 429 | An auth pool's codes asked for from the browser reached the pool's limit or the workspace's share of the email quota. Retry-After. |
| zone_limit / account_zone_limit | 429 | New DNS setups for today are used up: 10 per workspace, or 20 across the workspaces one person owns (that one carries `retry_after`). |
| delivery_failed | 502 | The mail provider refused the message; `message` has the reason. |
| provider_error | 502 | A sending domain could not be set up; nothing was saved. Try again in a minute. |
| ai_unavailable / ai_provider_error | 502 | No model could answer right now; nothing was charged. `harakumo-1` fails over between vendors. |
| zone_capacity | 503 | Harakumo is not accepting new domain setups for a while; nothing was set up. Buying a domain is not affected. |
| teardown_incomplete | 502 | A delete left some resources behind; the project is kept as teardown_failed. Delete again to retry. |
| processor_error | varies | The payment processor refused; the sentence says why. |

## Handling errors in code

**SDK**

```js
import Harakumo, { HarakumoError } from '@harakumo/sdk';

try {
  await hk.databases.query(dbId, 'SELECT 1');
} catch (err) {
  if (err instanceof HarakumoError) {
    if (err.status === 429) { /* wait err.body.retry_after seconds, then retry */ }
    if (err.body?.code === 'plan_limit') { /* show err.message to the person */ }
  }
  throw err;
}
```

---

# Architecture

Harakumo is an app's whole backend behind one client. Your front end and server deploy to the Harakumo edge; every capability the app needs is a namespace on the same SDK or a path on the same API, already wired together. The diagram shows a real shape — an online store, end to end — with the role each service plays.

Your server holds the API key; browsers get only what your server hands them (signed upload and download URLs, checkout links) or the auth pool's publishable client id.

---

# Use Harakumo from a phone app

Harakumo does not build or publish phone apps. You build the app with Xcode, Android Studio, Expo or Flutter, and Apple and Google review it and ship it. Harakumo is the app's backend (sign-in for its users, an API you write as a function, a database, file storage, AI, mail and payments) and it hosts the small website the stores ask for.

The app holds one Harakumo value: the sign-in pool's client id. Everything else goes through your function, which keeps the keys.

## Who does what

| Who | What they do |
| --- | --- |
| You | Write the app (Swift, Kotlin, React Native, Expo or Flutter). Create a project with a sign-in pool and a function, put the client id in the app, and deploy a small companion website. |
| Apple and Google | Review, sign and distribute the app and its updates; run in-app purchases; deliver push notifications. |
| Harakumo | Sign-in for the app's users, the function that is the app's API, the database, file storage, AI, mail, payments and push sending behind it, and the companion website. |

## How it fits together

1. Sign-in: the app calls `https://harakumo.com/api/auth-pools/public/<clientId>/<action>` to sign up, log in (with a password, or with Apple or Google), verify an email and reset a password, and gets back a token that lasts 7 days unless the pool sets a shorter lifetime, and, when it asks, a refresh token that keeps the phone signed in for months.
2. Everything else: the app calls your function (`https://hk-<project-slug>--<function-name>.harakumo.app`) and sends the token. The function checks it, then calls Harakumo's database, storage, AI, mail and payments APIs with keys it holds as environment variables.
3. Files: the function signs an upload or download link, and the app sends or fetches the bytes straight to storage.
4. Payments: the function creates a checkout link, the app opens it in the phone's browser, and the buyer comes back to the app through the app's own link (`appReturnUrl`) or an https link.
5. Push: the app registers each device's push token for the signed-in user, and your function sends to users by id; Harakumo delivers through Apple's and Google's push services with your keys.
6. The companion website: a static site on the same project serves the privacy, support and delete-account pages and the two app-link files, which Harakumo can write for you.

> With an AI assistant connected (see AI connector), setup_app_backend creates what the app needs (a sign-in pool, a database, storage, a key limited to the project) and stores every value as a project environment variable.

## What may ship inside the app

Anything inside an app can be read by anyone who downloads it, however it is stored or hidden. Only the client id is made to be read.

| Value | In the app? | Why |
| --- | --- | --- |
| Client id (`ap_…`) | Yes | Publishable. It can only sign up (while public sign-up is on), sign in (with a password, Apple or Google), refresh, send and check emailed codes, reset a password, check a session and, with a signed-in user's own token, change or delete that user's account and register their device for push; and, apart from the deletion page's actions while that page is on, only while app sign-in is on. |
| Client secret (`as_…`) | No, server only | It acts for the pool from your server, outside the limits the client-id endpoints have. |
| JWT secret (`js_…`) | No, server only | Whoever holds it can make a token for any user, and your function would accept it. |
| API keys (`hk_live_…`), read-only ones too | No, server only | A key reaches the project's data. Even a read-only key lists every user's email and signs download links for any file. |

## Turn on app sign-in

1. Create the pool: setup_app_backend from an AI assistant, Dashboard → the project → Auth, or `POST /api/projects/:id/auth`.
2. Turn on app sign-in: the switch "Allow sign-in from browsers and apps" on the project's Auth page, `PATCH /api/auth-pools/:id/settings { "publicEnabled": true }`, or the update_auth_settings tool (owner or admin). It is off by default; until it is on, every call to the client-id endpoints answers 403 `browser_access_off`, except the deletion page's two actions while that page is on.
3. If people create their accounts in the app, also turn on "Let anyone sign up" (`publicSignup: true`). It is a separate switch, off by default; until it is on, sign-up answers 403 `signup_closed` and log-in still works.
4. A native app sends no Origin header, so it needs no allowed origin. The allowed origins are for web pages, and for a website wrapped as an app (Capacitor, Ionic, Cordova, Tauri), which sends `capacitor://localhost`, `ionic://localhost`, `app://localhost` or `tauri://localhost`: add that one origin (`addOrigins` in update_auth_settings), or make the calls through native HTTP, which sends none.

**React Native or Expo**

```js
import * as SecureStore from 'expo-secure-store';

const AUTH = 'https://harakumo.com/api/auth-pools/public/ap_…';   // the client id: the only Harakumo value in the app

export async function logIn(email, password) {
  const r = await fetch(AUTH + '/login', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ email, password }),
  });
  const data = await r.json();
  if (!r.ok) throw new Error(data.error);   // data.code: invalid_credentials, locked, rate_limited …
  await SecureStore.setItemAsync('session', data.token);   // Keychain on iOS, Keystore on Android
  return data.user;
}

// On launch: still signed in?
export async function currentUser() {
  const token = await SecureStore.getItemAsync('session');
  if (!token) return null;
  const r = await fetch(AUTH + '/session', { headers: { authorization: 'Bearer ' + token } });
  if (r.status === 401) { await SecureStore.deleteItemAsync('session'); return null; }   // expired or revoked
  return (await r.json()).user;
}
```

**Swift**

```swift
let auth = URL(string: "https://harakumo.com/api/auth-pools/public/ap_…")!

struct Session: Decodable { let token: String }

func logIn(email: String, password: String) async throws -> String {
    var request = URLRequest(url: auth.appendingPathComponent("login"))
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONEncoder().encode(["email": email, "password": password])
    let (data, response) = try await URLSession.shared.data(for: request)
    guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw URLError(.userAuthenticationRequired) }
    return try JSONDecoder().decode(Session.self, from: data).token   // keep it in the Keychain
}
```

> Bodies are JSON with `content-type: application/json`. In Flutter, `jsonEncode` the body and set that header: a plain map is sent as a form and answers 400.

## Keep the token on the phone

- Store the token in the Keychain on iOS and in Keystore-backed storage on Android (expo-secure-store and flutter_secure_storage do this), never in AsyncStorage, SharedPreferences or a plain file.
- Send it to your function as `Authorization: Bearer <token>`. No cookies are involved.
- On launch, `GET …/session` with the token says who is signed in; 401 means sign in again.
- A token lasts 7 days unless the pool sets a shorter `tokenTtl`. To keep people signed in, ask for a refresh token at sign-in (`refresh: true`, with a `device` label such as "Sam's iPhone"), keep it in the Keychain or Keystore as well, and trade it with `refresh { refreshToken }` before the token runs out: each refresh answers a new token and a new refresh token. The session ends after 60 days without a refresh, or a year after sign-in. See Auth pools → Keep people signed in.
- To sign out, call `logout { refreshToken }` and delete both tokens from the phone. `logout { everywhere: true }` with the user's token, or `PATCH /api/auth-pools/:id/users/:userId { "action": "revoke-sessions" }` from your function, signs them out on every device.

## The JWT secret stays on the server

Tokens are signed with the pool's JWT secret (HS256), so whoever holds it can make tokens. The app never checks a token itself; your function does, in one of two ways:

- Ask Harakumo: `POST /api/auth-pools/:id/verify { "token" }` with the client secret. It also refuses the tokens of deleted users, of users signed out everywhere and of devices signed out.
- Check it in the function with the JWT secret from its environment: the HS256 signature, `iss` (the pool's issuer), `aud` (the client id) and `exp`. No call per request, but a revoked token is accepted until it expires.

## Your function is the app's API

One function can answer every screen of the app. It reads its settings as `env.NAME`, from the project's environment variables; the names below are the ones setup_app_backend stores. Functions deploy on every plan, including the free Hobby plan.

**Function**

```js
const API = 'https://harakumo.com/api';

function call(path, key, body) {
  return fetch(API + path, {
    method: 'POST',
    headers: { authorization: 'Bearer ' + key, 'content-type': 'application/json' },
    body: JSON.stringify(body),
  }).then(r => r.json());
}

export default {
  async fetch(request, env) {
    // Who is calling: check the token the app sent.
    const token = (request.headers.get('authorization') || '').replace(/^Bearer /, '');
    const { valid, user } = token
      ? await call('/auth-pools/' + env.HARAKUMO_AUTH_POOL_ID + '/verify', env.HARAKUMO_AUTH_CLIENT_SECRET, { token })
      : { valid: false };
    if (!valid) return Response.json({ error: 'Sign in again' }, { status: 401 });

    const url = new URL(request.url);
    if (request.method === 'GET' && url.pathname === '/orders') {
      const { results } = await call('/databases/' + env.HARAKUMO_DATABASE_ID, env.HARAKUMO_API_KEY, {
        sql: 'SELECT id, total_cents, status FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 20',
        params: [user.id],
      });
      return Response.json({ orders: results });
    }
    if (request.method === 'POST' && url.pathname === '/avatar') {
      const { size } = await request.json();   // the photo's length in bytes, from the app
      const signed = await call('/storage/' + env.HARAKUMO_BUCKET_ID, env.HARAKUMO_API_KEY, {
        key: 'users/' + user.id + '/avatar.jpg', method: 'put', contentType: 'image/jpeg',
        size, maxBytes: 5 * 1024 * 1024,
      });
      if (!signed.url) return Response.json({ error: signed.error }, { status: 400 });
      return Response.json({ upload: signed.url });   // the app PUTs exactly size bytes there, Content-Type: image/jpeg
    }
    return Response.json({ error: 'Not found' }, { status: 404 });
  },
};
```

> Every query names its user (`WHERE user_id = ?`): the function is the only thing between one user and another user's data. HARAKUMO_API_KEY is limited to the project, and it also sends mail and calls AI for the project; which key each service takes is in API keys & authentication.

## Files

1. The app asks your function for an upload link and says how big the file is. The function picks the key (for example `users/<id>/avatar.jpg`) and signs a PUT with `POST /api/storage/:id { "key", "method": "put", "contentType", "size" }`.
2. The app starts the PUT within 15 minutes, with the same Content-Type and exactly that many bytes. It is one plain request, so it works as a background upload on iOS and Android.
3. For downloads the function signs a GET link: 15 minutes by default, up to 7 days. Sign a long link once and reuse it for pictures many people see.

> With `size`, the link accepts exactly that many bytes, so nobody who gets hold of it can upload more; `maxBytes` refuses a bigger file before any link is made (413 `file_too_large`). `size` is optional, but a link without it takes any file of up to 5 GB from whoever holds it, so a function signing for an app always sends it. Upload links last at most 15 minutes. One upload is at most 5 GB (with `size`, 1 GB on Hobby). A single PUT cannot resume: a dropped one starts again with a new link. For big files or bad networks, have the function start a resumable upload instead and hand the app a link per part (Storage → Resumable uploads): a part that fails is sent again on its own, and the upload carries on later from the parts already sent. Sign links only for signed-in users, one key at a time.

## Push notifications

Harakumo sends push notifications straight to Apple's push service (APNs) and Firebase Cloud Messaging, with your own keys: save the APNs key and the Firebase service account once, on the Push notifications panel of the project's Auth page or with `PUT /api/push/:poolId/credentials`. They are sealed and never shown again.

After sign-in, the app registers the device's push token for the user: `register-push { pushToken, platform: "ios" | "android" }` at the public endpoint with the user's token, and `unregister-push { pushToken }` on sign-out. Your function sends to users by id: `POST /api/push/:poolId/send { userIds, title, body, data? }` with the client secret. Details and limits are in Auth pools → Push notifications.

Functions run only when called (no schedules yet), so a notification goes out when something calls the function, such as a payment webhook or another user's action. For work on a timer, an agent can run on a schedule (see Agents).

## Payments in a store app

- Harakumo checkout takes one-off card payments in US dollars. In a store app it fits physical goods and real-world services: orders, food, bookings, tickets, deposits, fees and one-to-one sessions.
- Digital goods used inside the app (subscriptions, credits, premium features, game currency) must be sold through Apple's in-app purchase and Google Play Billing, with exceptions that depend on the country and on each store's programs, such as linking out to a web purchase in the US. Check each store's current rules before you link out. Harakumo does not check store purchases yet; record what each user bought from your function.
- Open the checkout link in the phone's browser or its in-app browser sheet (SFSafariViewController on iOS, Custom Tabs on Android; expo-web-browser and url_launcher do this), never in a web view of your own: wallets and bank checks can fail there.
- To come back to the app, give the checkout `appReturnUrl`, the app's own link (such as `myapp://checkout/done`), instead of `successUrl` and `cancelUrl`: Harakumo's own done or cancelled page hands the buyer back to the app with `session_id` and `status` added. Or point `successUrl` (http or https) at a page on your companion website that is also a universal link (iOS) or app link (Android), so the phone opens the app; the page itself says "Back to the app" for anyone without it.
- Reaching the success page is not proof of payment. Your function confirms it with the session lookup or the payment.succeeded webhook, then records the purchase.
- Refunds are made by the workspace owner in the dashboard (the project's Payments page), not by the app: API keys get 403 `payouts_require_owner`. An admin screen in the app can link there.

## The companion website

Both stores ask for web pages, and app links need two files on your domain. Put them in a static site on the same project; static sites deploy on every plan, including Hobby.

The site can deploy from the app's own repository: a site/ or docs/ folder with an index.html is served as the site, or an Expo app exports its web version with a build script (`"build": "expo export -p web"`). A Flutter web build is not run here, because the build machines have no Flutter toolchain: run `flutter build web` on your machine and upload the `build/web` folder.

A repository that holds only the phone app is not deployed: when its start script only runs a phone development server (`expo start`, `expo run:ios`, `react-native start`, `flutter run` and the like, also through `npm run`, `yarn` or `pnpm`) or there is none, the deploy is refused with code `phone_app` before anything builds. A start script that runs a real server (for example an Expo app's server output on Express) deploys as a server app, on Pro and up.

- Harakumo writes both app-link files from your app's ids: `PUT /api/projects/:id/app-links { ios: { appIds: ["<TeamID>.<bundle id>"], paths? }, android: { apps: [{ package, sha256 }] } }` (the connector's set_app_links does the same) builds them and writes them into the static site that is live now. A new deploy replaces the site's files, so write them again after each deploy (`POST /api/projects/:id/app-links { action: "write" }`), or keep the two files in the site's `.well-known` folder, which every deploy then carries. A server app or an edge program serves its own paths: the answer has the two files for it to serve.

| Path | What it is for |
| --- | --- |
| `/privacy` | The privacy policy: linked in App Store Connect, in Play Console and inside the app. What Harakumo keeps about your app's users lists what Harakumo holds, for how long and who else receives it, for the policy and the stores' privacy forms. |
| `/support` | The support URL App Store Connect asks for: how people reach you. |
| `/delete-account` | How someone deletes their account and how long it takes. Google Play asks for a link that works after the app is uninstalled, so it can take the request itself: a form that runs the pool's deletion actions, or a link to Harakumo's hosted deletion page (Let people delete their account, below). |
| `/.well-known/apple-app-site-association` | Universal links on iOS: your Team ID and bundle id, as JSON, in a file with no extension. |
| `/.well-known/assetlinks.json` | App links on Android: your package name and the SHA-256 fingerprint of the Play App Signing key, not your upload key. |

> Each address works without `.html`: `/privacy` is served from `privacy.html` (what Next.js and Expo static exports write), and `/privacy/` redirects there, so a page needs no folder of its own (`privacy/index.html`). Give the stores the address without a trailing slash. `apple-app-site-association` is served as `application/json` though it has no extension. Files under `/.well-known/` are served; other dotfiles are not. A single-page app that wants every route answered with its index.html adds a `_redirects` file with the one rule `/* /index.html 200`; that fallback never answers under `/.well-known/` or for a path with a file extension, so the app-link files are the real files or a real 404.

## Let people delete their account

Both stores require that people who create an account in an app can delete it from the app, and Google Play also asks for the web link. The sign-in pool does both:

1. In the app: Settings → Delete account calls `delete-account { password }` at the public endpoint with `Authorization: Bearer <token>` (an account with no password, made with Apple or Google, asks for a code with `send-deletion-code` and sends `{ code }`). The account goes at once, with its sessions, push devices and codes, and the verify call refuses its tokens from then on. It cannot be undone, so the app asks first.
2. On the web: turn on the hosted page (`PATCH /api/auth-pools/:id/settings { deletionPage: true, deletionNote }`) and give the stores `https://harakumo.com/account/delete/<clientId>`; or build your own `/delete-account` page with `request-deletion { email }` and `confirm-deletion { email, code }`.
3. Your server hears `user.deleted` on the pool's webhook (Auth pools → Hear when a user is deleted) and deletes that person's rows and files. Your function can also delete a user itself with `DELETE /api/auth-pools/:id/users/:userId`.

> What Harakumo keeps about your app's users says what to remove from each service, and what stays.

## Limits today

- Database: every query from your function is an HTTPS call through Harakumo, and a workspace may make 600 per 5 minutes, shared by database queries, vector calls, memory calls and video status checks. All workspaces together also share one platform ceiling on the calls among these that reach the edge, and it is lower than one workspace's own allowance, so the platform as a whole can also refuse calls before your workspace reaches its number, and sooner while other workspaces are busy (429 `rate_limited`, with Retry-After). So do not plan on a steady rate per second. That suits a beta with a few testers, not yet an app many people use at once. Batch statements (a batch is one call) and cache what you can in the function; direct database connections for functions are planned.
- Sign-in is email and password, Sign in with Apple and Sign in with Google: no other social sign-in, and no multi-factor sign-in for the app's users yet. Apple asks for Sign in with Apple when an app offers another company's sign-in, such as Google's.
- Emailed codes asked for from the app may use at most 20% of the workspace's 30-day email quota: 20 a month on Hobby.
- Push has no topics, no sending to every user at once, no scheduled sends and no delivery receipts yet; functions have no logs and no schedules yet.
- Checkout is one-off payments in US dollars, with no subscriptions, and nothing checks App Store or Google Play purchases yet.

## Reference

**REST**

```http
# From the app: the client id only
POST   /api/auth-pools/public/:clientId/signup | login | sign-in-with | send-verification | verify-email | forgot-password | reset-password | refresh | logout
POST   /api/auth-pools/public/:clientId/delete-account | send-deletion-code | register-push | unregister-push    Authorization: Bearer <token>
GET    /api/auth-pools/public/:clientId/session      Authorization: Bearer <token>

# From your function: secrets in its environment
POST   /api/auth-pools/:id/verify                     { "token" }                                      # Bearer client secret
POST   /api/auth-pools/:id                            { "action": "login", "email", "password" }       # Bearer client secret
POST   /api/databases/:id                            { "sql", "params"? }                             # Bearer API key
POST   /api/storage/:id                               { "key", "method": "put"|"get", "contentType"?, "size"?, "maxBytes"? }
POST   /api/payments/:accountId                       { "amountCents", "reference"?, "successUrl"? | "appReturnUrl"? }
GET    /api/payments/:accountId/sessions/:sessionId   → { paid, payment, session }
POST   /api/push/:poolId/send                         { "userIds", "title"?, "body"?, "data"? }        # Bearer client secret
DELETE /api/auth-pools/:id/users/:userId              # account deletion from your server

# Once, for the companion website's app-link files (developer or above)
PUT    /api/projects/:id/app-links                    { "ios"?: { "appIds" }, "android"?: { "apps": [{ "package", "sha256" }] } }

# Once, by a developer or above (the connector tool, update_auth_settings: owner or admin)
PATCH  /api/auth-pools/:id/settings                   { "publicEnabled": true, "publicSignup"?: true, "allowedOrigins"?: [] }
```

---

# What Harakumo keeps about your app's users

The people who use your app have no Harakumo account. What Harakumo holds about them is what your app sends through each service, plus the few records a service keeps to work: a sign-in pool keeps each user's email address and password hash, the mail log keeps each email's recipient and subject, a payment keeps the buyer's email and country. This page lists all of it, from the code, so you can fill in the stores' forms and write your privacy policy from what is true.

To your users, Harakumo is a service provider acting for you: it keeps this data to run the service, bills you by counts and never by who your users are, does not sell it, and does not train AI models on it. Who at Harakumo can see what, and how long each kind of record is kept, is on harakumo.com/security; what Harakumo keeps about you, the account holder, is on harakumo.com/privacy.

## Before you fill in a form

Both stores count data a service provider stores for you as data your app collects, so what the table below lists counts as collected by your app, with you responsible for it. The forms also ask whether the data is linked to a person (a sign-in pool's records are, by email address), whether it is used for tracking (Harakumo uses none of it for advertising or tracking, and puts no identifier, analytics code or cookie in your app), and who else receives it, which the table says per service.

Only what your app sends is held: an app that never asks for a name stores no names. Add whatever your app itself collects and whatever other services it uses, such as crash reporting, an analytics kit, or a push service other than Harakumo's.

Read each store's current form before answering. The categories change, and the words below are Harakumo's, not the stores'.

## Service by service

| Service | What Harakumo keeps | For how long | Who else receives it |
| --- | --- | --- | --- |
| Sign-in (auth pools) | Each user's email address, the name your app sends (if any), a hash of their password (never the password; none for an account made with Apple or Google), when they signed up and last signed in, whether and when their email was verified, and a count of recent wrong passwords, with a 15-minute lock after too many. A code emailed to verify an address, reset a password, confirm a new address or delete the account is stored hashed and expires after 15 minutes; a new address waits beside its code until it is confirmed. The token the app holds is not stored: it lasts 7 days unless the pool sets a shorter lifetime, and a deleted user's tokens stop verifying at once. | Until the user deletes their own account (in the app, or on the deletion page), your app deletes the user (`DELETE /api/auth-pools/:id/users/:userId`), or you delete the pool or the project. | The email delivery provider, which carries the code emails. |
| Signed-in devices (refresh tokens) | For an app that asks for refresh tokens: one record per signed-in device, with the label the app gives it (such as "Sam's iPhone"), when it signed in and was last used, and hashes of its current refresh token and the one before it, never the tokens. At most 20 per user. | A session ends after 60 days without a refresh, a year after sign-in, at sign-out, or when the password or email address changes; an ended session's record is removed the next time that user signs in on a device asking for a refresh token, and every record goes with the user. | No one. |
| Sign in with Apple and Google | Which Apple or Google account signs in as which user: the provider, the account's id for your app, the email address it gave, and when it was first and last used. The ID token the app sends is checked and not kept. | Until the user is deleted, or you delete the pool or the project. | No one. Harakumo checks the token against Apple's and Google's published keys and sends them nothing; the person signed in with that company on their own device. |
| Push notifications | For an app that registers devices for push: each device's push token, its platform (iOS or Android), which push service delivers to it and, for Apple, whether it is a development build, with when it was registered and last seen. At most 20 per user. The notifications you send are not kept, only how many went out. | Until the app unregisters the device, the user registers more than 20 (the oldest goes), or the user is deleted. A device the push service reports gone is switched off and kept until then. | Apple's push service and Firebase Cloud Messaging, which carry each notification, its title, text and data, to the device. |
| Events to your server | When a user is deleted and the pool has a webhook: a `user.deleted` event with their id and email address, kept to deliver it to your server. | The address leaves the stored event once it is delivered or given up on (eight tries over about two days; a retry goes out only while your app is in use, so it can wait longer). The event without it stays until you delete the pool or the project. | Your own server, at the webhook address you set. |
| Sign-in abuse counters | The address a sign-up, sign-in, code or reset request came from, as a counter per pool: at most 10 sign-ups an hour and 30 sign-in attempts in 10 minutes from one address, for example. A counter is a number next to an address; no address is kept with a user. | A counter is deleted two days after it starts. | No one. |
| The bot check on sign-up (when it is on) | Nothing: the one-time token a visitor's browser earns is confirmed once and dropped. | — | The company that runs the check: the visitor's browser talks to it while passing the check, and Harakumo sends it the token, and the visitor's address when known, to confirm it. |
| Your function (the app's API) | Nothing. Requests to a function's own address do not pass through Harakumo, which neither logs them nor counts them, apart from how many requests Shield's rules refused, and nothing a function prints is kept. The guard in front of the function reads a caller's country, address and user agent only to apply your Shield rules, and keeps none of them. What your function stores, it stores in the services below. | — | Whoever your function sends data to, which only your code decides. |
| Databases | Whatever rows your app writes. Harakumo's own tools show a database's size and row counts, never its rows. | Until your app deletes the rows, or you delete the database or the project. Copies kept to recover from failures remain until those copies are deleted; that period is not set yet. | No one. |
| File storage | The files your app uploads, under the names it chooses, each with its size and time. Harakumo keeps no record of who uploads or downloads a file: the bytes pass between the person's device and storage, and a link is checked, not logged. An upload link lasts at most 15 minutes; a download link lasts at most 15 minutes from a read-only key and up to 7 days from a full one. | Until your app deletes the file (`POST /api/storage/:id { "key", "method": "delete" }`), or you delete the bucket or the project. | No one. |
| Vector DB | The texts and metadata your app adds, and their vectors. | Until your app deletes them by id (`POST /api/vectors/:id { "action": "delete", "ids" }`), or you delete the collection or the project. | No one. Text sent to be embedded is embedded on the infrastructure provider's inference and reaches no model company. |
| Memory | The keys, values and chat messages your app stores under the scope it names (a user id or a session), and the embeddings of what it stores for recall by meaning. A conversation keeps its last 1,000 messages per scope; an item with an expiry goes when it passes. | Until expiry, until your app deletes a key (`DELETE /api/memory/:id/keys/:key`) or a scope's transcript (`DELETE /api/memory/:id/messages?scope=`), or you delete the store or the project. | No one. Embeddings are made on the infrastructure provider's inference. |
| AI | A request log: the model asked for and the one that answered, token counts, cost, timing and whether it failed. Never the prompt or the answer. | 30 days. | The company that makes the model: your prompt and its answer go there. `GET /api/ai/chat` lists every model with its maker (`provider`). With `harakumo-1`, that is the first available model in its order, and another company's model when one fails; name a model to know which company. `llama-3.3-70b` and `kimi-k2.6` run on the infrastructure provider's edge inference, as embeddings do, and reach no model company. |
| Agents | Each run's input, output and every tool call with its arguments and results, so you can review them: what a tool read from your database, memory or repo is in the run. | Finished runs are deleted with their steps by a daily clean-up once they are more than 90 days old; a run still going is kept until it ends. Deleting the agent deletes its runs at once. | The company that makes the agent's model, as for AI. |
| Mail | For each email: the recipient's address, the sender, the subject, when it was sent and whether it was delivered or failed. Never the body. An address that bounced, complained or unsubscribed goes on the workspace's suppression list, so it is not mailed again. | The log and the list stay for as long as the workspace exists. | The email delivery provider, which carries the message. |
| Payments | Card numbers never reach Harakumo or your app: the buyer pays on the processor's hosted page. Harakumo keeps the amount, currency and description, the buyer's email and country with each payment, and the email your app gave when it made the checkout link. | Payment records are kept after the project is deleted, as the law requires. | The payment processor, which has the card and whatever the buyer typed on its page. |
| Media | The videos your app uploads and the live streams it sends, with each asset's name, size, duration and a view count. No record of who watched. | Until you delete the asset or the project. | The infrastructure provider, which stores and plays them. |
| The companion website (a static site) | The site's files. Harakumo keeps no visitor log: the program that serves a site reads a visitor's country, address and user agent only to apply your Shield rules, keeps none of them, and counts only how many requests each rule refused. | — | No one. |
| Server apps | The last 200 lines your app prints, as the deployment's runtime log in the dashboard. If your app prints a visitor's address or email, that is in the log. | Until the deployment is deleted. | No one. |
| Usage and billing | Counts per workspace and project: database calls, emails sent, AI tokens, agent runs, storage size. Never anything about one person. | Kept as the workspace's usage history. | No one. |

## Name the companies

The forms and your policy ask who else receives the data. For what Harakumo handles, that is Harakumo, as your service provider, and the companies behind it: a hosting, storage and inference provider (sites, functions, databases, files, edge models and embeddings), an email delivery provider, a payment processor, the AI model companies whose models your app uses, Apple's and Google's push services when your app sends notifications, and the bot-check provider when the bot check is on. Their names are not published on harakumo.com/security yet; until they are, name the kinds, and name each model company from `GET /api/ai/chat`.

The stores expect an app that sends what people type to an AI model to say so in its policy and to name who receives it. With a named model that is one company; with `harakumo-1` it is any company in the router's order, so an app that must name exactly one names a model.

## Deleting one person's data

Deleting the user from the sign-in pool removes everything the pool holds about them in one step. What your app stored about them in a database, storage or memory is for your app to remove, which the `user.deleted` event tells it to do.

1. Delete the user from the sign-in pool: they can do it themselves (`delete-account` in the app, or the deletion page), or your server calls `DELETE /api/auth-pools/:id/users/:userId`. Their tokens stop verifying at once, and their signed-in devices, push devices, Apple or Google links and emailed codes go with them. Your webhook hears `user.deleted`.
2. Delete their rows from your database with your own SQL, and their files from storage, key by key (`POST /api/storage/:id { "key", "method": "delete" }`).
3. Forget their memory: their transcript (`DELETE /api/memory/:id/messages?scope=<userId>`) and their keys, and their vectors by id.
4. What stays: their address and the subject lines in the mail log, their email and country on any payment (as the law requires), the abuse counters, which hold only an address for two days, and their address in the `user.deleted` event until it is delivered or given up on.

## What Harakumo does not do with your users' data

- No advertising and no analytics about your users: no advertising identifier, no analytics code and no cookie of Harakumo's in your app. Harakumo's only cookies keep you signed in and remember your active workspace on harakumo.com; neither is set in your app.
- No selling, and no training of AI models on your users' data, your content or your prompts.
- No reading by staff: Harakumo's admin tools show resource names, counts, sizes and status, never your database rows, stored files, your app's user list, the emails you send or your AI prompts. What they do show, and when a look is written to your audit log, is on harakumo.com/security.
- Nothing crosses a workspace: a resource id from another workspace answers 404 before any of its data is read (see Security & data isolation).

---

# Projects & environment

Each project gets a permanent slug, which becomes its address: `https://<slug>.harakumo.app`. Creating a project reserves the name; nothing serves until the first deploy succeeds. A project that only holds a database or storage has no website, and that is fine.

A name whose address would start with `hk-` or `harakumo` is kept for Harakumo's own services and refused with 400 `reserved_name`.

## What a project reports

`GET /api/projects/:id` returns `project` (with `live_url`, null until a deploy is live), `serving` (`state`: live, down, deploying, failed or not_deployed, plus the serving deployment), `deployAction` (what Deploy does: rebuild the latest GitHub commit, or redeploy kept files) and `services` (counts of vectors, memory stores, agents, auth pools and media, and the payment account). The project id is on the project's Overview (Connect your app) and Settings.

## Environment variables

- Set them in the dashboard (project → Environment) or with `POST /api/projects/:id/env { key, value }`. A key starts with a letter or an underscore and has only letters, digits and underscores (`API_KEY` and `api_key` both work); anything else is refused with 400, so every key that is accepted reaches your code. Setting an existing key replaces it.
- They are copied into sites, server apps and functions when those deploy. Change one, then redeploy (or press Redeploy) for it to take effect. Every answer to a change names the functions still running with the old values (`functions_behind`), and the Environment tab offers to redeploy them.
- Every server app, build, worker.js program and function also gets `HARAKUMO_PROJECT_ID`, the project's id, set by Harakumo. A project variable of that name never replaces it: the code gets the real id, and a server app's or worker.js program's deploy log says so when the stored value differs.
- Only the production target is used.
- Values are readable by developers and above, never by viewers or read-only keys. They are not encrypted secrets — do not reuse a value you could not afford a teammate to see.

## Deleting a project

Deleting removes the site, functions, buckets and their files, databases, vector collections, memory stores, media and custom-domain connections. Domains bought in the workspace stay in the workspace and keep renewing.

- Refused with 409 `deletion_blocked` while the project holds unwithdrawn earnings, a pending withdrawal, a checkout link that can still be paid, or a running deploy. `blockers[]` says what to do.
- If something cannot be removed, the answer is 502 `teardown_incomplete`; the project is kept with status teardown_failed, accepts only reads and deletes, and deleting again retries.
- Deleting needs an admin, or a full workspace key. Project keys and read-only keys cannot delete projects.

## Reference

**SDK**

```js
const { project } = await hk.projects.create({ name: 'my-app' });
const { project: p, serving, services } = await hk.projects.get(project.id);   // p.live_url, serving.state
const { projects } = await hk.projects.list();
await hk.projects.delete(project.id);
```

**CLI**

```bash
harakumo projects
harakumo projects create my-app
harakumo projects delete 12
```

**REST**

```http
POST   /api/projects                   { "name": "my-app", "repoUrl"? }
GET    /api/projects                   # each with live_url
GET    /api/projects/:id               # live_url, serving, deployAction, services
PATCH  /api/projects/:id               { "name"?, "repoUrl"?, "repoBranch"?, "mailSenderName"?, "mailFrom"?, "mailReplyTo"? }
DELETE /api/projects/:id               # 409 deletion_blocked · 502 teardown_incomplete
POST   /api/projects/:id/env           { "key": "SHIPPING_API_TOKEN", "value": "…" }   # upsert
GET    /api/projects/:id/env           # developer+
PATCH  /api/projects/:id/env/:envId · DELETE /api/projects/:id/env/:envId
```

---

# Deployments

Three ways in, one pipeline: a GitHub repository (when you press Deploy or call the API), an upload (dashboard, CLI, SDK or files sent by an AI assistant), or a commit in Harakumo Repos. A failed deploy never replaces a working one.

## In plain words

Puts your website or app on the internet at its own address. Upload your files or point it at your code on GitHub, and it builds them. If a new version fails, the last working one stays online.

For example: A bakery uploads its menu site and it is live at its own address a minute later.

## How your code is recognized, in order

1. A `worker.js` (or `_worker.js`) at the root becomes an edge program with your production environment variables (see Edge programs below).
2. A Next.js app that is not a static export becomes a server app.
3. An index.html in the root or in dist/, build/, out/, _site/, public/, site/ or docs/ becomes a static site. (From Git, a repository with a build script only serves dist/, build/, out/ or _site/.)
4. A package.json build script (Vite, Astro, Create React App …) builds on a build machine; the output becomes a static site. The build machines have Node.js but no Flutter toolchain, so a Flutter web build (`flutter build web`) is not started: the deploy answers 422 with code `flutter_web_build` instead of starting a build that cannot finish. Run `flutter build web` on your machine and upload the `build/web` folder, which is a static site.
5. A package.json start script that runs a server (Express and friends, or an Expo app's own server output) becomes a server app.
6. A phone app (Expo, React Native, Flutter, or an Xcode or Android Studio project at the root) with nothing above to deploy is not deployed. That is one whose start script only runs a development server for a phone (`expo start`, `expo run:ios`, `react-native start`, `react-native run-android`, `flutter run` and the like, also when the start script calls one through `npm run`, `yarn` or `pnpm`), or that has none. Phone apps ship through the App Store and Google Play. The deploy answers 422 with code `phone_app`, before anything is built, and says how to deploy the app's companion website from the same files: a site/ or docs/ folder with an index.html, or for Expo a build script that exports the web version. A GitHub deploy can instead fail during the build, with the same message in its log. See Use Harakumo from a phone app.

> A server app must not carry a static index.html in the places step 3 looks, or it is served as a static site. Keep its pages in another folder. Server apps (steps 2 and 5) are on Pro and up; static sites, edge programs and functions deploy on every plan.

## Static sites

- Served from the edge cache (the workspace's CDN setting), then object storage. They scale with traffic.
- Up to 500 files per site.
- Dotfiles (.env, .git …), environment files named without the dot (env.production, prod.env …) and key files (.pem, .key, id_rsa …) are never served; `/.well-known/` is.
- Pages answer without `.html`: `/privacy` is served from `privacy.html` when there is no file or folder by that exact name, and `/privacy/` with the slash redirects to `/privacy`. A folder with an index.html comes first: then `/privacy` redirects to `/privacy/`, which serves `privacy/index.html`.
- Single-page apps: put a `_redirects` file with the one rule `/* /index.html 200` in the folder the site is served from, and a route that matches no file is answered with index.html and a 200, for the app's own router. That is the only rule read from `_redirects`: any other rule in it is not applied, and the deploy log says how many were left out. Without it, a missing page is a 404 (your 404.html when there is one).
- Neither of those answers under `/.well-known/` or for a path with a file extension: app-link and domain checks get the real file or a real 404.
- Each file is served with the type its extension names. `apple-app-site-association`, which has no extension, is served as `application/json`.

## Edge programs (worker.js)

- Your own program on the edge, on every plan: its default export's `fetch(request, env, ctx)` answers every request to the project's address, at the edge location nearest each visitor, with nothing to keep awake. It reads the project's production variables and `HARAKUMO_PROJECT_ID` as `env.NAME`.
- CPU time: each request may use 50 ms on Hobby, 1 second on Pro, 2 seconds on Team and 10 seconds on Scale, the same as a function on that plan. Waiting on the network or a database does not count, so an API that calls other services rarely comes near it; work done in the program itself (resizing images, making PDFs, heavy cryptography) does. A request past it fails. The deploy log names the ceiling, and a plan change applies on the next deploy.
- Changed on 2026-10-11: a worker.js program deployed before then has no ceiling of its own, and gets the plan's on its next deploy. On Hobby, check that a program doing heavy work per request still answers after redeploying it, or move that work to a server app.
- In front of your code, from its next deploy: plain HTTP is redirected to HTTPS, security headers are added, and Shield's country rules turn a blocked visitor away before the program runs. Shield's address lists, AI-crawler rule and log-only mode do not reach a worker.js program yet.

## Server apps

- On Pro and up. On Hobby a deploy that would run as a server app answers 402 `plan_limit` (with `required_plan`) before anything is built. Server apps that were already live on a free workspace when server apps moved to Pro keep running and can be redeployed. A server app deployed on a paid plan is not one of those: if its workspace moves to Hobby, new deployments of it are refused.
- Run with `npm start` (or `next start`) on Node 22 in one container: about a quarter of a CPU and 1 GB of memory, one copy.
- Listen on `process.env.PORT`. Production environment variables, and `HARAKUMO_PROJECT_ID`, are injected at deploy.
- Plain HTTP is redirected to HTTPS, and answers carry HSTS, nosniff and a referrer policy. Shield's country rules are checked before the app is woken, so a blocked visitor never reaches it; a change to them reaches the app within about a minute, with no redeploy.
- Deploying a static site or a worker.js program over a server app stops the app's containers and deletes their snapshots; when they cannot be stopped that time, the deploy log says so, the old app gets no traffic from the address, and deleting the project stops it.
- After 15 idle minutes the app sleeps; the next visitor sees a short starting page while it wakes (usually seconds, longer on a cold start).
- WebSockets reach the app. Messages on an open connection count as activity, so the app stays awake while they flow; a connection that has gone quiet does not keep it awake.
- The disk is wiped on every sleep and deploy: keep uploads and data in Storage and Databases.
- If the app crashes three times, the project shows App crashed with the app's own output, and visitors get a plain 503 page.
- At most 40 server apps are awake across the platform today; sleeping apps do not count.
- On Pro, Team and Scale, every hour a server app is awake is charged at $0.05 from the paying workspace's Balance, daily, with no hours included (Team's $20 monthly usage credit pays first); an app asleep costs nothing. While a charge cannot be paid, new deploys wait; apps already running keep running. A Pro subscription from before these plans and Enterprise are not charged by the hour.

## From GitHub

- Set the repository once (at project creation or `PATCH /api/projects/:id { repoUrl, repoBranch }`). Only github.com repositories are supported.
- Deploys run when you press Deploy or call the API. A push to GitHub does not deploy on its own yet.
- Only the production branch deploys. A different branch is refused unless `repoUrl` is set in the same call, which makes that branch production.
- Private repository: add a `GITHUB_TOKEN` environment variable to the project (a fine-grained token with read access to that repository). It is used only for that project.

## From an upload

- Up to 25 MB compressed, 40 MB unpacked, 10 MB per file and 5,000 files.
- Leave out node_modules and build output: the build machine installs and builds. The dashboard leaves node_modules, .next, .git, .vercel, .turbo, .cache and .env files behind for you.
- Put secrets in environment variables, never in an uploaded .env file.
- Harakumo also reads the text of every uploaded file for what looks like a real key (a private key, a live Harakumo or payments key, a cloud access key, a Git hosting or chat app token, an AI service key). The deploy goes ahead, and its log and the upload's answer (`warnings`) name the file and line, never the key: revoke such a key and keep the new one under Environment. Deploys from GitHub are not read this way.
- A key limited to the project can deploy it; a read-only key can read deployments and logs.

## What a folder upload from your machine leaves out

From version 0.3.0 of the CLI and the SDK, uploading a folder from your machine (`harakumo deploy <projectId> <dir>`, or `hk.deployments.uploadDir` in Node) sends what `git add` would. Before anything goes, it says how many files it will send, their total size, and what it left out and why:

- Installed packages, build output, caches and tool folders, wherever they sit: node_modules, .git, .next, .vercel, .turbo, .cache, .svelte-kit, .nuxt, .output, .astro, coverage, __pycache__, a Python virtual environment and the like. The build machine makes its own.
- dist/, build/ and out/ at the top of the folder when package.json has a build script, because the build machine runs it and makes them again.
- Files that hold secrets by their name: .env files however they are spelled, private keys and certificates, cloud service-account keys, saved logins (.npmrc, .netrc, .pgpass and the like) and the folders where tools keep keys and logins, such as .ssh/. Set those values as environment variables instead (`harakumo env set <projectId> KEY=VALUE`).
- Whatever a .gitignore or a .harakumoignore excludes: in the folder, in any folder inside it, and in the .gitignore files above it up to the top of its Git repository. A .gitignore higher up that excludes the folder itself (a repository that ignores dist/, when dist/ is what you upload) is not applied, since you chose that folder.
- System files such as .DS_Store, and symbolic links, which are never followed.

## .harakumoignore

A `.harakumoignore` file leaves out more, or puts something back, without changing your .gitignore. It uses the .gitignore syntax: one pattern per line, `#` for a comment, `/` at the end for folders only, `/` at the start to match only beside the file, `*`, `?`, `[a-z]` and `**`, and `!` to bring back what an earlier rule left out. One inside a subfolder applies to that subfolder.

A `!dist/` line sends a dist/ folder that the build-script rule would leave out, for a site you build on your own machine.

**.harakumoignore**

```text
# Leave these out
drafts/
*.psd
/notes.md

# Send the dist/ folder built on this machine
!dist/
```

## A secret written in a file stops the upload

Before sending, the CLI and the SDK read the files that would go and look for secrets written inside them: a private key, a live Harakumo API key, a live payment secret key, a cloud access key, a GitHub, Slack or npm token, or an AI provider's key. If they find one, nothing is uploaded. The file and line are printed, never the secret. Move the value into an environment variable that only server code reads, or list the file in .harakumoignore.

Example values, such as `hk_live_…` or a key containing "example" or "your", do not count. A private key always does.

## See what would go first: --dry-run

`harakumo deploy <projectId> <dir> --dry-run` lists every file that would be uploaded and its size, then what it left out and why, and sends nothing. It needs no login. It stops where a real upload would stop before sending: a secret written in a file, a folder with nothing left to send, or one over the upload limits.

In code, `planUpload(dir)` (also `hk.deployments.planUpload`) returns the same plan without sending it, and `uploadDir(projectId, dir, { plan })` sends exactly that plan.

**CLI**

```bash
harakumo deploy 12 ./my-app --dry-run   # what would go, and what stays here
harakumo deploy 12 ./my-app             # the same check, then the upload
```

## Redeploy and roll back

`POST /api/deployments/:id/redeploy` deploys the same source again as a new deployment: the kept files of an upload (the serving deployment's files are kept) or a GitHub deployment's commit. Redeploying an older GitHub deployment is a rollback. This is also how environment variable changes take effect.

`DELETE /api/deployments/:id` deletes one deployment with everything it left behind: its files, its kept source and snapshot and, for a server app, its container. It is refused with 409 while it is still running, for the deployment the project serves now, and for one that still answers a custom domain (`deployment_serving`). If any part cannot be removed, the deployment is kept and the answer says so (502); deleting again retries.

## Build minutes

The time each deploy takes, failed ones included, counts toward the build minutes the plan includes each calendar month (UTC): 100 minutes hobby, 1,000 minutes pro, 5,000 minutes team, 10,000 minutes scale, on Hobby across all your free workspaces, and on a paid plan across the workspaces it covers. On Hobby, new deploys are refused once the plan's build minutes allowance is used up (402 `usage_limit`). On Pro, Team and Scale, use past it is charged daily from the paying workspace's Balance at $0.01 per minute (Team's $20 monthly usage credit pays first), and new deploys pause (402 `usage_paused`) only while a charge cannot be paid. Billing → Usage shows the month so far.

## Status and logs

A deployment moves queued → building → deploying → ready, or failed with the reason in its logs. Server apps also report runtime status (running, crashed or failed) and recent output in `runtime_status` and `runtime_logs`.

Each deployment records who started it. `triggered_by` is the person: whoever was signed in, the owner of a connected AI assistant, or for an API key (the CLI, the SDK, an agent) whoever created the key. `triggered_via` names the API key when one was used, for example "API key: ci", and is empty otherwise.

**Deploy a folder and wait for the URL (SDK)**

```js
const { deployment } = await hk.deployments.uploadDir(projectId, './dist');
let d = deployment;
while (!['ready', 'failed'].includes(d.status)) {
  await new Promise(r => setTimeout(r, 3000));
  ({ deployment: d } = await hk.deployments.get(deployment.id));
}
console.log(d.status, d.preview_url);
```

## Reference

**SDK**

```js
await hk.projects.update(project.id, { repoUrl: 'https://github.com/you/repo', repoBranch: 'main' });
const { deployment } = await hk.deployments.create(project.id);            // latest commit on the production branch
const { deployment: d } = await hk.deployments.get(deployment.id);        // status, logs, preview_url

const { deployment: up } = await hk.deployments.uploadDir(project.id, './my-app');   // Node: a folder
// await hk.deployments.uploadArchive(project.id, tarball);                // a gzip tar (Buffer, Uint8Array or Blob)
```

**CLI**

```bash
harakumo deploy 12               # the project's GitHub repository
harakumo deploy 12 ./my-app      # upload a folder
harakumo deploy 12 ./my-app --dry-run   # list what would be uploaded; sends nothing
harakumo deploys 12              # recent deployments with status and URL
```

**REST**

```http
POST  /api/projects/:id/deployments   { "repoUrl"?, "branch"? }     # branch must be the production branch
GET   /api/deployments/:id            # status, logs, preview_url, runtime_status, runtime_logs
POST  /api/deployments/:id/redeploy   # same files or commit again (rollback for GitHub)
DELETE /api/deployments/:id           # one deployment and its files; 409 for the one being served

# Deploy without a repository — a gzip tar (tar -czf app.tgz -C my-app .):
curl -X POST https://harakumo.com/api/projects/:id/deployments/upload \
  -H "Authorization: Bearer hk_live_…" -H "content-type: application/gzip" --data-binary @app.tgz

# … or files as JSON (binary files with "base64": true):
curl -X POST https://harakumo.com/api/projects/:id/deployments/upload \
  -H "Authorization: Bearer hk_live_…" -H "content-type: application/json" \
  -d '{ "files": [{ "path": "index.html", "content": "<h1>hi</h1>" }] }'
```

---

# Functions

A function is one ES module with a default `fetch` handler, deployed to the edge. Each request runs at the location nearest the caller with no server to size or keep awake. Create one with code, or without code to get a starter handler you replace later.

A function can be a whole phone app's API: Use Harakumo from a phone app shows one that checks the user's token, reads the database and signs uploads.

## In plain words

A small piece of code with its own web address. It does one job each time it is called, with no computer of your own to set up or keep running.

For example: When a customer pays, the payment service calls a function that marks the order as paid.

## The address

Every function is served at `https://hk-<project-slug>--<function-name>.harakumo.app` — never under the project's own site address. The create response carries it as `function.url`.

Functions created before the move to harakumo.app have moved there, and `url` is the address to use. Their old harakumo.com address no longer runs the function: it redirects to the new one (301 for GET and HEAD, 308 for anything else, so a POST is sent again as a POST with its body). Webhook senders and pages on other sites usually do not follow redirects, so give them the new address.

## Runtimes

| runtime | What the code gets |
| --- | --- |
| node22 (default), node20 | Web APIs plus the Node.js compatibility layer: `node:crypto`, `Buffer`, `process.env`. |
| edge | Web APIs only: fetch, Request and Response, crypto.subtle, streams. |

> Change the runtime with `PUT /api/functions/:id { runtime }`, with or without new code: the function is deployed again with it.

## In front of your code

- A function runs behind a small guard of Harakumo's: one deployed before 2026-10-11 gets it on its next deploy. Plain HTTP is redirected to HTTPS (except the two paths certificate checks use), security headers your answer does not set are added, and a WebSocket upgrade is passed through as it is.
- Shield's rules apply before your code runs: a visitor from a blocked country, an address on the deny list and, when Shield blocks them, an AI crawler that names itself get 403 and your code does not run. In log-only mode they are counted and let through. What the rules caught shows on Dashboard → Shield.
- The guard takes Shield's rules as they are at the deploy: a later change to them reaches the function on its next deploy, and a Shield save names the functions still running the rules from before.

## Environment variables

- The project's production variables are available as `env.NAME` in the handler, with `HARAKUMO_PROJECT_ID`, the project's id.
- They are read when the function deploys. After changing one, redeploy the function: `PUT /api/functions/:id` with no `code` deploys its kept code again with the variables as they are now. The answer to a variable change names the functions still running the old values (`functions_behind`), and the project's Environment tab offers Redeploy.
- The function receives them as secrets: the handler reads `env.NAME` as usual, and the values cannot be read back from where the function runs. In Harakumo they stay visible to anyone who can read the project's environment (developers and above), so they are configuration, not a vault.

## The handler

The module's default export has a `fetch(request, env, ctx)` method, called once per request. `request` is a standard Request; `env` holds the project's variables (`env.NAME`); `ctx.waitUntil` keeps work going after the answer (below). Return a Response, or a promise of one.

The address is public: anyone who knows it can call it. Before doing anything private, check something only your callers have, such as a sign-in token or a shared secret in a header.

**Read the path, the query and a JSON body**

```js
export default {
  async fetch(request, env, ctx) {
    // https://hk-shop--api.harakumo.app/items/42?expand=1
    const url = new URL(request.url);
    const parts = url.pathname.split('/').filter(Boolean);   // ['items', '42']

    if (request.headers.get('x-api-secret') !== env.API_SECRET) {
      return Response.json({ error: 'Not allowed' }, { status: 401 });
    }

    if (request.method === 'GET' && parts[0] === 'items' && parts.length === 2) {
      const id = Number(parts[1]);
      if (!Number.isInteger(id)) return Response.json({ error: 'The id must be a number' }, { status: 400 });
      return Response.json({ id, expand: url.searchParams.get('expand') === '1' });
    }

    if (request.method === 'POST' && url.pathname === '/items') {
      let item;
      try {
        item = await request.json();
      } catch {
        return Response.json({ error: 'Send JSON' }, { status: 400 });
      }
      if (typeof item.name !== 'string' || !item.name.trim()) {
        return Response.json({ error: 'name is required' }, { status: 400 });
      }
      return Response.json({ created: item.name.trim() }, { status: 201 });
    }

    return Response.json({ error: 'Not found' }, { status: 404 });
  },
};
```

## Answer pages on other sites (CORS)

A browser lets a page call a function on another address only when the function says it may, with CORS headers, and for most calls it first sends an OPTIONS request to ask. Name the sites you trust rather than `*` when the function does anything private. Calls from your own server, a phone app or curl need none of this.

**CORS for one site**

```js
const ALLOWED = 'https://my-store.harakumo.app';

const cors = {
  'access-control-allow-origin': ALLOWED,
  'access-control-allow-methods': 'GET, POST, OPTIONS',
  'access-control-allow-headers': 'content-type, authorization',
  'access-control-max-age': '600',
  vary: 'origin',
};

export default {
  async fetch(request) {
    if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: cors });
    const res = Response.json({ ok: true });
    for (const [name, value] of Object.entries(cors)) res.headers.set(name, value);
    return res;
  },
};
```

## Where a request comes from

`request.cf` says where the request came from, worked out from the caller's IP address: `country` (two letters, such as "DE"), and when known `city`, `region`, `postalCode`, `latitude`, `longitude`, `timezone` and `continent`, plus `isEUCountry` ("1" inside the EU) and `colo`, the edge location that answered. Any of them can be missing, so keep a default.

It is a guess from the address. Someone on a VPN or a company network shows where that network is, so use it to choose a language, a currency or a nearby store, never to prove where a person is. A test call from the dashboard or the API is made by Harakumo, so it shows where that request came from, not you.

Shield's country rules reach a function from its next deploy: the guard in front of its code turns a blocked visitor away before the code runs. For anything else that depends on the country, check `request.cf.country` yourself.

**Prices in the caller's currency**

```js
const CURRENCY = { US: 'USD', GB: 'GBP', JP: 'JPY', IN: 'INR' };

export default {
  async fetch(request) {
    const where = request.cf || {};
    const country = where.country || 'US';
    return Response.json({
      country,
      currency: CURRENCY[country] || (where.isEUCountry === '1' ? 'EUR' : 'USD'),
      timezone: where.timezone || 'UTC',
      city: where.city || null,
    });
  },
};
```

## Cache answers at the edge

A function can keep answers in the edge cache, so a repeat request is answered without running your code again. Functions share the edge with other Harakumo apps, so keep yours under a name only your project knows: put a long random value in an environment variable such as `CACHE_NAME` and open the cache with it. Do not keep anything private in a cache opened under a name someone could guess.

Only GET requests are cached here. An answer is kept for as long as its `cache-control` header says, in the edge location that saved it: a caller somewhere else may get a fresh answer, and an entry can go before its time. A redeploy does not clear it; change `CACHE_NAME` to start empty. Never cache an answer that depends on who is asking, or one that sets a cookie.

**Keep a GET answer for five minutes**

```js
async function answer(request, env) {
  const res = await fetch('https://example.com/rates.json');   // slow, or paid per call
  return Response.json(await res.json());
}

export default {
  async fetch(request, env, ctx) {
    if (request.method !== 'GET' || !env.CACHE_NAME) return answer(request, env);
    const cache = await caches.open(env.CACHE_NAME);
    const hit = await cache.match(request);
    if (hit) return hit;

    const fresh = await answer(request, env);
    if (!fresh.ok) return fresh;
    const res = new Response(fresh.body, fresh);
    res.headers.set('cache-control', 'public, max-age=300');
    ctx.waitUntil(cache.put(request, res.clone()));   // saved after the answer goes
    return res;
  },
};
```

## Work after the answer

`ctx.waitUntil(promise)` lets work finish after the response has been sent, so the caller does not wait for it: a log line, a webhook to another service, a cache write. It is for a short while (seconds, not minutes), and nobody sees it fail, so catch its errors. Anything that must happen belongs before the response.

**Send a log line without slowing the answer**

```js
export default {
  async fetch(request, env, ctx) {
    const started = Date.now();
    const res = Response.json({ ok: true });
    ctx.waitUntil(
      fetch(env.LOG_URL, {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({ path: new URL(request.url).pathname, ms: Date.now() - started }),
      }).catch(() => {}),
    );
    return res;
  },
};
```

## Use the project's other services

A function calls the Harakumo API with plain `fetch` and a key kept in an environment variable: a key limited to the function's project is the narrowest that works. setup_app_backend from an AI assistant creates one and stores it, with the database and bucket ids, as `HARAKUMO_API_KEY`, `HARAKUMO_DATABASE_ID` and `HARAKUMO_BUCKET_ID`. Never send the key back in an answer.

**Read from the database**

```js
async function query(env, sql, params = []) {
  const res = await fetch('https://harakumo.com/api/databases/' + env.HARAKUMO_DATABASE_ID, {
    method: 'POST',
    headers: { authorization: 'Bearer ' + env.HARAKUMO_API_KEY, 'content-type': 'application/json' },
    body: JSON.stringify({ sql, params }),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data.error || 'Harakumo answered ' + res.status);
  return data.results;
}

export default {
  async fetch(request, env) {
    const id = Number(new URL(request.url).searchParams.get('id'));
    if (!Number.isInteger(id)) return Response.json({ error: 'Send ?id=' }, { status: 400 });
    try {
      const [product] = await query(env, 'SELECT id, name, price_cents FROM products WHERE id = ?', [id]);
      return product ? Response.json(product) : Response.json({ error: 'No such product' }, { status: 404 });
    } catch (err) {
      return Response.json({ error: err.message }, { status: 502 });
    }
  },
};
```

## Status and failed deploys

- `status` is `creating` while the first deploy runs, `active` once the function answers at `url`, and `error` when its last deploy (or a delete) did not go through. `last_error` says why; `url` is null unless the function is `active`.
- Code the edge refuses (a syntax error, an import that does not exist, too much work while the module loads) is answered with 400 and the edge's own reason. On create nothing is kept; on redeploy the running version keeps serving.
- Problems on our side answer 502 or 503 with what went wrong; a 503 carries Retry-After. A failed deploy is never reported as a working function.
- If the code uploaded but its address could not be connected, the function is kept with status `error`: redeploy it (`PUT /api/functions/:id`) to try again, or delete it.

## Versions and rollback

Every deploy that changes a function's code is a version, numbered from 1: the create, a redeploy with new code, and a rollback. Deploying the same code again is not a new version. The newest 20 are kept, and the running one too if it is older; `current` is the version the function runs now. Its page in the dashboard lists them.

A version whose `status` is `failed` was refused when it was deployed: it never ran, and the version before it kept serving. `last_error` says why.

A rollback deploys a kept version's code again, with the runtime it ran with, as a new version whose `rolled_back_from` names the one it came from. It runs with the project's production variables as they are at that moment: a version records the names of the variables it was deployed with (`env_keys`), never their values.

A rollback fails the way a redeploy does. When the code is refused, the version that was live keeps serving, and the answer says why and which version that is. It takes the same permission as a redeploy; anyone in the workspace can list versions, and a version's code is for developers and above, like the function's own code.

**List versions**

```js
const { versions, current } = await hk.functions.versions(fn.id);
// versions, newest first: [{ version: 3, status: 'deployed', current: true, rolled_back_from: 1, env_keys: ['API_KEY'], … }, …]
```

**Roll back**

```js
const { version, url } = await hk.functions.rollback(fn.id, 2);
// version 2's code is live again, as a new version
```

## Limits

- One module, at most 200 KB, no npm install and no bundler — bundle dependencies yourself if you need them.
- CPU time per request: 50 ms on Hobby, 1 second on Pro, 2 seconds on Team and 10 seconds on Scale. Waiting on fetch or a database does not count; a request past it fails. A plan change applies on the function's next deploy.
- One deploy, rollback or delete of a function at a time: another sent while one runs answers 409 `deploy_in_progress`.
- Code that does not parse, or imports something that does not exist, is refused with 400 and the edge's reason; nothing is created.
- Invoking from the API or dashboard is a test call: it forwards method, path, headers and body, waits up to 10 seconds and returns the status, headers and the first 16 KB of the body. Real traffic goes to the URL and is not counted or capped yet.
- Test calls are counted as test calls, never as traffic: `test_call_count` on the function, and "Function test runs" under Usage. `invocation_count` (requests to the URL) is null until traffic is measured.
- No logs and no scheduled (cron) runs yet: a function runs when its URL is called. Work that must happen on a timer is an agent's job: an agent can run on a schedule (see Agents).
- Developers and above can read a function's stored source; viewers cannot.
- Keys: a key limited to the project can create, deploy and delete its functions; a read-only key can read and test-invoke them.

## Reference

**SDK**

```js
const code = `export default {
  async fetch(request, env) {
    return Response.json({ hello: 'world', region: env.REGION ?? 'edge' });
  },
};`;
const { function: fn } = await hk.functions.create(project.id, { name: 'hello', runtime: 'node22', code });
console.log(fn.url);                            // https://hk-<project>--hello.harakumo.app
await hk.functions.update(fn.id, { code });     // redeploy (also picks up env changes)
const { versions } = await hk.functions.versions(fn.id);
await hk.functions.rollback(fn.id, 1);          // version 1's code, live again as a new version
```

**CLI**

```bash
harakumo functions 12                            # a project's functions
harakumo functions deploy 12 hello ./hello.js    # create, or redeploy with new code
harakumo functions invoke 3 /ping
harakumo functions versions 3                    # every version, newest first
harakumo functions rollback 3 1                  # deploy version 1's code again
```

**REST**

```http
POST   /api/projects/:id/functions   { "name": "hello", "runtime"?: "node22"|"node20"|"edge", "code"? }
GET    /api/functions/:id            # detail; developers also get { source, envKeys }
PUT    /api/functions/:id            { "code"?: "export default { fetch() { … } }", "runtime"? }   # redeploy; no code = the kept code with today's variables
POST   /api/functions/:id            { "method"?, "path"?, "headers"?, "body"? }       # test invoke
DELETE /api/functions/:id
GET    /api/functions/:id/versions[?source=1]          # newest first, with current
GET    /api/functions/:id/versions/:n
POST   /api/functions/:id/versions/:n/rollback         # a new version with rolled_back_from
```

---

# Repos

Repos need no outside account or token. Every push saves a full snapshot as commit 1, 2, 3 …; deploying an older number is a rollback. An assistant connected over MCP can create a repo, push what it wrote and put a commit live in one conversation (create_repo, push_files, get_repo_files, deploy_repo, list_repos).

## In plain words

A place inside Harakumo to keep your app's code, with every saved version numbered, so any version can be put live again.

For example: An AI assistant saves each change as a new version. If version 8 breaks something, you put version 7 back.

## Limits

- A push over REST: up to 500 files, 10 MB per file, 25 MB in total. One push_files tool call is smaller (300 files of about 600 KB), so large projects push over REST or in parts.
- Text files up to 512 KB read back one by one; larger and binary files are listed with their size. Binary files are pushed base64-encoded (`"base64": true`).
- Environment files (.env and friends) are never readable back through the API. Do not commit secrets.
- Repos are workspace-wide: project keys cannot use them.
- Written through the API and the AI connector, and browsed in Nucleus (nucleus.harakumo.com), which shows every repo in your workspaces with its files and commits. There is no git push or SDK method for repos yet.

## Reference

**SDK**

```js
// Repos are reached over REST or the AI connector; the SDK has no repo methods yet.
```

**REST**

```http
GET    /api/repos                    # every repo, with commit counts
POST   /api/repos                    { "name": "school-site", "description"? }
GET    /api/repos/:id                # the repo and its commit history
POST   /api/repos/:id/commits        { "message": "…", "files": [{ "path": "index.html", "content": "…" }] }
GET    /api/repos/:id/files?commit=2                  # file tree at a commit (default: latest)
GET    /api/repos/:id/files?commit=2&path=index.html  # one file
POST   /api/repos/:id/deploy         { "projectId": 12, "commit": 1 }   # an older commit is a rollback
DELETE /api/repos/:id
```

---

# Databases

Every database you create is a separate SQLite database on the edge — two databases never share a file or a table. Engine "sqlite" is the one available today; Postgres, MySQL and Redis answer 501 until they exist.

There is no host and port: every query is a `POST https://harakumo.com/api/databases/:id` with an API key. Keep the key on your server.

## In plain words

Where your app keeps its records in tables, a bit like a spreadsheet: customers, orders, bookings. Your app reads and changes them, and you can look at them in the dashboard.

For example: A salon's booking app keeps each appointment as a row with the name, the time and the treatment.

## Connect from your app

1. Create the database (dashboard, CLI or `POST /api/projects/:id/databases { name }`) and note its id. The database page's Connect tab shows the endpoint, the id and snippets with your real values.
2. Create an API key limited to the project (Settings → API access; full access, Project set). Queries need full access, even for SELECT.
3. Put the key and the database id in the project's environment variables (for example HARAKUMO_API_KEY and NOTES_DB_ID) and redeploy.
4. Query from server code: the SDK, fetch or curl.

**fetch**

```js
const res = await fetch('https://harakumo.com/api/databases/' + process.env.NOTES_DB_ID, {
  method: 'POST',
  headers: { authorization: 'Bearer ' + process.env.HARAKUMO_API_KEY, 'content-type': 'application/json' },
  body: JSON.stringify({ sql: 'SELECT * FROM notes WHERE id = ?', params: [42] }),
});
const { results, meta } = await res.json();   // results: rows of the last statement
```

**curl**

```bash
curl -X POST https://harakumo.com/api/databases/5 \
  -H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
  -d '{ "sql": "SELECT count(*) AS n FROM notes" }'
```

## Responses

- A single request answers `{ results, statements, meta }`. SQL holding several statements runs them in order; `results` holds the last statement's rows and `statements` how many ran.
- `meta` has the rows read and written, changes and last_row_id from the database.
- Use `?` placeholders with `params` for every value — never build SQL by joining strings.

## Batch

Send up to 100 statements, each with its own params, in one request. It counts once toward the workspace rate limit, so batching is the way to do bulk work. Do not rely on a failing statement rolling back the earlier ones in the same batch until that behavior is confirmed; design writes to be safe to retry.

**Batch**

```bash
curl -X POST https://harakumo.com/api/databases/5 \
  -H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
  -d '{ "batch": [
        { "sql": "INSERT INTO notes (body) VALUES (?)", "params": ["first"] },
        { "sql": "INSERT INTO notes (body) VALUES (?)", "params": ["second"] },
        { "sql": "SELECT count(*) AS n FROM notes" } ] }'
# → { "batch": [ { "results": [], "meta": {…} }, …, { "results": [{ "n": 2 }], "meta": {…} } ] }
```

## Export

`POST /api/databases/:id/export` starts an export and answers `{ status: "active", bookmark }` while it runs; call again with the bookmark until it answers `{ status: "complete", download }`. `GET /api/databases/:id/export?bookmark=…` returns the .sql file (schema and data), which any SQLite tool can load. The Connect tab has an Export button.

While an export runs, that database takes no other queries until it finishes (seconds for a small database, longer for a large one): export at a quiet time.

There is no point-in-time restore or import through Harakumo yet. Export regularly if the data matters, and treat delete as permanent.

## Limits

| Limit | Value |
| --- | --- |
| Size of one database | 10 GB |
| One row or value | 2 MB |
| One SQL statement | 100 KB |
| Bound parameters per statement | 100 |
| Time per query | 30 seconds |
| Columns per table | 100 |
| Statements per batch | 100 |
| Query requests per workspace | 600 per 5 minutes |
| Databases per plan | 1 hobby, 10 pro, 50 team, 100 scale |
| Database storage per plan (all databases together) | 0.5 GB hobby, 5 GB pro, 25 GB team, 100 GB scale, measured daily; on Hobby, across all your free workspaces; on a paid plan, across the workspaces it covers; a Pro subscription from before these plans keeps 10 GB |

> On Hobby, database writes that add data (reads and deletes still work) are refused once the plan's database storage allowance is used up (402 `usage_limit`). On Pro, Team and Scale, use past it is charged daily from the paying workspace's Balance at $1.00 per GB a month (Team's $20 monthly usage credit pays first), and database writes that add data pause (402 `usage_paused`) only while a charge cannot be paid.

## Errors

| Status | Meaning |
| --- | --- |
| 400 | A SQL mistake; the message is the database's own. |
| 402 | The plan's database storage is used up, so statements that add data (INSERT, UPDATE, REPLACE, UPSERT, CREATE, ALTER) are held: `usage_limit` on Hobby, `usage_paused` on a paid plan that cannot pay for more. Reads, DELETE and DROP still run, so you can clean up (a DELETE is held too when the database has triggers, since a trigger can add data when rows are deleted: drop the triggers first), but a batch that contains any statement that adds data is held as a whole: send reads and deletes in a batch of their own. A word inside quotes, a quoted name or a comment does not count. The size is measured again daily, so room you free counts from the next measurement. |
| 409 | The database is not available (its create failed, or it was never created). A failed create shows the reason; retry with `PATCH { "action": "retry" }` — it does not count against the plan. |
| 410 | The database no longer exists on the edge. |
| 429 | The workspace rate limit or the database service is throttling. Wait Retry-After seconds, and batch. |
| 502 / 503 | A platform problem. Retry shortly. |

## How it scales

Each database has one writer and stops at 10 GB, and every query is an HTTPS call through Harakumo, capped at 600 requests per 5 minutes per workspace. That suits development, internal tools and low-traffic apps. For more: batch statements, cache reads in your app, and split data across several databases (per customer, per region). Direct connections from your functions and apps, read replicas and a Postgres engine for single datasets over 10 GB are planned.

## Moving from MongoDB

@harakumo/docdb keeps the MongoDB and Mongoose call shapes (find, updateOne, populate, bulkWrite …) and stores documents as JSON in a Harakumo database, so an app moves without rewriting every query. It is in preview and not yet published to npm; ask support for access.

## Reference

**SDK**

```js
const { database } = await hk.databases.create(project.id, { name: 'app', engine: 'sqlite' });
const { results } = await hk.databases.query(database.id, 'SELECT * FROM users WHERE id = ?', [42]);
// Batches and export are REST calls (below); the SDK sends single statements.
```

**CLI**

```bash
harakumo db 12                                # list a project's databases
harakumo db create 12 app --engine sqlite
harakumo db query 5 "select count(*) from users"
```

**REST**

```http
POST   /api/projects/:id/databases     { "name": "app", "engine"?: "sqlite" }
GET    /api/databases/:id              # the database, its endpoint and limits
POST   /api/databases/:id              { "sql": "SELECT * FROM users WHERE id = ?", "params": [42] }
POST   /api/databases/:id              { "batch": [{ "sql": "…", "params": [] }, …] }   # up to 100
PATCH  /api/databases/:id              { "action": "retry" }     # re-run a failed create
POST   /api/databases/:id/export       { "bookmark"? }            → { status, bookmark, download? }
GET    /api/databases/:id/export?bookmark=…                      # the .sql file
DELETE /api/databases/:id              # permanent
```

---

# Storage

Files live in buckets; each bucket belongs to a project and is its own bucket in object storage. A file's key is its path (`avatars/42.png`); folders are just the part of the key before a slash.

Files are private. To upload or download, your server asks Harakumo for a signed URL for one key, and the client sends or fetches the bytes directly. There are no public bucket URLs and no S3-style access keys yet.

From a phone app, the app's backend function signs the link and the app sends the bytes: see Use Harakumo from a phone app.

## In plain words

Private folders for your app's files, such as photos, documents and videos. People upload and download straight from their browser with links that stop working after a while, so nothing is public by accident.

For example: A customer attaches a photo of a damaged parcel to a support request.

## Upload from the browser

1. The browser tells your server the file's size (`file.size`). Your server signs a PUT for a key, naming the Content-Type and that `size`.
2. The browser PUTs the file to that URL within 15 minutes, with the same Content-Type header. Buckets accept browser uploads from any origin.

**Server**

```js
// size: the file's length in bytes, sent by the browser
const { url, expiresAt } = await hk.storage.presign(bucketId, {
  key: 'avatars/' + userId + '.png', method: 'put', contentType: 'image/png',
  size, maxBytes: 5 * 1024 * 1024,   // refuse anything over 5 MB before a link exists
});
// hand url to the browser
```

**Browser**

```js
await fetch(url, { method: 'PUT', headers: { 'content-type': 'image/png' }, body: file });
```

## Upload links

- An upload link lasts at most 15 minutes. A longer `expiresIn` is shortened to 900 rather than refused (any value up to 604800, the most any link may ask for): the answer's `expiresIn` is what the link got, `requestedExpiresIn` what you asked for, and `note` says so.
- With `size`, the file's exact length in bytes, the link accepts exactly that many bytes and no other length, so nobody who gets hold of it can upload more. `size` is optional on every plan, Hobby included, but a link without it is not locked to any length: whoever holds it can send one file of up to 5 GB. Send `size` before a link goes into an app. The dashboard's Upload button and the SDK's `hk.storage.upload` send it for you.
- A `size` over the plan's per-file cap (1 GB on Hobby, all the file storage it includes; 5 GB on the other plans) is refused with 413 `file_too_large` and no link. So is one over `maxBytes`, the most you will accept; `max_bytes` in the answer is the limit that refused it.
- `maxBytes` without `size` is refused with 400 `size_required`, since a link cannot be held to "at most": send both.
- `contentType` is signed too: the PUT must send exactly that Content-Type.

## Resumable uploads

For a big file, or a phone on a bad network: the file goes up in parts, each straight to storage on its own link, so a part that fails is sent again on its own and an upload left half done carries on later from the parts already there.

1. Start it: `POST /api/storage/:id/uploads { key, size, contentType?, partSize?, maxBytes? }` → `{ uploadId, partSize, partCount, expiresAt }`. `size`, the file's exact length, is required here. Parts are 8 MiB unless `partSize` (5 MiB up to 5 GiB) says otherwise; every part but the last is that long.
2. Get links: `POST /api/storage/:id/uploads/:uploadId { action: "sign", parts: [1, 2, …] }`, up to 100 parts a call. PUT exactly that part's bytes to each `url` within 15 minutes; a link takes no other length.
3. Carry on after a dropped connection: `GET /api/storage/:id/uploads/:uploadId` lists the parts storage holds (`uploaded`) and the ones still `missing`; `GET /api/storage/:id/uploads` finds an open upload again by its key.
4. Finish: `{ action: "complete" }` joins the parts into the file (409 `parts_missing`, with `missing`, until every part is there). `DELETE` on the upload stops it and drops its parts.

> One file is held to the same per-file cap as a single upload (413 `file_too_large`). An upload must be finished within six days of its start (410 `upload_expired` after). In Node, SDK 0.3.0 and later does all of it with `hk.storage.uploadResumable(bucketId, key, data)`, and `hk.storage.uploads` has each call for a server that hands a phone the part links. Starting, signing and finishing need the developer role (a full key); any member can read an upload.

## Download links

Sign a GET for a key and send the person (or an img tag) to the URL. Links last 15 minutes by default; pass `expiresIn` from 60 seconds to 7 days (604800). Viewers and read-only keys get download links of at most 15 minutes. For files shown to many people, sign a longer link once and reuse it instead of signing on every view.

**curl**

```bash
curl -X POST https://harakumo.com/api/storage/7 \
  -H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
  -d '{ "key": "reports/q3.pdf", "method": "get", "expiresIn": 86400 }'
# → { "url": "…", "expiresIn": 86400, "expiresAt": "…" }
```

## Listing and deleting

- `GET /api/storage/:id` lists objects: `prefix` narrows to a path, `delimiter=/` groups keys into `folders`, and `limit` (up to 1,000, default 100) with `cursor` pages through; `nextCursor` is set while more remain.
- `POST { key, method: "delete" }` removes one object. `DELETE /api/storage/:id` empties and deletes the bucket.
- Sizes and file counts are measured daily (or on `?measure=1`) and shown as a dash until a full count exists.

## Who can do what

- Viewers and read-only keys can list and get download links.
- Uploading and deleting need the developer role (a full key).
- A key limited to the project works for all of it.

## Limits and scaling

- One file is at most 5 GB, in one signed PUT or as a resumable upload in parts. On Hobby a link with `size` is at most 1 GB, all the file storage the plan includes.
- Keys are up to 1,024 characters.
- Buckets per plan: 2 hobby, 20 pro, 100 team, 500 scale.
- File storage per plan, all buckets together: 1 GB hobby, 50 GB pro, 250 GB team, 1,000 GB scale, measured daily (on Hobby, across all your free workspaces; on a paid plan, across the workspaces it covers; a Pro subscription from before these plans keeps 100 GB). On Hobby, new uploads are refused once the plan's file storage allowance is used up (402 `usage_limit`). On Pro, Team and Scale, use past it is charged daily from the paying workspace's Balance at $0.05 per GB a month (Team's $20 monthly usage credit pays first), and new uploads pause (402 `usage_paused`) only while a charge cannot be paid. Deleting files frees room once the bucket is measured again: daily, or right away with `GET /api/storage/:id?measure=1` (for a bucket of up to 10,000 files).
- The bytes never pass through Harakumo; each signed URL is one API call.

## Reference

**SDK**

```js
const { bucket } = await hk.storage.create(project.id, { name: 'uploads' });
const { url } = await hk.storage.presign(bucket.id, { key: 'file.png', method: 'put', contentType: 'image/png', size: 48213 });
const { url: download } = await hk.storage.presign(bucket.id, { key: 'file.png', method: 'get', expiresIn: 3600 });
const { objects, folders, nextCursor } = await hk.storage.objects(bucket.id);   // first page
await hk.storage.deleteObject(bucket.id, 'file.png');
```

**CLI**

```bash
harakumo storage 12              # list buckets
harakumo storage create 12 uploads
```

**REST**

```http
POST   /api/projects/:id/storage     { "name": "uploads" }
POST   /api/storage/:id              { "key": "file.png", "method": "put"|"get"|"delete", "contentType"?, "expiresIn"?, "size"?, "maxBytes"? }
GET    /api/storage/:id?prefix=&delimiter=/&limit=&cursor=     → { objects, folders, nextCursor, truncated }
DELETE /api/storage/:id              # empties and deletes the bucket
POST   /api/storage/:id/uploads      { "key", "size", "contentType"?, "partSize"?, "maxBytes"? }   # a resumable upload
GET    /api/storage/:id/uploads      # open uploads, newest first
GET    /api/storage/:id/uploads/:uploadId                  → { uploaded, missing } while open
POST   /api/storage/:id/uploads/:uploadId  { "action": "sign", "parts": [1, 2] } | { "action": "complete" }
DELETE /api/storage/:id/uploads/:uploadId                  # stop it and drop its parts
```

---

# Vector DB

Each collection is its own vector index. The default is 768 dimensions — what Harakumo's default embedding model (bge-base) produces, so text you send fits as-is. Choose 384 (bge-small) or 1,024 (bge-large) to match those models, or 1,536 for vectors you make elsewhere; larger is refused.

## In plain words

Search by meaning instead of exact words. You add your texts, and it finds the ones closest in meaning to what someone asks.

For example: Someone types "how do I get my money back" into a help center and finds the article called "Refunds".

## Embed, upsert, query

**SDK**

```js
const { collection } = await hk.vectors.create(project.id, {
  name: 'docs', dimensions: 768, metric: 'cosine',
  metadataIndexes: [{ propertyName: 'lang', indexType: 'string' }],
});

// Send text and let Harakumo embed it (metered on AI credits), or send your own "values".
await hk.vectors.upsert(collection.id, [
  { id: 'doc-1', text: 'Peaches are in season in July.', metadata: { lang: 'en' } },
  { id: 'doc-2', text: 'Mangoes ripen best at room temperature.', metadata: { lang: 'en' } },
]);

const { matches } = await hk.vectors.query(collection.id, {
  text: 'when can I buy peaches?', topK: 3, filter: { lang: 'en' }, returnMetadata: 'all',
});
```

## Namespaces and filters

- Give each vector a `namespace` (one per end user, say); a query that names the namespace sees only its vectors.
- Filters work on metadata fields made filterable with `metadataIndexes` at create time, or later with `POST /api/vectors/:id { action: "index-metadata", propertyName, indexType }`. Only vectors written after a field is indexed are filterable. Filtering on a field that is not indexed is a 400 that says so.
- Delete vectors with `POST /api/vectors/:id { action: "delete", ids: [...] }`.

## Limits

- Upsert up to 1,000 vectors per request (100 when sending text); metadata up to 10 KB per vector.
- topK up to 100, or 50 when returning full metadata or values.
- Upserts are applied in the background: new vectors become searchable in about a minute, and the upsert answer says so. `GET /api/vectors/:id` reports the index's own count, which trails new writes by as much.
- Collections per plan: 1 hobby, 10 pro, 25 team, 100 scale. Vectors per collection: 10,000 hobby, 1,000,000 pro, 1,000,000 team, 1,000,000 scale; a write that would pass it answers 402 `plan_limit` and writes nothing. Near the limit every vector sent counts as new until the index has processed it, even one that replaces an existing id, and a delete makes room once the index has processed it.
- Keys: a key limited to the project works for everything here; a read-only key can query but not upsert or delete.

## Reference

**SDK**

```js
const { embeddings } = await hk.ai.embed(['fresh mango', 'ripe banana'], 'bge-base');   // 768 dims
await hk.vectors.upsert(collectionId, [{ id: 'a', values: embeddings[0], metadata: { title: 'Mango' } }]);
const { matches } = await hk.vectors.query(collectionId, { vector: embeddings[1], topK: 5 });
```

**REST**

```http
POST /api/projects/:id/vectors   { "name": "docs", "dimensions"?: 768, "metric"?: "cosine", "metadataIndexes"? }
POST /api/vectors/:id/upsert     { "vectors": [{ "id": "doc-1", "values"?: [...], "text"?: "…", "metadata"?: {}, "namespace"? }] }
POST /api/vectors/:id/query      { "vector"?: [...], "text"?: "…", "topK"?: 5, "filter"?, "namespace"?, "returnMetadata"?: "all"|"indexed"|"none" }
GET  /api/vectors/:id            # the collection and its vector count
POST /api/vectors/:id            { "action": "delete", "ids": [...] } | { "action": "index-metadata", "propertyName", "indexType" }
DELETE /api/vectors/:id
```

---

# Memory

"kv" is a key-value store with TTL, to the second (carts, sessions, flags). "conversation" is an ordered transcript: append turns and read the last N back to build the next prompt. "semantic" embeds every value as you write it and recalls by meaning, with similarity scores.

One store serves every user of your app: pass a `scope` (a user or session id) on each call. Values, transcripts and searches in one scope never mix with another's.

## In plain words

Lets the AI in your app remember: facts about each person, the conversation so far, and notes it can look up later by meaning.

For example: A cooking assistant remembers that someone is vegetarian the next time they ask for a recipe.

## A multi-user chat

**REST from your server**

```js
const base = 'https://harakumo.com/api/memory/' + storeId;
const headers = { authorization: 'Bearer ' + process.env.HARAKUMO_API_KEY, 'content-type': 'application/json' };

// Append this user's turn to their own transcript
await fetch(base + '/messages', { method: 'POST', headers, body: JSON.stringify({ role: 'user', content: text, scope: userId }) });

// Read their last 20 messages to build the next prompt
const { messages } = await (await fetch(base + '/messages?scope=' + encodeURIComponent(userId) + '&limit=20', { headers })).json();

// Forget one user on request
await fetch(base + '/messages?scope=' + encodeURIComponent(userId), { method: 'DELETE', headers });
```

## Scopes

- A scope is 1–64 characters: letters, digits and . _ - : @ + = (so a user id or an email address fits as-is).
- Pass it in the query string, or in the body of a PUT or POST.
- A semantic search only matches values written in the same scope; a search with no scope sees only unscoped values.
- `PATCH /api/memory/:id { action: "flush", scope }` clears one scope; without scope it clears the store.

## Limits

- Values up to 1 MB; keys up to 512 characters.
- A conversation keeps the 1,000 most recent messages per scope; one read returns up to 500.
- Semantic values become searchable about a minute after they are written — a search right after a write can legitimately find nothing, and the response says so.
- Each plan caps items per store, and the bytes of every store together: on Hobby per workspace, on a paid plan across the workspaces it covers. Every kind of store counts (hobby: 10,000 items per store, 25 MB in all per workspace; pro: 100,000 items per store, 250 MB in all shared by the workspaces it covers; team: 250,000 items per store, 500 MB in all shared by the workspaces it covers; scale: 1,000,000 items per store, 1 GB in all shared by the workspaces it covers); a write past the cap gets a 402 naming it. Expired values are removed nightly.
- Semantic writes and searches are metered on AI credits (embedding), and searches share the embeddings rate limit.
- Every memory call spends from the workspace's data allowance of 600 calls per 5 minutes, shared with databases, vectors and video status checks: reading, writing or deleting a key-value value, appending to, reading or forgetting a conversation, listing keys and a flush are one call each, and so is a semantic write, delete or search. Past it, 429 `rate_limited` (`limit_name: workspace_data_plane`) with Retry-After, and nothing runs. Semantic calls, which reach the edge, also count toward the platform ceiling all workspaces share. Flushing a semantic scope is one call per 100 values, removes up to 1,000 values per call and answers `partial: true` until it is done; run it again.
- Keys: a key limited to the project works for everything here; a read-only key can read and search but not write.

## Reference

**SDK**

```js
const { store } = await hk.memory.create(project.id, { name: 'session', kind: 'kv' });
await hk.memory.set(store.id, 'cart', { items: 3 }, { ttlSeconds: 3600, scope: 'user-42' });

const { store: notes } = await hk.memory.create(project.id, { name: 'notes', kind: 'semantic' });
await hk.memory.set(notes.id, 'fact-1', 'Peaches cost $4 per pound.');
const { matches } = await hk.memory.search(notes.id, { query: 'what does fruit cost?', topK: 3 });
// Scoped reads, searches and transcripts: pass ?scope= over REST (below).
```

**REST**

```http
POST   /api/projects/:id/memory          { "name": "session", "kind": "kv" | "conversation" | "semantic" }
PUT    /api/memory/:id/keys/:key         { "value": {...}, "ttlSeconds"?, "scope"? }   # semantic values are embedded
GET    /api/memory/:id/keys/:key?scope=
DELETE /api/memory/:id/keys/:key?scope=
POST   /api/memory/:id/messages          { "role": "user", "content": "…", "scope"? }  # conversation
GET    /api/memory/:id/messages?scope=&limit=50                                        # oldest → newest
DELETE /api/memory/:id/messages?scope=                                                 # forget one scope
POST   /api/memory/:id/search            { "query": "…", "topK"?: 5, "scope"? }        # semantic
PATCH  /api/memory/:id                   { "action": "flush", "scope"? }               → { removed }
```

---

# AI Gateway

Call `POST /api/ai/chat` with a model and messages. Every answer reports the model that answered, the provider, tokens, latency, why it stopped and exactly what it cost. Usage is paid from the plan's included AI credit first, where the plan has one, then the Balance: on a paid plan, the Balance of the workspace that pays for the plan, for every workspace it covers, under that workspace's spending limit; on Hobby, the workspace's own.

`harakumo-1` (alias `auto`) is a router: it tries vendor models in order within a tier (fast, balanced or deep) until one answers, and is billed at the price of the model that answered — Claude Sonnet leads the balanced tier. Name a model to pin it; `llama-3.3-70b` and `kimi-k2.6` run on Harakumo's edge inference. A named model that cannot be reached right now may be answered by another (Kimi K3 by Kimi K2.6, for example): `modelUsed` and `fellBackFrom` always say which model answered, and it is billed at that model's price. Some models are off until an admin turns them on in Dashboard → AI Gateway; `GET /api/ai/chat` (SDK `hk.ai.models()`) marks each with `enabled` and its price.

## In plain words

AI models from several companies through one account. Your app asks a question and gets an answer. What it uses is paid from your balance, once any AI your plan includes is used up.

For example: A shop turns a few bullet points into a product description.

## Chat

**curl**

```bash
curl https://harakumo.com/api/ai/chat \
  -H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
  -d '{ "model": "harakumo-1", "system": "Answer in one sentence.",
        "messages": [{ "role": "user", "content": "What is an edge function?" }], "max_tokens": 300 }'
# → { "output", "modelUsed", "provider", "finishReason", "truncated", "tokensIn", "tokensOut", "cost", "latencyMs" }
```

**Streaming (server-sent events)**

```js
const res = await fetch('https://harakumo.com/api/ai/chat', {
  method: 'POST',
  headers: { authorization: 'Bearer ' + process.env.HARAKUMO_API_KEY, 'content-type': 'application/json' },
  body: JSON.stringify({ model: 'claude-sonnet-5', stream: true, messages: [{ role: 'user', content: 'Tell me a story.' }] }),
});
// Events: "meta" (the model answering), "delta" ({ text }), then "done"
// ({ finishReason, truncated, tokensIn, tokensOut, cost }) or "error".
for await (const chunk of res.body) process.stdout.write(new TextDecoder().decode(chunk));
```

**SDK**

```js
const answer = await hk.ai.chat({
  model: 'harakumo-1', system: 'Answer in one sentence.',
  messages: [{ role: 'user', content: 'What is an edge function?' }],
});
console.log(answer.output, answer.modelUsed, answer.cost);

for await (const ev of hk.ai.stream({ prompt: 'Tell me a story.' })) {
  if (ev.event === 'delta') process.stdout.write(ev.data.text);
  if (ev.event === 'done') console.log('\n', ev.data.cost);
}
```

## OpenAI-compatible

Point any OpenAI client at `https://harakumo.com/api/ai/v1` with a Harakumo key. The request and response shapes are the same (streaming included), `model` is any Harakumo model id, and each reply carries a `harakumo` field with the provider, cost and any fallback taken. Refusals come in OpenAI's error shape, `{ "error": { "message", "type", "code" } }`, with the codes below.

**OpenAI SDK**

```js
import OpenAI from 'openai';

const ai = new OpenAI({ apiKey: process.env.HARAKUMO_API_KEY, baseURL: 'https://harakumo.com/api/ai/v1' });
const r = await ai.chat.completions.create({ model: 'harakumo-1', messages: [{ role: 'user', content: 'Hi' }] });
console.log(r.choices[0].message.content);
```

## Request fields

| Field | Meaning |
| --- | --- |
| model | A model id, or `harakumo-1` / `auto` (the default when left out). `GET /api/ai/chat` lists the models this workspace can use and its limits. |
| messages or prompt | A chat history (`system`, `user`, `assistant` roles), or one prompt string. |
| system | A system prompt. |
| max_tokens | Default 1,024, up to 8,192 (2,048 on the Llama edge models). |
| temperature | 0–2 (Claude models are capped at 1). |
| tier | For harakumo-1: fast, balanced or deep. |
| stream | true for server-sent events. |
| projectId | Attribute the spend to a project (or send an X-Harakumo-Project header). A key limited to one project acts for its own project without it. |

> An answer that stopped at max_tokens has `truncated: true` and a note on how to get the rest.

## Embeddings

`POST /api/ai/embeddings { input, model }` returns vectors from bge-small (384 dimensions), bge-base (768, the default) or bge-large (1,024), up to 100 inputs per call. They pair with Vector DB collections of the same size.

## Price, limits and logs

- Hobby, Pro, Team and Scale include no AI usage: every request is pay as you go from the Balance (on a paid plan, the Balance of the workspace that pays for the plan, for every workspace it covers, under that workspace's spending limit; on Hobby, the workspace's own), at each model's per-token price: the model maker's list price, plus up to 5% on third-party models to cover buying that usage (edge models never carry it). On Hobby, vendor models need the owner's verified email. Workspaces that already have included AI keep it ($10 a month on a Pro subscription from before these plans and $100 a month on Enterprise), one amount per subscription shared by the workspaces it covers, used before the Balance. Team's $20 monthly usage credit pays for use past the plan, not for AI or domains, and what is not used does not carry over. `GET /api/ai/chat` lists the exact price of every model.
- Rate limit per workspace: 60 a minute on hobby, 600 a minute on pro, 2,000 a minute on team, 6,000 a minute on scale; chat and embeddings have separate buckets. Over it: 429 `ai_rate_limited` with Retry-After.
- Input up to 200,000 characters per request (413 over it).
- `GET /api/ai/requests` lists recent calls (model, tokens, cost, speed — never the prompt or answer), kept 30 days, with this month's gateway spend by model and project. Billing → Balance has every AI charge this month by model, embeddings, memory and vectors included.
- A key limited to one project calls chat, the OpenAI-compatible URL, the playground, embeddings and the model list for its own project: leave `projectId` out, or name that project (naming another is refused). The spend is the workspace's, as with any key, and a read-only key cannot spend. `GET /api/ai/requests` covers the whole workspace and needs a workspace key.

## How a request is paid for

Before the model is called, Harakumo sets an estimate aside, sized from your input and `max_tokens` (for `harakumo-1`, at the price of the dearest model its tier may use). When the answer is back you are charged the exact cost of the model that answered, and the rest is released. The dashboard playground shows that estimate before you press Run.

- A stream that ends early, because you disconnected or the model stopped, is charged only for what was delivered; a failure arrives as an `error` event with code `stream_error`.
- Auto-reload, when the owner has turned it on, is tried before a 402 for credit. Requests made with an API key set it off only if the owner allowed that (Billing → Limits & alerts).
- If a request is lost on Harakumo's side before it is settled, the estimate set aside for it is settled automatically within about half an hour: at what it had delivered, or given back.

## When a request is refused

A refused or failed request costs nothing, with one exception: a model that spends its whole token budget without answering has used those tokens, and they are charged.

| Answer | When | Charged |
| --- | --- | --- |
| 402 `ai_credits_exhausted` | Included AI (on a plan that has it) and the Balance are both used up. | Nothing |
| 402 `ai_credits_insufficient` | Some credit is left, but less than this request's estimate. Lower `max_tokens`, shorten the prompt or choose a cheaper model. | Nothing |
| 402 `spend_limit_reached` | The estimate would take the month past the spending limit of the workspace whose Balance pays (on a paid plan, the paying workspace's). | Nothing |
| 402 `ai_request_too_large` | The estimate is more than one request may reserve: $2 on hobby, $10 on pro, $10 on team, $10 on scale. | Nothing |
| 402 `email_unverified` | A vendor model on Hobby before the owner verified their email. `harakumo-1` answers from edge inference meanwhile. | Nothing |
| 403 `ai_gateway_off` / `ai_model_off` | The gateway, or this model, is switched off in Dashboard → AI Gateway. | Nothing |
| 429 `ai_rate_limited` | The per-minute limit. Wait Retry-After seconds. | Nothing |
| 400 or 413 `ai_request_refused` | The model refused the request itself (a parameter, or its size). It is not tried on other models, which would refuse it too. | Nothing |
| 422 `budget_exhausted` | The model used its whole `max_tokens` budget without an answer. Raise `max_tokens`. | The tokens it used |
| 502 `ai_unavailable` / `ai_provider_error` | No model could answer right now. Try again, or use `harakumo-1`, which fails over between vendors. | Nothing |

## Reference

**SDK**

```js
const answer = await hk.ai.chat({ model: 'harakumo-1', prompt: 'Summarize edge functions.' });
console.log(answer.output, answer.modelUsed, answer.cost);

for await (const ev of hk.ai.stream({ messages: [{ role: 'user', content: 'Write a haiku' }] })) {
  if (ev.event === 'delta') process.stdout.write(ev.data.text);
}

const { models, limits } = await hk.ai.models();                  // what this workspace can use, with prices
const { requests, month } = await hk.ai.requests({ limit: 20 });  // recent calls and this month's spend

// Embeddings for Vector DB (bge-small 384 · bge-base 768 · bge-large 1024):
const { embeddings, dimensions } = await hk.ai.embed(['fresh mango', 'ripe banana'], 'bge-base');
// hk.ai.generate({ prompt }) is the older name of a one-prompt chat.
```

**CLI**

```bash
harakumo ai "Write a haiku about the cloud" --model harakumo-1
```

**REST**

```http
POST /api/ai/chat                   { "model", "messages" | "prompt", "system"?, "max_tokens"?, "temperature"?, "tier"?, "stream"?, "projectId"? }
GET  /api/ai/chat                   # the models and limits this workspace can use
POST /api/ai/v1/chat/completions    # OpenAI-compatible (base URL https://harakumo.com/api/ai/v1)
POST /api/ai/playground             { "model", "prompt" }          # the one-shot alias
POST /api/ai/embeddings             { "input": ["…"], "model"?: "bge-base", "projectId"? }   → { embeddings, dimensions }
GET  /api/ai/requests?limit=&projectId=    # recent calls and this month's spend
```

---

# Agents

An agent calls tools in a loop until the task is done or it reaches its step limit. The tools are Harakumo's own actions on the agent's project (read the project and its deployments, search vectors … and, when allowed, query a database, deploy, create or delete). They run with an API key made for the run and revoked afterwards. That key is limited to the agent's project and is never more than whoever started the run: it is read-only unless the agent may make changes and the starter could make them too, and a key limited to another project cannot start the run.

So a run never has a tool that covers the whole workspace (projects, usage, domains, repos, the Balance), or one its key can never use, such as list_env_vars on an agent without writes (environment variables need the developer role). When you build an agent they are not offered as usable (they are shown greyed out with the reason, or left out), and saving an agent with one is refused with the reason. Sending email is kept: it sends as the agent's project.

Agents are for acting on your own Harakumo resources today. A run starts when you ask for one (the API, the SDK, the dashboard or an AI assistant), now or in the background; on a schedule; when your app sends the agent a signed event; or when a visitor writes to it from your site (see How an agent starts). Custom HTTP tools, memory attached to an agent and a callback when a background run ends are not available yet.

## In plain words

An AI helper that does a job step by step with the tools you allow, such as reading your data or sending an email. You see every step and what it cost.

For example: Each time you run it, an agent reads the new support messages and drafts replies for you to check.

## Agents for your project

Harakumo suggests the agents a kind of app usually needs (a store gets a shopping assistant, order follow-up and a low-stock alert; a booking app a booking assistant and reminders) and creates them in one step: the project's Agents tab, `GET`/`POST /api/projects/:id/agents/suggested`, or the AI connector's suggest_agents and create_suggested_agents.

An AI assistant can also design any agent itself with the connector (create_agent, get_agent, update_agent, set_agent_status, delete_agent, list_agent_tools), test it with run_agent and improve it from its steps.

Each agent records what starts it (`trigger`: manual, chat, schedule or event, with a label in plain words) and what it does (`purpose`). The project's Agents tab draws every agent as a flow: what starts it, the agent, and the services it uses. A schedule, event or chat start runs the agent once it is set up (below); until then, the flow says so and the agent runs when asked.

## How an agent starts

| Start | Set it up | When it runs |
| --- | --- | --- |
| When asked | `POST /api/agents/:id { input }`, the SDK's `hk.agents.run`, the dashboard or the connector's run_agent. | At once; the answer carries the finished run. |
| In the background | `POST /api/agents/:id { input, background: true }` (run_agent with background). | Answers 202 with the queued run at once; the run starts within about ten minutes, as whoever asked, and shows among the agent's runs once it has. Up to 20 can wait per agent. |
| On a schedule | `PATCH /api/agents/:id { schedule: "0 9 * * *" }` (five cron fields in UTC, or @hourly, @daily, @weekly, @monthly), or `{ schedule: { cron, input } }`; `null` removes it. Also on the agent's page and in update_agent. | At the first look at or after each time; the platform looks every ten minutes, so a run can start up to ten minutes late, and a schedule that asks for runs closer together than that is refused. `schedule` on the agent shows the next run and what the last one did. |
| On an app event | `PATCH /api/agents/:id { events: true }` answers the signing secret once (`events: "rotate"` gives a new one, `false` turns events off). Your server POSTs JSON (16 KB at most) to `/api/agents/:id/events`, signed as payment webhooks are: `Harakumo-Signature: t=<seconds>,v1=<HMAC-SHA256 of "<t>.<raw body>">`, with `Harakumo-Event` naming the event and `Harakumo-Delivery` its id. | The endpoint answers 202 at once; a run starts within about ten minutes. A second delivery of the same id is not a second run. 30 events a minute and 100 waiting per agent. |
| From a visitor's chat | `PATCH /api/agents/:id { chat: true }` answers a publishable chat id (`hkchat_…`) and its URL; `{ chat: { allowedOrigins, dailyLimit } }` changes them. Your page POSTs `{ message, history? }` to `/api/agents/chat/<chatId>` from the project's own addresses or an origin you allow. | At once, one run per message, answered in the same request with `{ reply, run_id }`. The run is read-only and has only the project's data tools (its vector collections), and 60 seconds. 10 messages a minute per visitor's address, and a daily limit (200 unless you set another). |

> An agent starts on its own one way at a time: a schedule, app events or visitor chat. A scheduled, event or background run uses a key cut from the access of whoever set it up, as it stands when the run starts, the plan's limit on runs at once and the same credit checks as any run, and has 5 minutes to finish (stop_reason `time_limit` past it). When one could not start, the reason is kept on its schedule, events or chat (`last_error`) for you to read.

## Create and run

**SDK**

```js
const { agent } = await hk.agents.create(project.id, {
  name: 'ops', model: 'claude-sonnet-5',
  instructions: 'Report failed deploys of this project and what their logs say went wrong.',
  tools: ['get_project', 'list_deployments', 'get_deployment'],
  maxSteps: 8,
});
const { run } = await hk.agents.run(agent.id, 'What needs attention today?');
// run.status, run.output, run.steps, run.cost_micros, run.model, run.stop_reason
```

## What a run returns

`POST /api/agents/:id` answers 202 with `{ run }` once the run has finished, and `hk.agents.run` resolves with it. With `background: true` it answers 202 with the queued run instead, and `GET /api/agents/:id` lists the run once it has started.

| Field | Meaning |
| --- | --- |
| status | `completed` or `failed` (`running` while it is still going). |
| stop_reason | Empty when it completed. `step_limit`: it reached maxSteps without finishing. `credits`: the AI credit ran out. `spend_limit`: the monthly spending limit was reached. `error`: the model or the run failed. `vendor`: the AI service was unavailable on Harakumo's side (our fault, not yours). `time_limit`: a scheduled, event or background run reached its 5 minutes, or a visitor's chat run its 60 seconds. `stale`: the process running it stopped. |
| output | The answer or, for a run that stopped, why it stopped and what to do. |
| steps | Every step in order: `step`, `kind` (`tool` or `text`), `tool_name`, `arguments`, `result` (its first 4,000 characters) and `ok`. |
| tokens_used, cost_micros, cost_usd | What its steps used and were charged, whether it finished, stopped or failed. |
| model, duration_ms | The model that ran it, and how long it took. |

## See what a run did

Dashboard → Agents → an agent lists its recent runs. Open one to see its input, its output or why it stopped, the model, the tokens and the cost, and every step: the tool it called, the arguments it passed and what came back, with failed tool calls marked. `GET /api/agents/:id` (`hk.agents.get`) returns the same for the 20 most recent runs.

## Settings

| Field | Meaning |
| --- | --- |
| model | A tool-capable model (Claude, GPT, Grok, Kimi) or `auto` (runs on claude-sonnet-5). On Hobby, vendor models need the workspace owner's verified email. |
| tools | Tool names from `GET /api/agents/tools` (add `?writes=1` to see the ones that change things); a name that is not a tool is refused with 400. Left empty, the agent gets every tool it is allowed. A run gets only the tools its key, limited to the agent's project, can use, so the listing, list_agent_tools and the dashboard's New Agent form offer only those: a tool that covers the whole workspace, or that the run's key could never use (list_env_vars without writes), is left out or marked `available: false` with a `reason`, and saving an agent with one is refused with 400 and that reason. With `?writes=1`, the tools only an agent with `allowWrites` can use (everything that changes something, and list_env_vars) are marked `needsWrites: true` with a `writesReason`. Tools that manage API keys, switch workspace, run agents or need an admin (members, the audit log, the Balance, service settings) are never given to an agent. |
| allowWrites | false by default: the run's key is read-only, so only read tools. true lets the agent use tools that change things in its own project: deploy files, create, change and delete the project's resources, run SQL, set environment variables and send mail. Turn it on deliberately. |
| maxSteps | 1–24, default 8. A run that hits it stops as failed with stop_reason step_limit. |
| instructions | Up to 16,000 characters. |

## Limits and cost

- Before a run starts, Harakumo sets aside an estimate of a few steps. With no AI credit left, or no room under the monthly spending limit, the run is refused with 402 (the same codes as an AI Gateway call) and never starts; nothing is charged.
- Each step is charged as it happens at the model's price, from included AI credit and then the Balance (on a paid plan, the Balance of the workspace that pays for the plan, for every workspace it covers, under that workspace's spending limit; on Hobby, the workspace's own). A run stops as failed when the money runs out (stop_reason `credits`) or the monthly spending limit is reached (`spend_limit`); its output says which, and where on the Billing page to add funds or raise the limit.
- A run's `tokens_used` and `cost_micros` are what its steps used and were charged, whether it finished, stopped or failed.
- Runs: 6 a minute per workspace (429 `rate_limited` past it); 1 at once on hobby, 5 at once on pro, 10 at once on team, 20 at once on scale (429 `plan_limit` with `limit` and `current`). Input up to 32,000 characters.
- A run asked for without `background` finishes inside the HTTP request; scheduled, event and background runs have 5 minutes, and a visitor's chat run 60 seconds. A run still marked running after 60 minutes is marked failed (stale).
- A paused agent answers 409 until it is resumed, and its schedule, events and chat start nothing meanwhile.
- Edit an agent with `PATCH /api/agents/:id` (model, instructions, tools, maxSteps, allowWrites, schedule, events, chat); its run history is kept.
- Billing → Balance breaks this month's AI spend down by model, agent runs included: each step is recorded under the model that ran it. Charges recorded before that, without a model, are one Unattributed row; each run also shows its own model and cost.

## Reference

**SDK**

```js
const { agent } = await hk.agents.create(project.id, { name: 'triage', model: 'auto', tools: ['list_deployments'] });
const { run } = await hk.agents.run(agent.id, 'Is this project live, and when was it last deployed?');
console.log(run.status, run.stop_reason, run.cost_usd, run.steps.length);
const { agent: a, runs } = await hk.agents.get(agent.id);   // the 20 most recent runs with their steps
await hk.agents.update(agent.id, { maxSteps: 12 });          // run history is kept
await hk.agents.pause(agent.id);                             // hk.agents.resume(agent.id) to run again
```

**REST**

```http
GET    /api/agents/tools?writes=1
POST   /api/projects/:id/agents   { "name", "model", "instructions"?, "tools"?: [], "maxSteps"?, "allowWrites"?, "trigger"?: { "kind", "label" }, "purpose"? }
GET    /api/projects/:id/agents/suggested?appType=store     # the agents this kind of app usually needs
POST   /api/projects/:id/agents/suggested   { "appType", "keys": [] }   # create them
POST   /api/agents/:id            { "input": "…", "background"?: true }   # run; 202 { run } once it has finished (or queued)
GET    /api/agents/:id            # the agent, its recent runs, schedule, events and chat
PATCH  /api/agents/:id            { "model"?, "instructions"?, "tools"?, "maxSteps"?, "allowWrites"? } | { "action": "pause"|"resume" }
PATCH  /api/agents/:id            { "schedule": "0 9 * * *" | { "cron", "input" } | null }
PATCH  /api/agents/:id            { "events": true | "rotate" | false } | { "chat": true | { "allowedOrigins", "dailyLimit" } | "rotate" | false }
POST   /api/agents/:id/events     # from your server, signed with the events secret (Harakumo-Signature)
POST   /api/agents/chat/:chatId   { "message", "history"? }   # from a web page; → { reply, run_id }
DELETE /api/agents/:id
```

---

# Media

Create a video asset, upload the file to its one-time upload URL, and play it over HLS when it is ready. Live channels get an RTMPS ingest URL and a stream key; point your encoder at it and go live.

Videos and live channels are on Pro and up. On a plan without them, creating one answers 402 `plan_limit` with `required_plan`, and nothing is created. A workspace that still holds assets from a plan that had them gets the same answer when it asks for an upload URL or takes a channel live; videos already uploaded keep playing, and a stream can still be ended.

## In plain words

Upload videos and have them play smoothly on any screen, or stream live to your viewers.

For example: A yoga studio puts its class recordings online and streams its Sunday class live.

## Details

- Video only, up to 1 hour per upload. Kinds "image" and "audio" answer 501 — keep those files in Storage.
- Status runs pending_upload → processing → ready. Size and duration appear once the file is processed.
- Looking at a video (`GET /api/media/:id`) checks its status at most once every 15 seconds while it is still moving, and never again once it is ready or failed; in between, the last status answers, and `recheck_after` says how many seconds until the next real check. Poll no faster than that.
- Each status check that is made, each "is an encoder connected?" check on a live channel, going live and a fresh upload URL count toward the workspace's data allowance (600 calls per 5 minutes, shared with database queries, vectors and memory, under the platform ceiling all workspaces share). When either says no, a look answers with the last status and a `note` saying when to look again, and going live or a fresh upload URL answers 429 `rate_limited` and changes nothing.
- An expired upload URL is replaced with a fresh one (`POST /api/media/:id/upload`); once a file has arrived, no new URL is issued.
- Stream keys and upload URLs are visible to developers and above, never to viewers.
- Live channels do not record.
- Assets per plan: 100 pro, 500 team, 1,000 scale. Each uploaded video and each live channel counts as one.

## Reference

**SDK**

```js
const { asset, uploadUrl } = await hk.media.create(project.id, { name: 'promo', kind: 'video' });
// POST multipart/form-data with field "file" to uploadUrl, then poll:
const { asset: ready } = await hk.media.get(asset.id);   // pending_upload → processing → ready
const live = await hk.media.create(project.id, { name: 'launch', kind: 'live' });   // ingest URL + stream key
```

**REST**

```http
POST  /api/projects/:id/media    { "name": "promo", "kind": "video" | "live" }
POST  /api/media/:id/upload      # a fresh upload URL while the asset waits for its file
GET   /api/media/:id             # status, size, duration, playback URL; recheck_after while it is processing
PATCH /api/media/:id             { "action": "go-live" | "end-stream" }
```

---

# Auth pools

Each project can have one pool. End users sign up and log in, Harakumo stores them (passwords hashed with PBKDF2) and returns a signed token your app checks on each request.

Enabling a pool returns three values. The client secret (`as_…`) lets your server act for this pool only; it is shown once and can be rotated. The JWT secret (`js_…`) verifies tokens; developers can show it again. The client id (`ap_…`) is publishable and keys the browser endpoints.

For an iOS or Android app end to end (the client id in the app, the token in the Keychain or Keystore, the function that checks it), see Use Harakumo from a phone app.

## In plain words

Sign-up and log-in for the people who use your app, with email and password, email checks and password resets, without building any of it yourself.

For example: Members of a gym make an account and see only their own classes.

## Two ways to call it

| From | Endpoint | Credential |
| --- | --- | --- |
| Your server | `POST /api/auth-pools/:poolId` with an `action` | Bearer client secret (as_…), or an API key |
| A browser or mobile app | `POST /api/auth-pools/public/:clientId/:action` | None — the client id is in the URL. Off until you turn it on. |

## Enable a pool

**curl**

```bash
curl -X POST https://harakumo.com/api/projects/<projectId>/auth \
  -H "Authorization: Bearer $HARAKUMO_API_KEY"
# 201 → { "pool": {…}, "clientId": "ap_…", "clientSecret": "as_…", "jwtSecret": "js_…",
#         "serverEndpoint", "browserEndpoint", "verifyEndpoint", "algorithm": "HS256" }
```

> Store clientSecret and jwtSecret as environment variables of the project (HARAKUMO_AUTH_CLIENT_SECRET and HARAKUMO_AUTH_JWT_SECRET, the names setup_app_backend uses), with the pool id, client id and issuer (HARAKUMO_AUTH_POOL_ID, HARAKUMO_AUTH_CLIENT_ID, HARAKUMO_AUTH_ISSUER). The issuer is `https://harakumo.com/auth/<project-slug>`. An AI connector is never answered the two secrets: they are stored as HARAKUMO_AUTH_CLIENT_SECRET and HARAKUMO_AUTH_JWT_SECRET (production) in the same write as the pool, and the answer carries only those names, in `secretsStoredAs`.

## Sign-up and login from your server

Every call names its action: signup, login, send-verification, verify-email, forgot-password or reset-password. Your server keeps the returned token in its own cookie and verifies it on each request. The example uses express and jose (`npm install express jose`).

Sign-ups your server makes are limited to 600 an hour per pool, counted apart from the browser's, so sign-ups from the browser never use them up. Pass the person's address as `ip` (in Express, `req.ip`) and that address is held to the same 10 sign-ups an hour as the browser, counted together with its browser sign-ups; without `ip`, only the per-pool limit applies. Past either, 429 `rate_limited` with Retry-After. An `ip` that is not one IPv4 or IPv6 address answers 400 `invalid_ip`.

**Express**

```js
import express from 'express';
import { jwtVerify } from 'jose';

const POOL = 'https://harakumo.com/api/auth-pools/' + process.env.HARAKUMO_AUTH_POOL_ID;
const secret = new TextEncoder().encode(process.env.HARAKUMO_AUTH_JWT_SECRET);

async function pool(body) {
  const r = await fetch(POOL, {
    method: 'POST',
    headers: { authorization: 'Bearer ' + process.env.HARAKUMO_AUTH_CLIENT_SECRET, 'content-type': 'application/json' },
    body: JSON.stringify(body),
  });
  const data = await r.json();
  if (!r.ok) throw Object.assign(new Error(data.error), { status: r.status });
  return data;   // { user, token, expiresIn }
}

const app = express();
app.use(express.json());

app.post('/signup', async (req, res) => {
  try {
    // ip: the visitor's address, for the per-address sign-up limit.
    const { token } = await pool({ action: 'signup', email: req.body.email, password: req.body.password, ip: req.ip });
    res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 7 * 864e5 }).json({ ok: true });
  } catch (err) {
    res.status(err.status || 500).json({ error: err.message });   // 409 when the email already has an account
  }
});

app.post('/login', async (req, res) => {
  try {
    const { token } = await pool({ action: 'login', email: req.body.email, password: req.body.password });
    res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 7 * 864e5 }).json({ ok: true });
  } catch (err) {
    res.status(err.status === 429 ? 429 : 401).json({ error: err.message });
  }
});

// Verify locally on every request: no call to Harakumo.
async function requireUser(req, res, next) {
  const token = (req.headers.cookie || '').match(/(?:^|; )session=([^;]+)/)?.[1];
  try {
    const { payload } = await jwtVerify(token || '', secret, {
      algorithms: ['HS256'], issuer: process.env.HARAKUMO_AUTH_ISSUER, audience: process.env.HARAKUMO_AUTH_CLIENT_ID,
    });
    req.user = payload;   // sub, email, name, email_verified …
    next();
  } catch {
    res.status(401).json({ error: 'Sign in first' });
  }
}

app.get('/me', requireUser, (req, res) => res.json({ id: req.user.sub, email: req.user.email }));
app.listen(process.env.PORT || 3000);
```

## Token claims

| Claim | Value |
| --- | --- |
| iss | The pool's issuer, `https://harakumo.com/auth/<project-slug>` |
| aud | The pool's client id |
| sub | The end user's id |
| email, name | As signed up |
| email_verified | true once they entered a verification code |
| pool | The pool id |
| tv | Token version: moves on when the user is reset or signed out everywhere |
| sid | Only on a token issued with a refresh token: the device session it belongs to |
| iat, exp | Issued and expiry; tokens last 7 days (HS256) unless you set a shorter `tokenTtl` (5 minutes to 7 days) |

> Local verification cannot see a revocation until the token expires. `POST /api/auth-pools/:id/verify { token }` also refuses tokens of deleted users, users signed out everywhere and devices signed out (reason "revoked"); call it where that matters, or shorten `tokenTtl` once your app refreshes.

## Sign in with Apple and Google

List your app's ids on the pool: `PATCH /api/auth-pools/:id/settings { appleClientIds: ["com.example.app"], googleClientIds: ["1234-abc.apps.googleusercontent.com"] }` (the iOS bundle id, and a website's Services ID, for Apple; the OAuth client ids for iOS, Android and web for Google). An empty list turns a provider off. An iOS app that offers Google sign-in must offer Sign in with Apple too.

The app signs the person in with Apple or Google on the device, then sends what it got: `sign-in-with { provider: "apple" | "google", idToken, nonce?, name? }`. The token must be signed by the provider, unexpired and made for one of your ids (401 `invalid_id_token`, `wrong_audience`, `id_token_expired`); with `nonce`, the raw nonce the app used, the token must carry it, as is or as its SHA-256 (401 `nonce_mismatch`). Apple gives the person's name only on their first sign-in, and only to the app: send it as `name`.

An Apple or Google account seen before signs in as its user. Otherwise one with a verified address is linked to the user who has that address; if that user never verified it, whoever made the account may not own the address, so its password is removed and its other sign-ins end. Otherwise a user is made with no password (201, `newUser: true`), from a browser or phone only when sign-up is open. Answers are the same as login, refresh tokens included.

Apple's private relay addresses (…@privaterelay.appleid.com) are real addresses, but mail reaches them only from a domain you register with Apple: send from your own domain (Mail) and register it in your Apple developer account. Deleting an account here does not revoke the app's Sign in with Apple at Apple; do that from your server with your own Apple key.

## Keep people signed in: refresh tokens

Add `refresh: true` to any sign-in (signup, login, verify-email, reset-password), with an optional `device` label such as "Sam's iPhone", and the answer also carries `refreshToken` (`hkrt_…`) and `refreshExpiresIn`. Keep the refresh token in the Keychain or Keystore, never where scripts can read it.

When the token is about to expire, send `refresh { refreshToken }`: the answer is a new token and a new refresh token, which replaces the old one. A session ends after 60 days without a refresh, and a year after sign-in however often it is used; then the person signs in again.

The refresh token you just replaced still works for one minute, so a refresh whose answer was lost on a bad network can be sent again. Any earlier one ends the session (401 `refresh_token_reused`): it can only be a copy. Refresh once at a time on each device (the SDK's `HarakumoAuth.refresh` does). An ended or expired session answers 401 `invalid_refresh_token`.

`logout { refreshToken }` signs out one device; its tokens are then refused by the session check and the verify call. `logout { everywhere: true }` with the user's token signs out every device. A password change or reset and Sign out everywhere in the dashboard end every session too. A user keeps at most 20 sessions; signing in on one more ends the one used longest ago.

Refresh tokens work the same on every plan and need nothing turned on. Apps that do not ask for one are unchanged.

**Phone app**

```js
import { HarakumoAuth } from '@harakumo/sdk';
const auth = new HarakumoAuth({ clientId: 'ap_…' });   // the pool's client id: publishable

const { token, refreshToken } = await auth.login({ email, password, refresh: true, device: 'Sam's iPhone' });
// … later, before the token runs out:
const next = await auth.refresh(refreshToken);   // keep next.token and next.refreshToken
await auth.logout({ refreshToken: next.refreshToken });
```

## People manage their own account

Both app stores require that people can change and delete their account inside the app. Each action takes the user's own token: `Authorization: Bearer <token>` at the public endpoint, or `token` in the body from your server.

| Action | Body | What it does |
| --- | --- | --- |
| `update-profile` | `{ name }` | Changes the name (up to 100 characters) and answers a fresh token carrying it. |
| `change-password` | `{ currentPassword, newPassword }` | Signs every other device out and answers new tokens for this one. A wrong current password counts toward the lockout (403 `wrong_password`). |
| `change-email` | `{ newEmail, password }` | Emails a code to the new address. Nothing changes until it comes back. |
| `confirm-email-change` | `{ code }` | Moves the account to the new address, marks it verified, signs other devices out and tells the old address. 409 `email_taken` if another account has the address. |
| `sessions` | — | The signed-in devices: `{ sessions: [{ id, device, created_at, last_used_at, current }] }`. |
| `revoke-session` | `{ sessionId }` | Signs one of those devices out. |
| `delete-account` | `{ password }` or `{ code }` | Deletes the account at once, with its codes and sessions. It cannot be undone. |
| `send-deletion-code` | — | For an account without a password: emails the code `delete-account` takes. |

> Codes for these actions last 15 minutes, allow 5 tries and are limited to 5 an hour per address and per account. From the browser they also count toward the public code limits below. 401 `token_required` when no token is sent, `not_signed_in` when it is expired or signed out.

## Account deletion without the app

Google Play also asks for a web link where people can ask for their account to be deleted without the app. Turn on the hosted page with `PATCH /api/auth-pools/:id/settings { deletionPage: true }` (or on the pool's Auth page), and say what your app keeps after deleting an account, and for how long, with `deletionNote` (plain text, up to 1,000 characters).

The page is `https://harakumo.com/account/delete/<clientId>`. Someone enters their email address, gets a code, and entering it deletes the account. It works while browser sign-in is off, and answers the same whether or not an address has an account.

A deletion page of your own runs the same two actions through your server or the public endpoint: `request-deletion { email }`, then `confirm-deletion { email, code }`.

## Push notifications

Notifications go straight to Apple's push service (APNs) and to Firebase Cloud Messaging, with your own keys. Save them once: `PUT /api/push/:poolId/credentials { service: "apns", keyId, teamId, bundleId, privateKey }` with the .p8 file's text, and `{ service: "fcm", serviceAccount }` with the Firebase service account JSON. Each key is checked before it is kept, sealed, and never shown again.

After sign-in, the app registers the device's token for the user: `register-push { pushToken, platform: "ios" | "android" }` with the user's token (`service: "fcm"` for an iOS app that uses Firebase; `environment: "sandbox"` for an APNs development build). On sign-out it calls `unregister-push { pushToken }`. A token belongs to one user at a time: registered by someone else, it moves to them. A user keeps their 20 newest devices.

Your server sends: `POST /api/push/:poolId/send { userIds, title, body, data? }` with the client secret or an API key. Up to 100 users and 500 devices a call, 600 calls an hour per pool (429 `rate_limited`). The answer lists each device: a device the service reports as gone is switched off and not sent to again; `no_credentials` and `credentials_refused` mean a key is missing or was refused. `data` is text values only, 3,000 bytes at most: put no personal details in a notification, since lock screens are public.

Not yet: topics and sending to every user at once, scheduled sends, delivery receipts, and categories that users switch off one by one.

## Hear when a user is deleted

Set an https endpoint with `PUT /api/auth-pools/:id/webhook { url }`. The first time, the answer carries the signing secret once (`hkwhsec_…`); store it in your server's environment. An AI connector is never answered it: it is stored as an environment variable of the project (production), and the answer names that variable in `secretsStoredAs`.

When a user is deleted (by themselves, on a deletion page, or in the dashboard), your endpoint gets a POST: `{ id, type: "user.deleted", created, data: { pool_id, project_id, user: { id, email }, reason } }`, with `reason` one of `self`, `deletion_page` or `dashboard`. Check the `Harakumo-Signature` header with `Harakumo.verifyWebhook(secret, rawBody, header)`, the same check as payment events, and delete what your app keeps about that user. The id (also the `Harakumo-Delivery` header) is the same on every retry.

Answer with any 2xx. A delivery that fails is tried again with growing gaps, up to 8 tries over about two days. A retry that has fallen due goes out when one of your users signs in or acts on their account (at most once a minute), and when you open the webhook settings. `POST …/webhook { action: "test" }` sends a `ping`; `{ action: "rotate-secret" }` replaces the secret; `DELETE` removes the webhook.

## Sign-in from the browser or a mobile app

1. Turn browser access on: `PATCH /api/auth-pools/:id/settings { publicEnabled: true }`. Letting anyone create an account is a separate switch, `publicSignup: true`.
2. The project's own addresses and verified custom domains are allowed automatically. Add other origins with `allowedOrigins` (up to 20): https addresses, http only for localhost, and for a website wrapped as a phone or desktop app (Capacitor, Ionic, Cordova, Tauri) exactly `capacitor://localhost`, `ionic://localhost`, `app://localhost` or `tauri://localhost`. None of those app origins is allowed until you add it, and no other custom scheme can be. A request from any other Origin is refused (403 `origin_not_allowed`) before anything runs, and the answer says which origin to add, or why it cannot be added. A native app, or a wrapped app that makes its calls through native HTTP (such as CapacitorHttp), sends no Origin and needs no entry. Android app shells use `https://localhost`, an ordinary origin you add like any other.
3. Call the public endpoints with the client id. `GET …/session` with `Authorization: Bearer <token>` answers who is signed in.

**Browser**

```js
const AUTH = 'https://harakumo.com/api/auth-pools/public/ap_…';

const r = await fetch(AUTH + '/login', {
  method: 'POST', headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ email, password }),
});
const { token, user, error } = await r.json();

const me = await (await fetch(AUTH + '/session', { headers: { authorization: 'Bearer ' + token } })).json();
```

> Browser limits: sign-up 10 per IP an hour and 600 per pool an hour; login 30 per IP per 10 minutes; codes 20 per IP an hour, and 50 an hour and 300 a day per pool (see below). An IPv6 address counts by its /64, so moving between addresses in one network does not reset these.

## A bot check on sign-up

Not available yet: until Harakumo turns the check on, `PATCH /api/auth-pools/:id/settings { botCheck: true }` answers 409 `bot_check_unavailable`, and `settings` says `botCheck.available: false`.

Once it is, and you turn it on, a sign-up from the browser endpoint must carry `botToken`, a short check passed on one of the pool's own pages (its project's addresses and allowed origins): 400 `bot_check_required` without one, 403 `bot_check_failed` for one that was not passed, was used before or came from another site. Show the check with the loader in `botCheck.loaderUrl`: `harakumoBotCheck.render('#check', { action: 'signup', onToken })`. A native app has no page to show it on, so leave it off for an app that signs people up natively, or sign them up from your server.

Your own forms can use it too: show the check with the same loader and check the token from your server with `POST /api/auth-pools/:id/bot-check { token, ip?, action? }` (client secret or API key), which answers `{ ok: true, hostname, action }` or `{ ok: false, code, error }`. A token works once, for five minutes.

## Email verification and password reset

1. `send-verification { email }` emails a 6-digit code; `verify-email { email, code }` marks the address verified and returns a fresh token.
2. `forgot-password { email }` emails a reset code; `reset-password { email, code, password }` sets the new password, signs the user out everywhere else and returns a token.

- Codes last 15 minutes and allow 5 tries; at most 5 codes an hour per address.
- They are sent under your app's sender name and count toward the workspace's email quota. If the app has no sender name recipients may see (see Mail → Sender names), code requests answer 409 `mail_not_ready`.
- Browser answers never reveal whether an email has an account.

## Limits on codes asked for from the browser

Anyone who has your client id can ask the public endpoints to email a code, so those codes have limits of their own, and a stranger cannot spend the mail your app needs for its own password resets:

- Per pool: at most 50 codes an hour and 300 a day. Only codes actually mailed count: a request for an address with no account sends nothing and uses none.
- Per workspace: codes asked for from the browser, across all its pools, may use at most 20% of the 30-day email quota (20 on hobby, 400 on pro, 2,000 on team, 20,000 on scale). They still count toward the quota like any email.
- Past either limit, `send-verification` and `forgot-password` answer 429 with code `code_limit` and a Retry-After header, whatever the address, so the answer reveals nothing about who has an account. The per-IP and per-address limits answer 429 `rate_limited`.
- Codes your server asks for with the client secret (or an API key) are not held to these limits, only to the 5 an hour per address, so resets your own server sends keep working.

## Managing users and secrets

- `GET /api/auth-pools/:id?q=&before=&limit=` lists users (up to 200 a page).
- `PATCH /api/auth-pools/:id/users/:userId` with action reset-password (with password), send-password-reset, revoke-sessions or unlock. `DELETE` removes the user; their tokens and refresh tokens stop working, and your webhook hears `user.deleted` (reason `dashboard`).
- `GET /api/auth-pools/:id/secret` shows the JWT secret again (developer and up, audited). An AI connector's token is not shown it: it gets 403 `dashboard_only`, with `dashboard_url` the pool's page in the dashboard.
- `POST /api/auth-pools/:id/secret { which: "jwt" }` rotates it, keeping the old one valid for 24 hours (or `keepPrevious: false` to revoke at once). `{ which: "client" }` replaces the client secret immediately. Rotating needs an admin.
- When an AI connector rotates a secret, the new value is stored as the project's environment variable (HARAKUMO_AUTH_JWT_SECRET or HARAKUMO_AUTH_CLIENT_SECRET, production) in the same write, and the answer carries only that name, in `secretsStoredAs`. The app reads it after its next deploy; until then a rotated client secret makes its server's calls fail.

## Limits and what is not available yet

- Passwords 8–256 characters. 10 wrong passwords lock the account for 15 minutes (429 with Retry-After).
- Codes asked for from the browser: 50 an hour and 300 a day per pool, and at most 20% of the workspace's email quota (429 `code_limit`).
- Apart from Sign in with Apple and Google (above): No social login, no end-user MFA, no hosted sign-in page and no RS256/JWKS yet. The hosted page that exists is the one for deleting an account.
- Pools per plan: 1 hobby, 10 pro, 50 team, 100 scale. Users per pool: 1,000 hobby, 50,000 pro, 100,000 team, 250,000 scale; past it a sign-up, or a first Sign in with Apple or Google, answers 402 `plan_limit` and no account is made. From the browser (the client id) that answer names no plan and no numbers; your server's carries `limit` and `current`. Existing users are never removed.
- Verifying tokens locally scales with your app. Sign-ups and logins run on the shared platform and suit apps with thousands of active users.

## Reference

**SDK**

```js
// On your server, the client secret works as the SDK key — for this pool's sign-in actions only.
const pool = new Harakumo({ apiKey: process.env.HARAKUMO_AUTH_CLIENT_SECRET });
const { token } = await pool.auth.signup(poolId, { email: 'user@yourapp.com', password: 'correct-horse-9' });
const { token: t2 } = await pool.auth.login(poolId, { email: 'user@yourapp.com', password: 'correct-horse-9' });
const { valid, claims, reason } = await pool.auth.verifyToken(poolId, t2);

// Keep a phone signed in: ask for a refresh token, trade it when the token runs out.
const { refreshToken } = await pool.auth.login(poolId, { email: 'user@yourapp.com', password: 'correct-horse-9', refresh: true });
const next = await pool.auth.refresh(poolId, refreshToken);

// The user's own account, with their token:
await pool.auth.deleteAccount(poolId, next.token, { password: 'correct-horse-9' });

// Managing the pool needs an API key:
const { pool: p, clientId, clientSecret, jwtSecret } = await hk.auth.enable(project.id);
```

**REST**

```http
POST   /api/projects/:id/auth                     # enable → clientId, clientSecret, jwtSecret, endpoints (a connector: secretsStoredAs)
POST   /api/auth-pools/:id        { "action": "signup"|"login"|"send-verification"|"verify-email"|"forgot-password"|"reset-password", "email", "password"?, "code"?, "name"?, "ip"?, "refresh"?, "device"? }
POST   /api/auth-pools/:id        { "action": "sign-in-with", "provider": "apple"|"google", "idToken", "nonce"?, "name"?, "refresh"?, "device"? }
POST   /api/auth-pools/:id        { "action": "refresh", "refreshToken" } · { "action": "logout", "refreshToken" } | { "action": "logout", "token", "everywhere": true }
POST   /api/auth-pools/:id        { "action": "update-profile"|"change-password"|"change-email"|"confirm-email-change"|"sessions"|"revoke-session"|"send-deletion-code"|"delete-account", "token", … }
POST   /api/auth-pools/:id        { "action": "request-deletion", "email" } · { "action": "confirm-deletion", "email", "code" }
POST   /api/auth-pools/:id/verify { "token": "…" }   → { valid, claims, user } | { valid: false, reason }
GET    /api/auth-pools/:id/settings · PATCH { "publicEnabled"?, "publicSignup"?, "allowedOrigins"?, "deletionPage"?, "deletionNote"?, "tokenTtl"?, "botCheck"?, "appleClientIds"?, "googleClientIds"? }
POST   /api/auth-pools/:id/bot-check { "token", "ip"?, "action"? }   → { ok, hostname, action } | { ok: false, code, error }
GET    /api/auth-pools/:id/webhook · PUT { "url" } · POST { "action": "test"|"rotate-secret" } · DELETE
GET    /api/push/:poolId · PUT /api/push/:poolId/credentials { "service": "apns"|"fcm", … } · DELETE ?service=
POST   /api/push/:poolId/send { "userIds", "title"?, "body"?, "data"?, "sound"?, "badge"?, "ttlSeconds"?, "collapseKey"? }
GET    /api/auth-pools/:id/secret · POST { "which": "jwt"|"client", "keepPrevious"? }
GET    /api/auth-pools/:id?q=&before=&limit=     # users
PATCH  /api/auth-pools/:id/users/:userId         { "action": "reset-password"|"send-password-reset"|"revoke-sessions"|"unlock" }
DELETE /api/auth-pools/:id/users/:userId
DELETE /api/auth-pools/:id                       # disable: removes the pool and every user

POST   /api/auth-pools/public/:clientId/signup | login | sign-in-with | send-verification | verify-email | forgot-password | reset-password | refresh | logout
POST   /api/auth-pools/public/:clientId/update-profile | change-password | change-email | confirm-email-change | sessions | revoke-session | register-push | unregister-push | send-deletion-code | delete-account
       Authorization: Bearer <token>
POST   /api/auth-pools/public/:clientId/request-deletion | confirm-deletion
GET    /api/auth-pools/public/:clientId/session  Authorization: Bearer <token>
GET    /api/auth-pools/public/:clientId/bot-check  # the bot check's loader script
```

---

# Payments

Harakumo is the merchant: the buyer pays on a hosted checkout, the money is recorded as your project's Earnings, and after the hold the workspace owner withdraws it to a payout account in their own name (a one-time identity check with our payout provider).

Earnings are not the workspace Balance. The Balance is money you load to pay Harakumo; Earnings are money your customers paid you. They are never mixed.

Inside an App Store or Google Play app, checkout fits physical goods and real-world services; digital goods used in the app need the store's billing. See Use Harakumo from a phone app.

## In plain words

Take card payments in your app without opening an account with a payment company. Make a payment page, get paid, and move your earnings to your bank after a one-time identity check.

For example: A tutor sends a link to a payment page for a pack of lessons.

## The flow

1. Enable payments on the project — one call, live at once.
2. Create a checkout link for an amount, with your own reference and metadata and a successUrl on your site.
3. Send the buyer to the link. It can be paid once and expires after 24 hours.
4. After paying, the buyer returns to your successUrl with `session_id` added.
5. Your server confirms the payment with the session lookup, or receives the payment.succeeded webhook.
6. The net (amount less the fee) is added to Earnings, held 7 days, then withdrawable by the workspace owner.

## Fees and rules

| Item | Rule |
| --- | --- |
| Platform fee | 3.9% + 30¢ per sale — the processor's cost plus 1% for Harakumo |
| Hold | 7 days per payment before it can be withdrawn |
| Payout limits | a new seller's first payout comes 14 days after their first sale; payouts start at up to $500 each and $1,000 a week per workspace, and rise as the workspace keeps selling and payouts go through, to $50,000 and $100,000 after 180 days, 12 payouts and $50,000 paid out. Payouts pause while over 10% of the last 90 days' sales is refunded, or while 2 or more disputes make up 1% of them (3 disputes pause payouts whatever the share). Harakumo staff can also raise a workspace's limits, or hold its payouts while they look at its sales; either is written in the workspace's audit log. Earnings kept back wait for the next payout; `earnings.payout_limit.message` says why and when it lifts |
| Amount | $0.50 to $1,000,000, USD, one-time payments |
| Link | single use, expires after 24 hours |
| Refund | your Earnings go down by the same share of your net as the refund is of the payment |
| Dispute | the payment's net is held back from Earnings while the dispute is open; a won dispute gives it back |
| Live links | need a verified workspace-owner email (403 `email_unverified` otherwise) |

## Create a checkout link

| Field | Rules |
| --- | --- |
| amountCents | required, at least 50 |
| description, customerEmail | optional |
| reference | your own id for the sale (order, user …), up to 200 characters; filter lists by it |
| metadata | up to 20 string keys; keys starting with hk_ are reserved |
| successUrl | http or https. `session_id={CHECKOUT_SESSION_ID}` is added unless you put the placeholder in yourself. Without it the buyer sees Harakumo's public thank-you page. |
| cancelUrl | where the buyer goes if they back out |
| appReturnUrl | For a phone app, instead of successUrl and cancelUrl (giving it with either is a 400): the app's own link, such as `myapp://checkout/done`. The buyer goes through Harakumo's own done or cancelled page, which hands them back to the app with `session_id` and `status` (paid, pending, expired or cancelled) added. Web addresses and schemes that run code, read files or start calls are refused. |
| test | `true` makes a test checkout: test cards only, no money moves (no payment, no earnings), and your webhook hears payment.succeeded with `livemode: false`. It needs no verified owner email. Test checkouts are listed apart (`GET …/sessions?test=true`). Available once Harakumo turns test mode on; until then 501 `test_mode_off`. |

**curl**

```bash
curl -X POST https://harakumo.com/api/payments/<accountId> \
  -H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
  -d '{ "amountCents": 2900, "description": "Pro plan", "customerEmail": "buyer@example.org",
        "reference": "order-1042", "metadata": { "userId": "42" },
        "successUrl": "https://yourapp.com/thanks", "cancelUrl": "https://yourapp.com/cart" }'
# 201 → { "url": "…", "session": { "id", "state": "open", "expires_at", "single_use": true, "reference", "metadata", … }, "feeCents" }
```

**SDK**

```js
const { url, session } = await hk.payments.createSession(accountId, {
  amountCents: 2900, description: 'Pro plan', reference: 'order-1042', metadata: { userId: '42' },
  successUrl: 'https://yourapp.com/thanks', cancelUrl: 'https://yourapp.com/cart',
});
```

## Confirm a payment

On your success page, look the session up with the id from the redirect. `paid` is true once the processor has confirmed it (an open checkout is checked with the processor on read, so this never waits on a webhook).

**fetch**

```js
const r = await fetch('https://harakumo.com/api/payments/' + accountId + '/sessions/' + encodeURIComponent(sessionId), {
  headers: { authorization: 'Bearer ' + process.env.HARAKUMO_API_KEY },
});
const { paid, payment, session } = await r.json();
// payment: { amount_cents, fee_cents, net_cents, customer_email, reference, metadata, status, available_at, … }
```

## Webhooks

Register an https URL with `PATCH /api/payments/:accountId { action: "set-webhook", webhookUrl }`. The signing secret (`hkwhsec_…`) is returned once. Events: payment.succeeded, payment.refunded, payment.disputed, payment.dispute_won, and ping (from `action: "test-webhook"`).

Each delivery is a POST with the JSON event and three headers: Harakumo-Event (the type), Harakumo-Delivery (the event id — use it to ignore duplicates) and Harakumo-Signature (`t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">`). Answer 2xx within 5 seconds; redirects are not followed.

A failed delivery is tried again, 8 attempts in all. Each retry falls due 1 minute, 5 minutes, 30 minutes, then 2, 6, 12 and 24 hours after the attempt before it failed. Due retries are sent every ten minutes (sooner when there is other payment activity on the account), so a retry goes out within about ten minutes of falling due, and the 1- and 5-minute retries can each take up to ten.

**Verify and handle (Express)**

```js
import crypto from 'node:crypto';
import express from 'express';

const app = express();

// Verify against the raw body, before any JSON parsing.
app.post('/webhooks/harakumo', express.raw({ type: 'application/json' }), (req, res) => {
  const parts = Object.fromEntries((req.get('harakumo-signature') || '').split(',').map(p => p.trim().split('=')));
  const t = Number(parts.t);
  const expected = crypto.createHmac('sha256', process.env.HARAKUMO_WEBHOOK_SECRET)
    .update(t + '.' + req.body.toString('utf8'))
    .digest('hex');
  const fresh = Math.abs(Date.now() / 1000 - t) <= 300;
  const valid = fresh && typeof parts.v1 === 'string' && parts.v1.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected));
  if (!valid) return res.status(400).send('bad signature');

  const event = JSON.parse(req.body.toString('utf8'));
  if (event.type === 'payment.succeeded') {
    const p = event.data.payment;   // mark order p.reference as paid — idempotently
  }
  res.sendStatus(200);
});
```

**Event body**

```json
{
  "id": "evt_ps_318", "type": "payment.succeeded", "created": 1790000000,
  "data": { "payment": {
    "id": 318, "account_id": 9, "project_id": 12, "session_id": 771, "session_key": "…",
    "reference": "order-1042", "metadata": { "userId": "42" },
    "amount_cents": 2900, "fee_cents": 143, "net_cents": 2757, "refunded_cents": 0,
    "currency": "usd", "customer_email": "buyer@example.org", "country": "US",
    "status": "succeeded", "created_at": "2026-09-25 14:02:11"
  } }
}
```

> Rotate the secret with `action: "rotate-webhook-secret"`. Every delivery and its result is listed on the project's Payments page.

## Lists

`GET /api/payments/:accountId/payments` and `/sessions` page through one project's payments and checkout links (`?limit=1..100&cursor=&status=&reference=`, answering `nextCursor`). `GET /api/payments` lists the workspace's recent payments across projects. `GET /api/projects/:id/payments` returns the account, recent activity and `earnings` (balance, held, available, withdrawn, pending, next release).

## Refunds, withdrawals and turning it off

- Refund all or part of a payment: `PATCH { action: "refund", paymentId, amountCents? }`.
- Withdraw: connect a payout account once (`withdraw-setup` returns an identity-check link), then `withdraw` sends what is available, up to the workspace's payout limits (409 `payout_limited`, with the reason and the date it lifts, when none can go yet). A withdrawal the processor has not confirmed stays pending until it does; it is never re-credited on a guess.
- Refunds, payout setup, the payout dashboard, withdrawals and permanent delete need the workspace owner signed in to the dashboard. API keys, developers and AI connectors get 403 `payouts_require_owner`.
- `DELETE /api/payments/:accountId` disables payments: no new links, but earnings, history and the payout account are kept and links already sent are still credited. `POST /api/projects/:id/payments` turns it back on. `?permanent=true` erases the account, only when nothing is owed or open.

## Limits and scale

- USD and one-time payments only: no subscriptions, products, coupons or tax yet. Each link is for one payment.
- Payment accounts per plan: 1 hobby, 10 pro, 50 team, 100 scale (one per project).
- Payments are recorded in Harakumo's ledger, not polled by your app: use the webhook for volume, and the session lookup on the success page.
- Keys: a key limited to the project (full access) can enable payments, create links, look sessions up, list payments and set the webhook. A read-only key can look up and list. Money leaving — refunds and withdrawals — always needs the owner.

## Reference

**SDK**

```js
const { account } = await hk.payments.enable(project.id);
const { url } = await hk.payments.createSession(account.id, { amountCents: 2900, description: 'Pro plan', successUrl: 'https://yourapp.com/thanks' });
const { account: a, earnings } = await hk.payments.get(project.id);
const { totals, funnel, daily } = await hk.payments.analytics(project.id);
// Session lookup, lists, refunds and webhooks: REST (below).
```

**CLI**

```bash
harakumo payments                 # recent payments across the workspace
harakumo payments 12              # one project's earnings and payments
harakumo payments enable 12
harakumo payments link 12 29 --description "Pro plan" --success-url https://yourapp.com/thanks
# Session lookup, refunds and webhooks: REST or the dashboard.
```

**REST**

```http
POST   /api/projects/:id/payments                 # enable (or re-enable)
GET    /api/projects/:id/payments                 # account, earnings, recent payments and links
POST   /api/payments/:accountId                   { "amountCents", "description"?, "customerEmail"?, "reference"?, "metadata"?, "successUrl"?, "cancelUrl"?, "appReturnUrl"?, "test"? }
GET    /api/payments/:accountId/sessions/:sessionId   → { paid, payment, session }
GET    /api/payments/:accountId/payments?limit=&cursor=&status=&reference=
GET    /api/payments/:accountId/sessions?limit=&cursor=&status=&reference=&test=
GET    /api/payments?limit=&cursor=               # the whole workspace
GET    /api/projects/:id/payments/analytics
PATCH  /api/payments/:accountId                   { "action": "set-webhook", "webhookUrl" } | { "action": "rotate-webhook-secret" } | { "action": "test-webhook" }
PATCH  /api/payments/:accountId                   { "action": "refund", "paymentId", "amountCents"? }      # owner, signed in
PATCH  /api/payments/:accountId                   { "action": "withdraw-setup" } | { "action": "withdraw" } # owner, signed in
DELETE /api/payments/:accountId[?permanent=true]
```

---

# Mail

Every email carries your app's name and comes from, in order: the `from` address on the email, the project's From address, or no-reply@harakumo.com. Recipients see your project as the sender — "Vertex <hello@vertex.app>" or "Vertex <no-reply@harakumo.com>", never Harakumo.

The name comes from, in order: fromName on the email, the project's saved sender name, the project's name, a team workspace's name, then the project's address read as words (fruitly-store becomes Fruitly Store). A name that breaks the sender-name rules below is skipped for the next one; a personal workspace's name is never used, because it is its owner's own name. A workspace with several projects must pass projectId.

## In plain words

Sends emails from your app under your app's name, such as a welcome message, a receipt or a sign-in code.

For example: A store emails each customer a receipt the moment they pay.

## Send

**curl**

```bash
curl -X POST https://harakumo.com/api/mail/send \
  -H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
  -d '{ "to": "user@yourapp.com", "subject": "Welcome", "text": "Hello!", "projectId": 12 }'
# 200 → { "message": { "status": "sent", "from_name": "Vertex", "from_email": "no-reply@harakumo.com", "message_id": "…" },
#         "from_fallback": null }

# From your own verified domain, with a Reply-To (both optional)
curl -X POST https://harakumo.com/api/mail/send \
  -H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
  -d '{ "to": "user@yourapp.com", "subject": "Welcome", "text": "Hello!", "projectId": 12,
        "from": "hello@yourdomain.com", "replyTo": "support@yourdomain.com" }'
```

> Mail to reserved test domains (example.com, .test, .invalid, .localhost) is never delivered: it is logged with status simulated. Test with a real address.

## What the statuses mean

| Status | Meaning |
| --- | --- |
| sent | The provider accepted the message. Delivery, bounces and opens are not reported yet. |
| simulated | Not sent: a reserved test address, or no provider in this environment. |
| failed | The provider refused it; the response is 502 with the reason. Failed sends do not count toward the quota. |

## Sender names

Set one per project on the Mail page (Sender names & addresses) or with `PATCH /api/projects/:id { mailSenderName }` ("" resets it), or per email with `fromName`. The same rules apply on every address, your own domain's included, because recipients read the name first:

- Up to 64 characters of Latin letters (accents are fine), digits, spaces and basic punctuation. Letters from other scripts are refused, because some pass for Latin ones in an inbox. Angle brackets, quotes, backslashes, @ and invisible characters are taken out.
- Nothing that reads as Harakumo, anywhere in the name: look-alike letters, digits standing in for letters and one-letter misspellings count too, so "Team Harakumo" and "Harakum0" are refused.
- Not only generic words. A name made of nothing but words like Support, Security, Team, No Reply, Account, Billing or Notifications is refused, because it reads as the operator of the address; one word of your own makes it fine ("Vertex Support").
- Subjects may use any script, but may not mention Harakumo (400 `reserved_subject`).
- A name that breaks a rule is refused with 400 and the reason, whether it is saved on the project or passed as `fromName` (code `sender_name` on a send); nothing is sent.

## Send from your own domain

Add a domain you own once, and your apps can send from any address on it, like hello@yourdomain.com. Its DNS lives on Harakumo, so Harakumo adds every email record itself: you never copy DKIM or SPF records. How much you do depends on where the domain's DNS is today. Harakumo finds out for you when you add it.

A subdomain like mail.yourdomain.com works too. Sending domains per plan: 1 on hobby, 10 on pro, 25 on team, 50 on scale.

| You have | What happens |
| --- | --- |
| A domain bought on Harakumo | Nothing to copy. Harakumo adds every email record for you. If its DNS hosting is off, turn it on first (Domains page, or `host_domain_dns` from an AI assistant). |
| A domain connected to a project, nameservers already switched | Nothing to copy. Harakumo adds every email record for you and it is verified within minutes. |
| A domain connected to a project, nameservers not switched yet | Switch the nameservers and sending turns on by itself. |
| A domain at another company (GoDaddy, Namecheap, Squarespace, Hostinger …) | Harakumo sets up DNS for it and copies the records it has today, so its website and email keep working. You switch its nameservers to the two shown, where you bought it: about 5 minutes. Sending turns on by itself once they switch (usually under an hour, sometimes up to a day). |

> Until a domain is verified, your mail still goes out from no-reply@harakumo.com under your app's name, so nothing breaks while you set it up. Mail is never sent from an unverified domain.

## If the domain's DNS is on Harakumo

1. Open Mail → Sending domains and press Add a domain. Type the domain (for example yourdomain.com) and press Continue.
2. Harakumo sees the DNS is here and says "Good news … nothing to copy". Press Set it up.
3. Harakumo adds the email records to your DNS and confirms them. This usually takes a few minutes; the page updates by itself and the workspace owner gets an email.
4. When it shows Verified, choose an address like hello@yourdomain.com for a project under Sender names & addresses.

> If the card says Waiting for nameservers, the domain is set up on Harakumo but its nameservers still point at your old provider: follow the steps in the next section from step 3.

## If the domain's DNS is at another company

1. Open Mail → Sending domains and press Add a domain. Type the domain and press Continue. Harakumo explains what happens next; press Set up DNS on Harakumo.
2. Harakumo sets up DNS for the domain and copies the records it has today: your website's addresses, the MX records your inbox uses, verification records and subdomains. The card lists them under Records we copied.
3. Sign in where you bought the domain and replace its nameservers with the two on the card (each has a copy button, and Copy both copies them together). The card shows the steps for GoDaddy, Namecheap, Squarespace, Hostinger or any other provider, and guesses which one runs your DNS today.
4. Press "I switched them, check now". The switch takes usually under an hour, sometimes up to a day. The page re-checks every minute for 10 minutes, and Harakumo keeps checking every 10 minutes by itself, so you can close the page.
5. Once it switches, Harakumo adds the email records and confirms them. The domain shows Verified and the workspace owner gets an email. Choose an address on it for a project, or pass `from` when you send.

> Switch the nameservers within 14 days of starting. After that the setup stops, the DNS set up for it is removed, and Start again sets it up afresh. Nothing changes for your visitors or your inbox until you switch.

## What we copy, and why your website and email keep working

A domain's nameservers decide which DNS answers for it. Before you switch them, Harakumo copies every record it can find in the domain's current DNS into its new DNS here, so when the switch happens, your website, your inbox and anything else on the domain answer exactly as before.

- Check the list on the card (Records we copied), or open the domain on the Domains page, where you can add, edit or delete records. Harakumo reads the records the domain answers with publicly, so a record nothing asks for, like an unusual subdomain, may be missing: add it there before you switch.
- If the card says only the email records could be copied, or none, add your website's records on the Domains page before you switch.
- If DNSSEC is turned on for the domain at your provider, turn it off before switching, or the domain can stop working.
- Once the switch is seen, Harakumo adds the records sending needs: DKIM (proves the mail really comes from you), SPF and MX on a bounce subdomain (so they never clash with your inbox), and DMARC when the domain has none. It never changes or removes a record you made. Already have a DMARC record at `_dmarc`? Yours is kept.

## Changing nameservers, provider by provider

| Provider | What to do |
| --- | --- |
| GoDaddy | Sign in and open your Domain Portfolio (My Products, then Domains). Choose your domain, then DNS, then the Nameservers tab. Choose Change Nameservers, then I'll use my own nameservers. Paste the two Harakumo nameservers, one per box, delete any extra boxes, and save. GoDaddy may ask you to confirm with a code. |
| Namecheap | Sign in, open Domain List and choose Manage next to your domain. Under Nameservers, change Namecheap BasicDNS to Custom DNS, paste the two Harakumo nameservers and save with the green check mark. Namecheap Email Forwarding only works while Namecheap runs your DNS, so if you use it, set up forwarding somewhere else first. Namecheap Private Email keeps working: its records are copied. |
| Squarespace (domains that were at Google Domains are here now) | Open Domains in your Squarespace account and choose your domain. Go to DNS, then Domain Nameservers, and choose Use custom nameservers. Paste the two Harakumo nameservers and save. Squarespace warns that its own DNS settings stop applying: that is expected. |
| Hostinger | Open Domains in hPanel and choose your domain. Open DNS / Nameservers, then Change Nameservers, choose Change nameservers (custom), paste the two Harakumo nameservers and save. |
| Any other provider | Sign in where you bought the domain (the company you pay for it each year, which is not always where your website or email is). Find Nameservers, often under DNS, Domain settings or Advanced. Choose custom nameservers, replace every one listed with the two Harakumo nameservers, and save. If DNSSEC is on for the domain, turn it off first. Stuck? Ask their support to change the nameservers to the two shown. |

> The dashboard picks your provider for you from the domain's current nameservers; `harakumo mail domains show yourdomain.com --provider godaddy` prints the same steps (namecheap, squarespace, hostinger, other).

## Checking, and what each status means

Check now (or `POST /api/mail/domains/:id/check`, or the `check_mail_domain` tool) looks whether the nameservers switched and, once they have, looks each email record up in public DNS and answers with every record's status. Checks less than 10 seconds apart return the last result. In the background, domains still being set up are checked every 10 minutes and verified ones every 6 hours.

- Each record Harakumo added is `found`, `missing` or `wrong`. A wrong record also lists what public DNS shows instead (`seen`).
- `next_action` is one of `point_nameservers`, `fix_records`, `wait`, `wait_for_platform`, `restart` or `none`, and `next` says it in a sentence. While it is `point_nameservers`, the domain carries `nameservers`, `provider_hints` and `copied_records`.

| Status | can_send | Meaning |
| --- | --- | --- |
| pending | false | Being set up: waiting for the nameservers to switch, or for the records to be confirmed. `next_action` says what, if anything, you need to do. |
| verified | true, or false while `reason_code` is set | Ready. Your apps can send from any address on the domain. A verified domain with a `reason_code` is waiting on Harakumo, not on you. |
| failed | false | Stopped. `reason` says why; Start again (or add it again) sets it up again. |
| removed | false | You removed it. It no longer counts toward your plan. |

## Nameservers not switching?

- Give it time. The switch takes usually under an hour, sometimes up to a day. Press Check now again later; nothing is lost while you wait.
- Changed at the wrong company. Nameservers are set where you bought the domain (where you pay for it each year), which is not always where your website or email is. The card's "Looks like … runs DNS" line says who runs it today.
- An old nameserver left in the list. Replace every nameserver with exactly the two shown and remove the rest; a leftover one keeps answering for part of the internet.
- A typing mistake. Use the copy buttons: one wrong letter points the domain nowhere.
- DNSSEC is on. Turn it off at your provider, then switch.
- The setup stopped after 14 days. Press Start again: Harakumo sets up DNS again and shows the nameservers.
- Still stuck? Ask your provider's support to change the nameservers to the two shown.

## When a domain can't send

A send asking for an address on a domain that cannot send yet still goes out, from no-reply@harakumo.com under your app's name. The response's `from_fallback` says why: `{ requested, reason_code, reason }`. It is null when the address you asked for was used.

An explicit `from` on a domain this workspace never added is refused (400 `from_domain_not_added`) rather than sent from the shared address.

| reason_code | What it means | What to do |
| --- | --- | --- |
| zone_pending | The domain's DNS is set up on Harakumo but its nameservers do not point here yet. | Switch the nameservers (a domain bought on Harakumo with DNS hosting on is switched for you). Sending turns on by itself. |
| records_missing | Harakumo added the records; they are not visible in public DNS yet. | Nothing. It usually takes a few minutes. |
| records_wrong | A record you already had at the same name blocks one of Harakumo's. | Edit it on the Domains page so it matches the Value shown, then press Check now. |
| provider_pending | Everything is in place; the final confirmation is running. | Nothing. It usually takes a few minutes. |
| edge_not_permitted | Harakumo is finishing setup on its side for domains hosted here. | Nothing. The page updates by itself and this never expires. |
| expired | The setup did not finish within 14 days. | Start again. |
| verification_lost | It was verified, but its email records are gone. | Nothing, usually: Harakumo adds them back on the next check. If a record of yours blocks one, edit it on the Domains page. |
| domain_left_workspace | The domain's DNS is no longer hosted on Harakumo by this workspace. | Start again to set it up again. |
| replaced | Another workspace set the domain up after this setup stopped making progress. | If it is yours, press Start again. |
| domain_claimed_elsewhere | Another workspace on Harakumo now hosts or holds this domain. | If it is yours, contact support@harakumo.com. |
| domain_removed | The project's saved From address is on a domain this workspace no longer has (only in `from_fallback`). | Pick a new From address, or add the domain again. |
| method_retired | The domain was set up the old way, with records added at another DNS provider, which no longer sends. | Press Start again and switch its nameservers to Harakumo to keep the address. Its existing records are copied first. |

## Choosing the From address and Reply-To

- Per project: on the Mail page under Sender names & addresses, or `PATCH /api/projects/:id { mailFrom, mailReplyTo }` ("" resets either). A domain that is still pending can be chosen in advance; mail uses no-reply@harakumo.com until it is verified.
- Per email: `from` and `replyTo` on `/api/mail/send` (the SDK, CLI and `send_email` tool take the same).
- The part before @ uses lowercase letters, numbers and . _ + - (up to 64 characters). postmaster@, abuse@, hostmaster@ and mailer-daemon@ are reserved for mail servers.
- On the shared domain only no-reply@harakumo.com can be used. Add your own domain for any other address.
- Reply-To is one address and needs no verification, but it cannot be a Harakumo address.
- A domain belongs to one workspace. Once a workspace has verified yourdomain.com, or holds its DNS here, other workspaces cannot send from it or from names under it. A setup another workspace started keeps the name for 3 days; one that never went further than that does not block you.
- Removing a domain (Remove on its card, or `DELETE /api/mail/domains/:id`) moves projects that used it back to no-reply@harakumo.com; their sender names stay. The email records Harakumo added are removed, and so is DNS it set up for the domain whose nameservers were never switched.

## Set it up with an AI assistant

With the connector attached, ask: "Set up yourdomain.com as a sending domain on Harakumo and walk me through it." The assistant adds the domain, tells you which records were copied, gives you the two nameservers and the steps for your provider, checks until the switch is seen, and sets your project's From address once it is verified.

- `add_mail_domain` — start (or start again). Returns the nameservers, the numbered steps, provider hints and the copied records.
- `check_mail_domain` — check now: whether the nameservers switched, then each record's status.
- `list_mail_domains` / `get_mail_domain` — status, steps and every project's current From address.
- `set_mail_from` — a project's From address and Reply-To.
- `remove_mail_domain` — stop sending from a domain.
- `host_domain_dns` — for a domain bought on Harakumo, host its DNS here so every record is added for you.
- `send_email` — accepts `from` and `replyTo`.

## From code: API, SDK and CLI

**curl**

```bash
# 1. Add the domain
curl -X POST https://harakumo.com/api/mail/domains \
  -H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
  -d '{ "domain": "yourdomain.com" }'
# 201 → { "created": true, "domain": { "id": 7, "domain": "yourdomain.com", "method": "platform", "status": "pending",
#   "can_send": false, "reason_code": "zone_pending", "next_action": "point_nameservers",
#   "nameservers": ["<first nameserver>", "<second nameserver>"],
#   "copied_records": [{ "type": "MX", "host": "@", "value": "<your mail server>" }, …],
#   "steps": ["We set up DNS for yourdomain.com on Harakumo and copied the 6 records it has today …", …], … } }

# 2. After switching the nameservers where you bought the domain
curl -X POST https://harakumo.com/api/mail/domains/yourdomain.com/check -H "Authorization: Bearer $HARAKUMO_API_KEY"

# 3. Once verified, make it the project's From address
curl -X PATCH https://harakumo.com/api/projects/12 \
  -H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
  -d '{ "mailFrom": "hello@yourdomain.com", "mailReplyTo": "support@yourdomain.com" }'
```

**SDK**

```js
const { domain } = await hk.mail.domains.add('yourdomain.com');   // domain.nameservers, domain.steps
// … switch the nameservers where the domain was bought …
await hk.mail.domains.check('yourdomain.com');                    // status, can_send, next
await hk.mail.domains.waitUntilVerified('yourdomain.com');        // polls every 30 s, up to 10 minutes
await hk.mail.setFrom(project.id, { from: 'hello@yourdomain.com', replyTo: 'support@yourdomain.com' });
```

**CLI**

```bash
harakumo mail domains add yourdomain.com                  # the nameservers and the steps
harakumo mail domains show yourdomain.com --provider godaddy
harakumo mail domains check yourdomain.com --wait
harakumo mail from 12 hello@yourdomain.com --reply-to support@yourdomain.com
```

## Quotas and controls

- 100 on hobby, 2,000 on pro, 10,000 on team, 100,000 on scale, counted over a rolling 30 days (402 `plan_limit`), on a paid plan across the workspaces it covers and on Hobby across the owner's free workspaces; a Pro subscription from before these plans keeps 5,000. The owner is emailed at 80% and 100%.
- Custom sending domains: 1 on hobby, 10 on pro, 25 on team, 50 on scale (402 `plan_limit`). Pending and failed domains count until you remove them.
- Your own controls at `/api/mail/settings` (admins): `paused` stops every send (403 `mail_paused`), `dailyCap` limits any 24 hours (429 `daily_cap`).
- Harakumo staff can suspend a workspace's sending, for example after reports of abuse: every send then answers 403 `mail_suspended` with the reason, and support@harakumo.com is where you ask about it.
- A suppression list at `/api/mail/suppressions`: a suppressed address is refused with 422 `suppressed` before anything is sent — record unsubscribes there.
- One recipient per call, text and/or HTML, with an optional Reply-To. No attachments, cc, templates or batch sending yet.
- A key limited to one project can send too: it sends for that project (leave `projectId` out, or name that project; naming another is refused) and counts toward the workspace's quota. It sends only as that project: from an address on the project's own sending domain (the domain of its saved From address) or the shared no-reply, never from another of the workspace's domains (403 `from_not_project_domain`), and never under another project's name or sender name, or a close variant of one such as the name with a word added (names of five letters or more) or one letter changed (six or more) (403 `sender_name_other_project`); another project's exact name is refused even when its own project's name contains it. The same rules apply when such a key changes its project's `mailFrom` or `mailSenderName`; a person moves a project to a new domain on the Mail page. A read-only key cannot send. The other mail routes (messages, settings, suppressions and sending domains) cover the whole workspace and need a workspace key.

## Reference

**SDK**

```js
await hk.mail.send({ to: 'user@yourapp.com', subject: 'Welcome', text: 'Hello!', projectId: project.id });
await hk.projects.update(project.id, { mailSenderName: 'Vertex' });                       // set once
await hk.mail.send({ to, subject, html, projectId: project.id, fromName: 'Vertex Billing' }); // or per email
const { messages } = await hk.mail.messages();

// Your own domain
await hk.mail.domains.add('yourdomain.com');
await hk.mail.domains.check('yourdomain.com');
await hk.mail.setFrom(project.id, { from: 'hello@yourdomain.com', replyTo: 'support@yourdomain.com' });
const { from_fallback } = await hk.mail.send({ to, subject, text, projectId: project.id, from: 'hello@yourdomain.com', replyTo: 'support@yourdomain.com' });
```

**CLI**

```bash
harakumo mail send user@yourapp.com "Welcome" "Hello from Vertex" --project 12 --from-name "Vertex"
harakumo mail domains add yourdomain.com
harakumo mail domains check yourdomain.com --wait
harakumo mail from 12 hello@yourdomain.com
harakumo mail send user@yourapp.com "Welcome" "Hello" --project 12 --from hello@yourdomain.com --reply-to support@yourdomain.com
```

**REST**

```http
POST   /api/mail/send          { "to", "subject", "text"?, "html"?, "projectId"?, "fromName"?, "from"?, "replyTo"? }   → { message, from_fallback }
GET    /api/mail/messages      ?projectId=&status=sent|failed|simulated&before=&limit=   → { messages, usage, settings }
GET    /api/mail/settings · PUT { "paused"?, "dailyCap"? }        # admin
GET    /api/mail/suppressions · POST { "address", "reason"?: "manual"|"unsubscribe" } · DELETE ?address=
PATCH  /api/projects/:id       { "mailSenderName"?, "mailFrom"?, "mailReplyTo"? }   # "" resets each
GET    /api/mail/domains                        → { domains, limit, default_from, can_add, projects }
GET    /api/mail/domains/preview?domain=        → { domain, root, zone_active, nameserver_move, needs_dns_hosting, nameservers, platform_option, limit, existing }
POST   /api/mail/domains       { "domain" }                  → 201 { domain, created: true } · 200 when it existed
GET    /api/mail/domains/:idOrDomain            → { domain }
POST   /api/mail/domains/:idOrDomain/check      → { domain, throttled }
DELETE /api/mail/domains/:idOrDomain            → { removed, domain, projects_reset, note }
```

---

# Domains

Every domain in the workspace is one entry, keyed by its root (`GET /api/domains`), with one plain-word state and the next step. www is served together with the apex.

## In plain words

Your own web address, like yourshop.com. Search for one, buy it from your balance and it is set up to point at your project, or connect one you already own.

For example: A bakery buys freshbakes.com, and its site answers there.

## Buy, connect or transfer?

| You want to | Do this |
| --- | --- |
| A new name | Buy it here. It is paid from the Balance, and can connect to a project in the same call. |
| Use a name you own elsewhere | Connect it: add it to a project and switch its nameservers to the two Harakumo gives you. |
| Move a name you own to Harakumo | Transfer it in with its auth code; the price includes a year of renewal. |

## Buying

1. Search: `GET /api/domains/search?q=myshop` (or `myshop.org` to search that name first). Each result has `available` (null means it could not be checked — not taken), `sellable`, `price_cents`, `renewal_price_cents` and, for a name you can buy, `lock_lifts_at`.
2. Buy: `POST /api/domains/registrations { domain, projectId? }`. The live price is checked again; if it is above the price you saw (`expectedPriceCents`) nothing happens.
3. With a projectId the domain connects automatically and goes live within minutes to hours as DNS settles; the owner gets an email when it is live.

- Endings sold today: .com, .dev, .app, .io, .ai, .cloud. Automatic purchases above $100 a year are refused.
- Every domain is paid from the Balance at the price shown before you buy, on every plan. There is no cap on how many you buy — each is paid for.
- A Balance too small answers 402 `insufficient_balance` with the shortfall and `add_funds_url`, the page where funds are added; nothing is charged. The card on file is charged only when the owner, signed in to the dashboard, asks (`payWithCard: true`): see Who pays, and how. That answer offers the card (`pay_with_card`) only to the owner signed in to the dashboard; anyone else, an AI connector or API key included, gets `pay_with_card: null` and is told the owner pays by card on the Domains page.
- Purchases count toward the workspace's monthly spending limit, if one is set.
- Search results and renewal prices come from the registrar's price list, which Harakumo refreshes once a day; a purchase checks the live price again before anything is charged.
- Harakumo registers the domain through its registrar account and manages it for your workspace. Harakumo is the registrant of record (the registration lists Harakumo, not you, as the holder, and your details are not sent to the registrar); your workspace is the record that the domain is yours, and you can transfer it out to hold it in your own name. Registries lock a domain for 60 days after registration or transfer.
- That lock means a domain you buy cannot move to another registrar until `lock_lifts_at`, 60 days after the purchase; it works as usual meanwhile. The buy dialog says so before you pay, and search results, the dry run (`dryRun: true`), the purchase answer and every registration carry the date.
- Prices are in US dollars and do not include taxes such as sales tax, VAT or GST.

## Who pays, and how

- Anyone who manages domains: developers, admins and owners (in the dashboard, or through their AI connector with write access) and full workspace API keys. AI connectors and API keys always pay from the Balance.
- The card is the owner's, so it takes the owner in person. An AI connector, the owner's own included, is a long-lived token another app holds, and an API key belongs to an app: asked to use the card, any of them that manages domains gets `card_requires_owner` (402, or 403 for `cardBackup`) with `dashboard_url`, the Domains page where the owner can do it, and nothing is charged or changed. A read-only connection or read key is told it is read-only.
- When the Balance is short, an AI connector or API key gets `add_funds_url` to hand to the person, who adds funds in a browser; then the call is made again. An owner's AI connector can also make a checkout link for that (`create_add_funds_link`).
- In a workspace another workspace's plan covers, its domains are paid from its own Balance, under its own spending limit, not from the paying workspace's (which pays for its AI): `add_funds_url` opens that own Balance (Billing → Balance), `add_funds_api` is `POST /api/billing/balance { "cents": 1000, "own": true }` (it charges the card on file, so it takes the owner signed in: an AI connector or API key that calls it gets `dashboard_only`, whose `dashboard_url` is that own Balance), and the checkout link takes `own: true` (`create_add_funds_link`, or `POST /api/billing/balance/checkout { "cents": 1000, "own": true }`), which a connector can make for the owner to open and pay.

| Call | From the Balance | With the card on file |
| --- | --- | --- |
| Buy: `POST /api/domains/registrations` | Anyone who manages domains | `payWithCard: true`, or `autoRenewCard: true` for its renewals: the owner signed in to the dashboard |
| Renew now: `PATCH /api/domains/registrations/:id { action: "renew" }` | Anyone who manages domains | `payWithCard: true`: the owner signed in to the dashboard |
| Auto-renew: `PATCH /api/domains/registrations/:id { action: "auto-renew", enabled }` | Anyone who manages domains | `cardBackup: true`: the owner signed in to the dashboard |
| Transfer in: `POST /api/domains/transfers` | Anyone who manages domains | `payWithCard: true`: the owner signed in to the dashboard |

## Connecting a domain you own

1. Add it to a project: `POST /api/projects/:id/domains { hostname, connect: true }`. Harakumo checks no other workspace owns that root domain. Adding the domain itself (example.com) serves www.example.com too, unless www is added as a hostname of its own; a subdomain serves only itself.
2. Harakumo copies the records your domain answers with today (email included) into its DNS, and returns two nameservers. Review the copied records in DNS before switching.
3. Set those nameservers at your registrar. The domain goes live on its own within about 10 minutes of the change being visible — there is nothing to press.

> Switching nameservers moves all DNS for the root domain, even when you only connect a subdomain. Connecting by CNAME is not available yet.

## DNS records

Any domain whose DNS Harakumo hosts — bought, transferred in or connected by nameservers — has editable records at `/api/domains/zones/{root}/dns` (GET, POST, PATCH, DELETE), and the same DNS panel under Manage on the Domains page. Record types: A, AAAA, CNAME, MX, TXT and NS. SRV and CAA are not supported yet.

**Add an MX record**

```bash
curl -X POST https://harakumo.com/api/domains/zones/myshop.com/dns \
  -H "Authorization: Bearer $HARAKUMO_API_KEY" -H "content-type: application/json" \
  -d '{ "type": "MX", "name": "@", "content": "mail.example.net", "priority": 10 }'
```

> Want email from this domain? Mail → Sending domains sets it up (automatically when its DNS is here).

## Renewal

- Bought domains auto-renew by default (`autoRenew: false` at purchase to opt out). Renewal happens in the last 7 days before expiry, at `renewal_price_cents`, paid from the Balance. Warnings go out 30, 7 and 1 days before.
- The card covers a renewal only if the owner, signed in to the dashboard, turned on card backup (`autoRenewCard: true` at purchase, or `PATCH { action: "auto-renew", enabled: true, cardBackup: true }`). Renewals set up by developers, API keys, AI connectors or transfers pay from the Balance only.
- A domain past expiry is marked expired; renewing it within 30 days points it back at its DNS.
- Every renewal is paid at its renewal price, on every plan. The renew answer says what was charged and where from (`chargedCents`, `paidFrom`), and the expiry email quotes the price of an automatic renewal.
- Renewals above $100 a year are never automatic: the daily renewal, the API, AI connectors and other roles stop there (403 `over_cap`, with `dashboard_url`), and the owner is emailed. The workspace owner, signed in to the dashboard, renews it by hand on the Domains page at the price shown; that call carries `expectedPriceCents` at or above the price (409 `confirm_price` without it, 409 `price_moved` when the price went up).

## Stop serving, DNS only, other nameservers

- `DELETE /api/domains/registrations/:id` (or `PATCH { action: "detach" }`) stops serving the project. DNS, email and renewal stay.
- `PATCH { action: "host-dns" }` hosts DNS here without a project; `{ action: "set-nameservers", nameservers: [...] }` points the domain at another provider; `{ action: "connect", projectId }` serves another project.
- Deleting a project detaches its domains; bought domains stay in the workspace and keep renewing.
- `GET /api/domains/registrations/:id?include=registrar` adds `at_registrar`: what the registrar holds for the domain right now, read live — its transfer lock (true, false, or null when it could not be read) and the nameservers it points at, whether those are this domain's Harakumo DNS and who runs them. The Domains page shows the same for every domain the workspace holds. The lock is not changed here: a transfer-out request has it lifted.

## Transfers

- In: unlock the domain at your current registrar, get its auth code, and `POST /api/domains/transfers { domain, authCode, dryRun: true }` to validate and quote without paying. Then send it again without dryRun (with `expectedPriceCents`). The Balance pays once (the card only when the owner, signed in to the dashboard, sends `payWithCard: true`); the transfer completes in 5–7 days.
- Out: `PATCH /api/domains/registrations/:id { action: "transfer-out" }`. The auth code arrives within five calendar days (usually sooner), by email and in the dashboard. The domain keeps working until it has moved.

## Limits

- Your own domains connected per plan: 2 hobby, 20 pro, 100 team, 500 scale.
- Up to 10 new DNS zones per workspace a day, and 20 a day across all the workspaces one person owns. Connecting a domain the workspace already holds is never refused by either.
- Domain routes are workspace-wide: use a workspace key. Adding a hostname to a project (`/api/projects/:id/domains`) accepts that project's key.

## Reference

**SDK**

```js
const { results } = await hk.domains.search('myshop');     // [{ domain, available, sellable, price_cents, renewal_price_cents }]
const pick = results.find(r => r.available && r.sellable);
const { registration, paidFrom } = await hk.domains.register(pick.domain, { projectId: project.id, expectedPriceCents: pick.price_cents });
// 402 insufficient_balance → err.body.shortfall_usd, err.body.add_funds_url

await hk.domains.add(project.id, { hostname: 'app.myshop.dev' });   // a domain you own; connect: see REST
const { registration: one } = await hk.domains.registration(registration.id);   // renewal_price_cents, lock_lifts_at
const check = await hk.domains.transferIn('myshop.com', { authCode: 'EPP-CODE', dryRun: true });
```

**CLI**

```bash
harakumo domains buy myshop.com 12    # from the Balance; the CLI never pays with the card
```

**REST**

```http
GET    /api/domains                         # every domain, one per root, with state, next step and renewal
GET    /api/domains/search?q=myshop
POST   /api/domains/registrations           { "domain", "projectId"?, "expectedPriceCents"?, "autoRenew"?, "autoRenewCard"?, "payWithCard"? }
GET    /api/domains/registrations           # each with renewal_price_cents, lock_lifts_at and state
GET    /api/domains/registrations/:id       # one, with renewal_price_cents and lock_lifts_at; ?include=registrar adds at_registrar
PATCH  /api/domains/registrations/:id       { "action": "connect", "projectId" } | "detach" | "host-dns" | "set-nameservers" | "renew" | "auto-renew" | "transfer-out" | "cancel-transfer-out"
DELETE /api/domains/registrations/:id       # stop serving (DNS and renewal stay)
POST   /api/projects/:id/domains            { "hostname", "connect": true }   # → nameservers
POST   /api/domains/:id                     # check a connected domain now (the sweep also does)
GET|POST|PATCH|DELETE /api/domains/zones/:root/dns
POST   /api/domains/transfers               { "domain", "authCode", "projectId"?, "dryRun"?, "expectedPriceCents"? }
GET    /api/domains/transfers · PATCH /api/domains/transfers/:id { "action": "check"|"replace-code"|"cancel" }
```

---

# Shield

Shield turns away the visitors you choose: by country, from your static sites, server apps, functions and worker.js programs; by address or range, and AI crawlers that say who they are, from your static sites and functions. It keeps the files beside your pages that should stay private from ever being served, can put a static site behind a password (paid plans), counts what its rules catch on static sites and functions, and checks how the workspace itself is set up. Network-level attack protection runs in front of every site, app and function and needs no setting.

The rules are one setting for the whole workspace. Saving them puts them on every live static site at once, and Apply now puts them on any site a save could not reach. A server app reads the country rules live: a change reaches it within about a minute, with no redeploy. Functions and worker.js programs get them on their next deploy. Turning Shield off reaches each of them the same way.

Changed on 2026-10-11: country rules used to reach static sites only. Since then a workspace that blocks countries refuses them on its server apps too (they took effect within about a minute, with no redeploy), and on each function and worker.js program from its next deploy. The rules are one setting for the whole workspace, so an app cannot be left open to a country its sites block: take the country off the list, or keep that app in another workspace.

## In plain words

Protection for your websites and apps. Keep out visitors from countries you choose, or put a site behind a password. Private files next to your pages are never shown.

For example: A shop that only delivers in its own country turns away visitors from the countries it never sells to.

## What each part covers

| Part | What it does | Where |
| --- | --- | --- |
| Country rules | A visitor from a blocked country gets 403 "Not available in your region". On a static site the check runs before the cache, so a cached page never gets past it; on a server app it runs before the app is woken, so a blocked visitor never reaches it; in front of a function or a worker.js program it runs before the code, which does not run for them. | Static sites (at their harakumo.app address and on their own domains), server apps, functions and worker.js programs |
| Address lists | An address or range on the deny list gets 403, whatever else is true. One on the allow list is let past the country rules and the AI-crawler rule. | Static sites and functions |
| AI-crawler blocking | AI crawlers and AI assistants' fetchers that say who they are in their user agent get 403. One that pretends to be a browser is not caught. | Static sites and functions |
| Log-only mode | Counts what the rules above would have refused and lets it through, so a rule can be checked before it blocks anyone. A site password still applies. | Static sites and functions |
| What the rules caught | Each site and function counts what it refused, or what log-only mode let through, and sends the counts a moment later; Dashboard → Shield shows them per site, function and rule for the last 7 or 30 days. They are close counts, not exact ones. | Static sites and functions |
| Site passwords | A static site can be put behind one password (paid plans). A visitor sees the password page before anything else, and the right password lets them in on that site for seven days. Only a salted hash is kept, sealed; the password itself is never stored or answered. | Static sites only |
| File guard | Dotfiles, `.env` files and private keys (`.pem`, `.key`, `id_rsa` and the like) are left out when the site deploys, with a line in the deploy log, and a request for one answers 404. `/.well-known/` stays public. | Static sites |
| HTTPS redirect and security headers | Plain HTTP is redirected to HTTPS, and answers are sent with security headers. | Static sites, server apps, functions and worker.js programs |
| Network-level attack protection | Always on, because every Harakumo address is served through the edge network. There is no setting for it, and no numbers are shown for it. | Every site, app and function |
| Security checks | A check of the workspace's own records: two-factor, API keys, domains, roles and more (below). | The workspace |

> Country rules reach static sites (at once on a save), server apps (within about a minute, no redeploy), functions and worker.js programs (on their next deploy): a server app's deploy log and a worker.js program's name the countries they refuse. The address lists, the AI-crawler rule, log-only mode and the counts work on static sites and functions; the file guard and site passwords on static sites only.

## Settings

| Setting | Meaning |
| --- | --- |
| `enabled` | Whether the rules are applied (default on). Turned off, a site blocks nobody from that save or the next Apply now, a server app within about a minute, and a function or a worker.js program from its next deploy. A site password stays in force. |
| `blockedCountries` | Two-letter country codes whose visitors are refused, such as `["KP"]` (default none). The dashboard takes them comma separated. |
| `denyIps` | Addresses and ranges that are always refused, such as `["203.0.113.7", "2001:db8::/32"]` (default none). |
| `allowIps` | Addresses and ranges let past the country rules and the AI-crawler rule (default none). An address on both lists is refused: the deny list wins. |
| `blockAiCrawlers` | Whether AI crawlers that say who they are in their user agent are refused (default off). |
| `logOnly` | Count what the rules would refuse and let it through (default off). A site password still applies. |
| `passwords` | Set or remove a static site's password: `{ "passwords": { "<site>": "<password>" } }` sets one, and `null` in place of the password removes it (a paid plan to set one, any plan to remove it). It is never read back: the answers name the protected sites (`protectedSites`) and say whether this plan may set one (`passwordsAllowed`). |

> A country code that is not a country, an address that is not an address or a range, and a setting Shield does not have are refused with 400, and nothing is stored. Check each code before you save it: the United Kingdom is GB, not UK. XX stands for visitors whose country is unknown and T1 for the Tor network.

## Where to change them

- Dashboard → Shield. Every member can see the settings and what the rules caught; an owner or admin changes them.
- `GET /api/services/shield` and `PUT /api/services/shield` (the body is merged over the saved settings); `POST /api/services/shield` is Apply now.
- An owner's or admin's AI assistant, with `get_service_config` and `update_service_config` for the service `shield`.

> A save puts the rules on your live static sites at once and says which it reached (`appliedNote`); Apply now puts them on any site a save could not reach. A server app reads the country rules within about a minute of a save, with no redeploy. Functions, and worker.js programs (the country rules), get them on their next deploy, and a save names the functions still running the rules from before. A static site's deploy log names the countries it blocks (`Shield: blocking KP`); a server app's and a worker.js program's say which they refuse as they go live.

## Security checks

Dashboard → Shield also checks the workspace itself, measured from its own records each time you look: a score out of 100, what each check found, and the changes made in the workspace per hour over the last 24 hours (UTC). Every member can see them. The same checks come from `GET /api/security/overview`, the SDK's `hk.security.overview()` and the AI connector's `security_overview` tool.

| Check | What it looks at | In the score |
| --- | --- | --- |
| Owners and admins with two-factor | Whether every owner and admin signs in with two-factor. | Yes |
| Full-access API keys | Active keys that are neither read-only nor limited to one project. | Yes |
| Oldest active key | How old the oldest key that has not been revoked is. | Yes |
| Never-used keys | Active keys that have never made a call. Revoke the ones nobody needs. | Yes |
| Domain verification | Custom domains that are not verified yet. | Yes |
| Owners and admins | How many members hold a role that can change everything. | Yes |
| Environment variables | How many are stored. Anyone with developer access to a project can read its variables. Shown, not scored: storing them is normal. | No |
| End-user sign-in lockouts | Users of your apps locked out after repeated failed sign-ins. | Yes |
| Password hashing | How Harakumo stores members' passwords. Shown, not scored: it is not something you set. | No |
| Sign-in for your apps' users | Whether your apps' users can add a second factor. It is not available yet, so it is shown, not scored. | No |
| Audit activity | Changes recorded in the audit log over the last 7 days. | No |
| API key activity | Which keys were used in the last 24 hours, and the data calls counted today and yesterday (UTC). | No |

## Not yet

- No rate limits per site and no bot challenges yet.
- No managed rule sets of the kind a web application firewall has.
- Rules are one setting for the whole workspace, not per project.
- Server apps and worker.js programs get the country rules only: the address lists, the AI-crawler rule, log-only mode and the counts do not reach them yet. The file guard and site passwords are for static sites only.
- Functions get a change on their next deploy, not at once; so do worker.js programs.

## Reference

**SDK**

```js
const { score, checks } = await hk.security.overview();   // the workspace's security checks
// Shield's settings have no SDK method yet: use REST or the AI connector.
```

**REST**

```http
GET /api/services/shield · PUT /api/services/shield  { "enabled": true, "blockedCountries": ["KP"], "denyIps": ["203.0.113.0/24"], "blockAiCrawlers": true, "logOnly": false }
PUT  /api/services/shield  { "passwords": { "<site>": "<password>" } }   # null in place of the password removes it
POST /api/services/shield                                                # Apply now: the saved rules on every live static site
GET  /api/services/shield/blocks?days=7                                  # what the rules caught (7 or 30 days)
GET  /api/security/overview                                              # the workspace's security checks
```

---

# CDN

One setting for every static site in the workspace, built into each site when it deploys, so a change takes effect on each site's next deploy, or at once when you press Apply now on Dashboard → Shield (or `POST /api/services/cdn`), which puts the saved CDN and Shield settings on every live static site. Blocking countries is in Shield.

## Settings

| Setting | Meaning |
| --- | --- |
| `cacheTtlSeconds` | How long the edge keeps each page and file, and browsers keep files such as images, scripts and styles (default 3,600 seconds). Browsers check HTML pages again on every visit, so a page never goes stale in someone's browser. |
| `enabled` | Turning the CDN off stops caching: every visit is served fresh. |

> The edge keeps a copy per deployment, so a redeploy shows at once: nothing has to be cleared. Server apps, worker.js programs and functions are not affected by this setting yet. Changing it needs an owner or admin.

## Where to change them

- Dashboard → CDN, or `PUT /api/services/cdn` (the body is merged over the saved settings), or an owner's or admin's AI assistant with `get_service_config` and `update_service_config`.

## Reference

**REST**

```http
GET /api/services/cdn · PUT /api/services/cdn  { "enabled": true, "cacheTtlSeconds": 3600 }
```

---

# Workspaces, roles & teams

A workspace (called an organization in some API paths) is the unit of access: it has one plan, one Balance, one team and its own API keys, audit log and settings. Its plan is one it pays for, or one another workspace you own pays for and covers it with (Pro 3, Team 10 and Scale 25 workspaces per subscription, Billing → Workspaces). Signing up creates your personal workspace; create more from the workspace switcher. How many you can own depends on the best plan among them.

Free limits are per person, across all your free workspaces. A paid plan's limits are shared across the workspaces it covers. A second free workspace shares the first one's allowance: every Hobby count (projects, functions, databases, team members, emails and the rest) is the total across the free workspaces its owner owns.

## Roles

| Role | Can |
| --- | --- |
| Owner | Everything, including the plan, cards, the Balance, spending limits, withdrawals and refunds, requiring two-factor sign-in of every member, and deleting the workspace. |
| Admin | Members and invitations, API keys (create, give an expiry date, pause, revoke), workspace settings (CDN, Shield, AI Gateway models, mail controls), the audit log, reading billing, deleting projects. |
| Developer | Create, change and delete resources and deployments; read environment variables, function source and stream keys. |
| Viewer | Read only — no environment variables, secrets or function source. |

## Team members

- Admins invite by email. The invitation is emailed, and accepting it needs a verified email at the invited address.
- Seats per plan: 3 hobby, 10 pro, 25 team, 100 scale. Pending invitations hold a seat; a full workspace answers 402 `plan_limit`, and accepting re-checks. On Hobby the seats are people across all the owner's free workspaces. On a paid plan they are per workspace, even when one plan covers several.
- Removing a member lists the API keys they created so you can revoke them.

## Requiring two-factor sign-in

- An owner can require two-factor sign-in of every member: Settings → Workspace, or `PUT /api/orgs/:id/security { requireMfa: true }`, signed in to the dashboard (an AI connector is answered 403 `dashboard_only`). The owner needs two-factor on their own account first, so the rule can never lock them out.
- While it is on, a member without two-factor is refused on everything that acts in the workspace, from the dashboard, the API or a connector, with 403 `workspace_requires_mfa` and where to turn it on. Their own account pages stay open, and they can still leave the workspace.
- API keys belong to the workspace, not to a person, and keep working. `GET /api/orgs/:id/security` says whether the rule is on; owners and admins also see which members have no two-factor yet.

## What lives where

- In the workspace: the plan, Balance and cards, members, API keys, the audit log, domains bought, repos, the mail log and settings, and CDN, Shield and AI Gateway settings.
- In a project: its site or app, environment variables, custom domains, and its databases, buckets, functions, vector collections, memory stores, agents, auth pool, payment account and media.

## Deleting a workspace or account

- Deleting a workspace cancels its subscription immediately (no proration refund) and tears down every project. Workspaces its plan covered move to Hobby (or to a complimentary plan they still have); nothing in them is deleted.
- It is refused while the workspace owns unexpired bought domains (transfer them out, or turn off auto-renew and let them lapse), has a domain transfer, refund or Balance top-up in progress, or holds project earnings.
- A positive Balance is given up, not refunded, and only when you say so: send `{ "confirmForfeit": true }` (the first answer is 409 `confirm_forfeit_required`).
- Deleting your account (`DELETE /api/auth/me { password, confirmForfeit? }`) applies the same rules to every workspace it removes.

## Reference

**REST**

```http
GET    /api/orgs                        # your workspaces
POST   /api/orgs                        { "name" }                              # browser session
GET    /api/orgs/:id/members · PATCH · DELETE                                    # admin to change
POST   /api/orgs/:id/invitations        { "email", "role": "admin"|"developer"|"viewer" }
GET    /api/orgs/:id/security · PUT { "requireMfa": true|false }               # owner, signed in, to change
DELETE /api/orgs/:id                    { "confirmForfeit"? }                   # owner
```

---

# Security & data isolation

Every request is checked the same way: who is calling, which workspace, what their role allows, and whether the resource belongs to that workspace's project. An id from another workspace answers 404, exactly like an id that does not exist, before any of its data is read.

What Harakumo staff can and cannot see, how long each kind of record is kept, and what is still being switched on are on the Security page: harakumo.com/security.

## Your account

- Two-factor sign-in: Settings → Account. Scan the code with an authenticator app, confirm a code, and keep the recovery codes. Sign-in then asks for a code (the API answers `{ mfaRequired, ticket }`; complete with `POST /api/auth/login/mfa { ticket, code | recoveryCode }`). Turning it off needs your password and a code.
- Changing your email needs your current password (and a code when two-factor is on), then a code sent to the new address. The old address is told.
- A password reset or change signs out your other sessions and disconnects AI connectors. A reset code lasts 15 minutes and allows 5 tries; asking again keeps the codes already sent working (the newest three), and one account is sent at most three codes in 15 minutes. The email's button opens the second step with the code filled in.
- Sessions last 30 days; Settings lists them and signs the others out.
- Changes made with the dashboard cookie are refused when they come from another site, and so is signing out: another site cannot sign you out.
- Download a copy of what Harakumo keeps about you as a person in Settings → Account (Your data): your account, two-factor status, workspaces and roles, sign-ins, connected AI assistants, the API keys you made, invitations, messages you sent us and your most recent 5,000 actions, as a JSON file with no secret or hash of one in it. It takes you signed in to the dashboard (`GET /api/auth/me/export`; an AI connector or API key gets 403 `dashboard_only`), at most five times an hour, and each download is written in your workspace's activity and emailed to you.
- An account Harakumo suspends (for abuse, for example) is refused everywhere with 403 `account_suspended`, its sessions and AI connectors included, and so are the API keys it made (`key_creator_suspended`). Nothing in it is deleted, and the refusal says where to ask why.

## Keys and connectors

- API keys can be read-only, limited to one project, or both (see API keys & authentication). Give each app the narrowest key that works, give keys an expiry date, and pause one you suspect has leaked while you replace it.
- An owner can require two-factor sign-in of every member of the workspace (see Workspaces → Requiring two-factor sign-in).
- AI connectors are connected read-only or read-and-manage; a read-only connector cannot change anything. Changes still ask you first in the assistant.
- No key or connector can change your email, password or sessions, accept invitations, or move money out — those need you in a browser.
- Revoke keys in Settings → API access and connectors in Dashboard → Connectors; both take effect immediately.

## How your data is kept apart

| Layer | Separation |
| --- | --- |
| Every API call | Your credential resolves to one workspace; resources are found through project → workspace, never by id alone. |
| Databases | Each database is its own SQLite database. SQL in one can never reach another. |
| Storage | Each bucket is its own bucket in object storage; a signed URL is for one key in one bucket. |
| Vector DB | One index per collection. |
| Memory | Rows in Harakumo's control database, keyed by store and scope; searches never cross scopes. |
| Functions | Each runs as its own isolated edge program with only its project's environment variables and no access to platform storage. |
| Static sites | Served by a Harakumo-written program that reads only its own deployment's files and keeps its own edge cache under a secret name. A site gets changes to this program, such as its own cache and its HTTPS redirect, when it is published again or when Harakumo refreshes every live site. |
| Server apps | Each deployment runs in its own container. |
| Auth pools | One per project, with its own signing secret. |
| Payments | Buyers pay into Harakumo's processor account; each project's share is tracked in a ledger, and withdrawals go only to the payout account the workspace owner connected. |
| Mail | Each workspace sends from no-reply@harakumo.com or from its own verified domains (a domain belongs to one workspace), with its own log and suppression list. |
| Domains | Each root domain has one owning workspace; no other workspace can add a hostname under it. |

## How secrets are stored

- Passwords (yours and your end users'): PBKDF2 hashes.
- Sessions, API keys, connector tokens and auth-pool client secrets: SHA-256 hashes; the plaintext is shown once.
- Two-factor recovery codes: hashed, and each works once.
- Environment variables, auth-pool JWT secrets and payment webhook secrets: kept, because they are injected into deploys and used to sign tokens and events. Never shown to viewers or read-only keys, and the AI connector never returns environment variable values or webhook secrets. Encrypting them with a key kept outside the database is being switched on; until it is, they are stored readable.
- An auth pool's JWT secret is returned once to whoever enables the pool (`POST /api/projects/:id/auth`), so the app can store it: a developer in the dashboard or a full-access API key. When an AI assistant enables the pool through the connector (`enable_auth` or `setup_app_backend`), the JWT secret and the client secret are stored as the project's environment variables `HARAKUMO_AUTH_JWT_SECRET` and `HARAKUMO_AUTH_CLIENT_SECRET` instead, and the assistant gets only their names. After that, developers and up, full-access API keys included, can show the JWT secret again (`GET /api/auth-pools/:id/secret`), and each reveal is audited.
- Authenticator secrets (two-factor): stored readable until sealing them with a separate key is switched on.

## Security checks

Your workspace's security checks are on Dashboard → Shield: a score, and what each check found in the workspace's own records. Shield → Security checks says what each one measures.

## Reference

**REST**

```http
POST /api/auth/login           { "email", "password" }        → session, or { mfaRequired, ticket }
POST /api/auth/login/mfa       { "ticket", "code" | "recoveryCode" }
GET|POST|PUT|PATCH|DELETE /api/auth/mfa   # status, set up, confirm, new recovery codes, turn off (browser session)
GET  /api/auth/sessions · DELETE                                # sign out other sessions
GET  /api/auth/me/export                                        # a copy of your personal data (browser session)
GET  /api/security/overview    # the workspace's security checks
```

---

# Billing, Balance & AI credits

A plan is bought by one workspace and can cover several of its owner's workspaces; each workspace has its own Balance. The plan decides what you can create and what is included each month, shared by the workspaces it covers; the paying workspace's Balance pays for AI and for use past it in every workspace it covers, and each workspace's Balance pays for the domains bought in it. Money your customers pay you through Payments is Earnings, held per project — it never pays Harakumo and the Balance never pays you.

## Plans

Limits & scaling lists every count and allowance. Plans limit how many things you create, and include an amount of file storage, database storage and build minutes (past it, Hobby stops and paid plans pay per unit from the Balance); bandwidth and function traffic have no plan quota yet.

Free limits are per person, across all your free workspaces. A paid plan's limits are shared across the workspaces it covers. Hobby is free: static sites, functions, databases, storage, sign-in, email and payments within its counts. Server apps are on Pro and up, videos and live channels on Pro and up, and AI on Hobby is paid from the Balance.

Enterprise is arranged with us, from $2,000 a month, and is not bought online (`POST /api/billing` and checkout answer 409 `contact_sales` with `contact_url`); workspaces already on Enterprise keep it, at the price they pay. A plan whose price is not set up yet answers 503 `price_missing` with `contact_url`: nothing is charged, and we set it up for you.

Every price is in US dollars and does not include taxes such as sales tax, VAT or GST.

|  | Hobby | Pro | Team | Scale |
| --- | --- | --- | --- | --- |
| Price (per subscription) | Free | $20 a month | $100 a month | $500 a month |
| Workspaces covered (limits shared) | Per person | Up to 3 | Up to 10 | Up to 25 |
| Usage credit | None | None | $20 a month | None |
| AI usage | Pay as you go from your balance | Pay as you go from your balance | Pay as you go from your balance | Pay as you go from your balance |
| Emails (rolling 30 days) | 100 | 2,000 | 10,000 | 100,000 |

## One plan, several workspaces

- One subscription covers several of the owner's workspaces: up to Pro 3, Team 10 and Scale 25. The workspace that buys it pays. Its owner adds another workspace they own in Billing → Workspaces, or `POST /api/billing/workspaces { workspace_id }` signed in to the dashboard, and takes one off with `DELETE /api/billing/workspaces/:id`. Both workspaces must be the caller's own; an AI connector gets 403 `dashboard_only`.
- A covered workspace is on the plan: the counts and usage of every workspace the plan covers add up against one allowance, so spreading work over more workspaces never adds to it. Team members stay per workspace.
- Use past the plan in any covered workspace is charged daily to the paying workspace's Balance, under its spending limit, after Team's $20 monthly usage credit; Billing → Workspaces shows which workspace used what (`usage` in `GET /api/billing/workspaces`).
- AI in any covered workspace is paid from the paying workspace's Balance too, under its spending limit and after any included AI the subscription keeps (Team's $20 usage credit does not pay AI); the paying workspace's `GET /api/billing/balance` splits this month's AI by workspace (`ai_usage.by_workspace`). In a covered workspace, `GET /api/billing/balance` answers with the paying workspace's balance and AI (`paid_by` names it; `own_balance_usd` is the covered workspace's own), and adding funds or changing auto-reload there goes to the paying workspace's Balance and card, for an owner of the paying workspace only (403 `payer_only` otherwise). Domains are paid from the Balance of the workspace that buys them. In a covered workspace that is its own Balance, under its own spending limit: with `own: true`, `POST /api/billing/balance` and `POST /api/billing/balance/checkout` add to it on its own card, and `PATCH /api/billing/limits` sets its own limit (`own_spend` in `GET /api/billing/limits` and `GET /api/billing/balance`), for an owner of the covered workspace. A domain refused there for a short Balance or a spending limit says so, and its `add_funds_url` or `limits_url` opens that own Balance or limit.
- A workspace taken off moves to Hobby at once (nothing is deleted), and its place stays held for 7 days. When the paying subscription ends, the workspaces it covers move to Hobby too; while it is unpaid they have Hobby limits, and they get the plan back once it is paid. Deleting the paying workspace moves them to Hobby.
- A covered workspace does not change its own plan (409 `covered_by_subscription`): the paying workspace's Billing page does. Moving to a plan that covers fewer workspaces than are on it is refused until some are taken off (409 `covers_too_many`).
- Existing subscriptions keep their plan and price: a subscription from before covers its own workspace, and a Pro owner can add up to 2 more. It also keeps what its plan included before, shared across the workspaces it covers (Pro: 100 GB of files, 10 GB of database storage, 5,000 emails a month, $10 of AI usage a month included and server apps with no hourly charge), until we move it to the plan as listed here; we email the owner first. Billing shows which a workspace has (`terms` in `GET /api/billing`).
- Use past the plan counts each workspace's build minutes and server-app awake hours only while it is covered: a workspace added during the month brings none of its earlier use, and one taken off keeps its use while it was covered in the month's total.

## Changing plan

- Only the owner changes the plan, signed in to the dashboard: Dashboard → Billing, or `POST /api/billing { plan }` from that session. A change charges or changes what the card on file pays, so an AI connector gets 403 `dashboard_only` with `dashboard_url` (Billing → Plan).
- An owner's AI connector can instead make a checkout link for a workspace without a paid plan (`POST /api/billing/checkout { plan }`): the person opens it in a browser and pays, and the plan changes once they have.
- Upgrading charges the prorated difference now; the plan changes once that payment succeeds.
- Downgrading between paid plans starts at the next renewal. Moving to Hobby ends the subscription at the end of the period; `POST /api/billing { action: "resume" }` (Keep plan) withdraws a pending cancel or downgrade.
- Nothing is deleted on a downgrade; creating more than the new plan allows is refused.
- Hobby does not include server apps, videos or live channels: new deployments of a server app, new video uploads and going live are refused there, and AI is paid from the balance.
- A missed subscription payment keeps the plan while the processor retries, with included AI paused (AI is then paid from the Balance); an unpaid subscription drops to Hobby limits until it is paid.
- If checkout says a plan's price is being updated, nothing was charged; try again later.

## Included AI usage

- Hobby, Pro, Team and Scale include no AI usage: every AI request is pay as you go from the Balance (on a paid plan, the Balance of the workspace that pays for the plan, for every workspace it covers, under that workspace's spending limit; on Hobby, the workspace's own), and on Hobby vendor models need the owner's verified email. Workspaces already on a plan that includes some keep it ($10 a month on a Pro subscription from before these plans and $100 a month on Enterprise). Team's $20 monthly usage credit pays for use past the plan, not for AI or domains, and what is not used does not carry over.
- It resets on the 1st at 00:00 UTC and does not carry over. It is used first; then the Balance pays.
- AI is priced at each model's per-token price: the model maker's list price, plus up to 5% on third-party models to cover buying that usage (edge models never carry it). `GET /api/ai/chat` lists the exact price of every model. Every answer reports its exact cost.
- Billing → Balance breaks this month's AI usage down by model: requests, tokens in and out, and spend for each, from the records the charges were made from — chat, embeddings, memory and vectors alike (`ai_usage.by_model` in `GET /api/billing/balance`). Charges recorded without a model, from before per-model recording and from agent runs, are one Unattributed row; each agent run shows its own model and cost.

## Domains

Domains are not part of any plan. Every purchase, renewal and transfer is paid from the Balance at the price shown first (or the card, when the owner asks signed in to the dashboard; AI connectors and API keys pay from the Balance), and there is no limit on how many you buy. Connecting a domain you already own is free, up to the plan's custom-domain limit.

## The Balance

- Prepaid dollars the owner loads from Dashboard → Billing → Balance: $10 to $1,000 at a time, with the card on file or through checkout (which saves the card). An owner's AI connector cannot charge the card for it; it makes a checkout link instead (`POST /api/billing/balance/checkout`), which the person opens and pays.
- It never expires and is not refundable. Deleting a workspace gives it up only after you confirm.
- It pays for every domain purchase, renewal and transfer in its workspace. On Pro, Team and Scale the paying workspace's Balance pays for AI and for use past the plan in every workspace the plan covers: file storage at $0.05 per GB a month, database storage at $1.00 per GB a month, build minutes at $0.01 per minute and server-app awake time at $0.05 per awake hour, charged daily, after Team's $20 monthly usage credit (which does not pay AI). On Hobby it pays for its own workspace's AI, and use past the plan stops at the plan's amounts instead.
- A card is charged only when the owner chose it, signed in to the dashboard: paying with the card for one purchase (`payWithCard: true`), auto-reload, or domain renewal card backup.
- Every movement — funds added, AI usage, usage past the plan, domains, refunds, plan invoices — is a transaction on the Billing page, with receipts for card charges.

## Spending limit and alerts

- Set a monthly spending limit ($1 to $100,000; 0 for none) in Billing → Limits & alerts or `PATCH /api/billing/limits { spendLimitCents }` (the owner, signed in to the dashboard; an AI connector gets 403 `dashboard_only`). It covers everything paid beyond the plan: AI beyond the included amount, domain purchases from the Balance or the card, and file storage, database storage, build minutes and server-app awake time past a paid plan. Past it, AI requests and purchases answer 402 `spend_limit_reached`; a usage charge that does not fit is left unpaid, and uploads, database writes that add data or deploys pause (402 `usage_paused`) until it is paid.
- Alerts (`alertLevels`, default 50, 80 and 100 percent) email the owner once per level per month, for the included AI amount and for the limit.
- Auto-reload charges the card a chosen amount when available AI credit drops below a threshold, at most 10 times a month. It fires only for requests from a signed-in person unless the owner sets `autoReloadForKeys: true`.
- In a workspace another workspace's plan covers, AI and use past the plan run under the paying workspace's limit, alerts and auto-reload: `GET /api/billing/limits` there shows them (with `paid_by`), and `PATCH` sets them, for an owner of the paying workspace only (403 `payer_only` otherwise). Domains bought in it stay under its own.
- That own limit is shown as `own_spend` and set with `PATCH /api/billing/limits { spendLimitCents, own: true }` (Billing → Limits & alerts, own spending limit), by an owner of the covered workspace, signed in to the dashboard.

**curl (owner session)**

```bash
PATCH /api/billing/limits
{ "spendLimitCents": 5000, "alertLevels": [50, 80, 100], "autoReloadForKeys": false }
```

## What needs the dashboard

Anything that charges the card on file or decides what it is charged needs the owner signed in to the dashboard. An owner's AI connector with read and write access gets these answers instead (a read-only one makes none of these changes either); the `dashboard_only` and `card_requires_owner` ones carry `dashboard_url`, the page to do it on:

- What works from an AI connector instead: paying from the Balance (domains, AI usage), and links the person opens and pays in a browser: `POST /api/billing/checkout { plan }` for a workspace without a paid plan, and `POST /api/billing/balance/checkout { cents }` to add funds. Both are the owner's.
- API keys never make billing changes. A full workspace key acts as a developer: billing changes answer it 403 for its role, and the domain card options `card_requires_owner`; a read key is told it is read-only, and a project key that it works on one project. An admin hears that their role does not allow a billing change, at a browser or through an AI connector, read-only or not.

| Call | The owner's AI connector gets |
| --- | --- |
| `POST /api/billing`: change, cancel or resume a paid plan | 403 `dashboard_only`, Billing → Plan |
| `POST /api/billing/portal`: invoices, the card, cancelling | 403 `dashboard_only`, Billing |
| `PATCH /api/billing/limits`: the spending limit, alerts, `autoReloadForKeys` | 403 `dashboard_only`, Billing → Limits & alerts |
| `POST /api/billing/balance`: add funds with the card on file | 403 `dashboard_only`, Billing → Balance |
| `PATCH /api/billing/balance`: switch auto-reload on (switching it off works) | 403 `dashboard_only`, Billing → Balance |
| `POST /api/billing/workspaces` or `DELETE /api/billing/workspaces/:id`: which workspaces a plan covers | 403 `dashboard_only`, Billing → Workspaces |
| `POST` or `DELETE /api/billing/payment-methods`: add or remove a card | 403 `payouts_require_owner` |
| `payWithCard`, `autoRenewCard` or `cardBackup` on a domain | `card_requires_owner` (see Domains), the Domains page |

## Seller earnings

Payments you take are Earnings on the project: each sale less the 3.9% + 30¢ fee, held 7 days, then withdrawn by the workspace owner. See Payments.

## When things reset

| What | Clock |
| --- | --- |
| Included AI usage | The 1st of each month, 00:00 UTC |
| Build minutes | The 1st of each month, 00:00 UTC |
| Server-app awake time and the usage credit | The 1st of each month, 00:00 UTC |
| Spending limit, alerts, auto-reload count | Calendar month (UTC) |
| Email quota | Rolling 30 days |
| Subscription | Monthly from the day you subscribed |
| Domain renewals | Each domain's own expiry date |
| Earnings hold | 7 days after each payment |

## Who can see and change billing

- Admins and owners can see billing; only the owner can change the plan, cards, Balance, auto-reload and limits, and the changes that charge the card or decide what it is charged need the owner signed in to the dashboard (What needs the dashboard).
- API keys see the AI credit summary in `GET /api/usage` (`aiCredits`) and cannot read or change billing. An owner's AI connector can read the Balance.

## Reference

**SDK**

```js
const { aiCredits } = await hk.usage.get();
// { balance_usd, available_usd, included_usd, included_used_usd, included_remaining_usd, resets_on, buy_url, auto_reload }
// Billing changes are made by the owner in the dashboard (an API key acts as developer).
```

**CLI**

```bash
harakumo balance      # Balance, included AI this month, what is available
harakumo usage
```

**REST**

```http
GET   /api/usage                       → aiCredits { balance_usd, available_usd, included_usd, included_remaining_usd, resets_on, buy_url }
GET   /api/billing                     # plan, subscription state, limits and usage (admin+)
POST  /api/billing                     { "plan": "hobby"|"pro"|"team"|"scale" } | { "action": "resume" }   (owner, dashboard)
POST  /api/billing/checkout            { "plan": "pro"|"team"|"scale" }   → { url }   (owner; from an AI connector, only without a paid plan)
GET   /api/billing/workspaces          # the workspaces this plan covers, places left, which workspace used what (admin+)
POST  /api/billing/workspaces          { "workspace_id": 12 }   (owner of both, dashboard)
DELETE /api/billing/workspaces/:id     # take one off: it moves to Hobby (owner, dashboard)
POST  /api/billing/portal              → { url }   (owner, dashboard)
GET   /api/billing/balance             # balance, credits, transactions, this month's AI usage by model (admin+)
POST  /api/billing/balance             { "cents": 2500 }   Idempotency-Key: <unique>   (owner, dashboard; card on file)
POST  /api/billing/balance/checkout    { "cents": 2500 }   → { url }
PATCH /api/billing/balance             { "autoReloadCents": 1000, "autoReloadBelowCents": 200 }
GET   /api/billing/limits · PATCH { "spendLimitCents"?, "alertLevels"?, "autoReloadForKeys"? }   (PATCH: owner, dashboard)
```

---

# Usage, activity & audit log

`GET /api/usage` reports counts against plan limits, the AI credit position, and measured usage for a period. Dashboard → Analytics shows the same data, with a list of what is not measured yet.

## Usage

- `?period=` month, last_month, 7d, 30d (default) or 90d, in whole UTC days; `?project=<id>` for one project.
- Returns `totals` per metric, a daily `series`, `byProject`, measured storage and database sizes (with when they were measured), deploy time and build-machine time, and `notMeasured`.
- Measured: resource counts, deploys and build time, database queries and rows read and written, storage bytes and object counts (daily), database sizes (daily), server-app running time, memory and vector operations, emails, end-user logins, checkout sessions, agent runs, AI requests, embeddings and AI cost.
- Not measured yet: requests and bandwidth to sites and apps, function-URL traffic, errors and latency.
- Function test runs (Invoke in the dashboard, the API or a connector) are their own metric, `function_test_runs`, never counted as traffic. In the 30-day `metrics`, `testRuns30d` and `totalTestRuns` count them; `invocations30d` and `totalInvocations` are null while function-URL traffic is not measured.

## What needs attention

`GET /api/attention` is a ranked to-do list: failed or stuck deploys, functions whose last deploy failed or stalled or that still run older environment variables, domains expiring or waiting on nameservers, failed renewals and transfers, refunds owed, plan limits and AI credit running low, a sign-in pool or vector collection near its plan's cap, failed mail and sending domains, and an unverified email. Each item links to the page that fixes it. The dashboard Overview shows it at the top; the AI connector's needs_attention tool reads it.

## Audit log

- Records changes: creates, deletes, settings, keys, members, money events. Admins read it in Dashboard → Audit Log (filter, export to CSV) or `GET /api/orgs/:id/audit-logs`.
- High-volume data calls (queries, memory writes, vector upserts, object deletes, sends, checkout creation, agent runs) are counted in usage instead of written one row each.
- `GET /api/activity` is the short recent feed on the Overview; non-admins do not see member, key, billing or account rows.

## Reference

**SDK**

```js
const usage = await hk.usage.get();   // counts, limits, aiCredits, metrics (30 days)
```

**CLI**

```bash
harakumo usage
```

**REST**

```http
GET /api/usage?period=30d&project=12
GET /api/attention
GET /api/activity
GET /api/orgs/:id/audit-logs?action=&before=&limit=     # admin
```

---

# AI connector (MCP)

The connector speaks the Model Context Protocol and signs you in with OAuth. You choose one workspace or all of them, and read-only or read-and-manage access. It then does what your role allows — nothing more — through the same API, with the same limits, audit rows and money checks. Its tools cover projects and deploys, repos, environment variables, databases, storage, functions, vector collections, memory, agents, domains and DNS, Shield and CDN settings, security checks, payments, sign-in, mail, usage and these docs, and your team: new workspaces, invitations, roles, and links to upgrade the plan or add funds.

It is tested with Claude. ChatGPT and other MCP clients speak the same protocol and the steps below connect them, but they are not verified yet.

## In plain words

Lets an AI assistant such as Claude work in your Harakumo account when you ask in everyday words, with only the rights your role has.

For example: You ask your assistant why your site did not update, and it reads the error and puts the fixed version live.

## Connect

| Client | How |
| --- | --- |
| Claude (web and desktop) | Settings → Connectors → Add custom connector → paste `https://harakumo.com/mcp` → sign in and choose the workspace and access. |
| Claude Code | `claude mcp add --transport http harakumo https://harakumo.com/mcp` |
| ChatGPT | Turn on developer mode, add an app with the URL `https://harakumo.com/mcp` and OAuth sign-in. Step by step in Connect ChatGPT below. |
| Other clients (Cursor …) | `{ "mcpServers": { "harakumo": { "url": "https://harakumo.com/mcp" } } }` |
| With an API key instead | Any client that can send a header, e.g. Claude Code: `claude mcp add --transport http harakumo https://harakumo.com/mcp --header "Authorization: Bearer hk_live_…"`. See Connect with an API key below. |

## Connect with an API key

A headless agent, or an AI tool you would rather not sign in to, can send an API key as a header instead of signing in. In Claude Code: `claude mcp add --transport http harakumo https://harakumo.com/mcp --header "Authorization: Bearer hk_live_…"`. In a client set up with JSON that takes headers (Cursor and others): `{ "mcpServers": { "harakumo": { "url": "https://harakumo.com/mcp", "headers": { "Authorization": "Bearer hk_live_…" } } } }`.

- One key covers one workspace. A full key gets the developer tools, a read key only the read tools, and a key limited to one project works on that project only. Make keys in Settings → API access.
- A key belongs to the workspace, not to you, so it never manages members, keys or billing and cannot create workspaces. For those, connect by signing in instead.
- Never paste a key into a chat message. It would stay in the chat history, and the connector does not read keys from messages anyway: it only uses the one in its settings.

## Connect ChatGPT

ChatGPT's search and deep research use two read-only tools, `search` and `fetch`. `search` looks through these docs and your workspace — projects, deployments, databases, functions, storage buckets and domains — and returns results with ids like `project:12`, `deployment:340`, `domain:example.com` or `doc:deployments`. `fetch` reads one in full: a deployment comes with its build log, a docs topic as markdown. Both see only what your role and the connection allow, like every other read tool.

1. In ChatGPT on the web, open Settings and turn on Developer mode. It is available on Plus, Pro, Business, Enterprise and Education plans; ChatGPT moves the switch between releases (it has lived under Apps & Connectors and under Security).
2. Add an app (a connector): name it Harakumo, set the server URL to `https://harakumo.com/mcp` and choose OAuth for authentication.
3. Sign in to Harakumo when ChatGPT asks, then choose the workspace and Read only or Read and manage.
4. In a chat, pick Developer mode from the + menu and switch Harakumo on. Name it in your request, e.g. "Use Harakumo to show my failed deploys".

> Tools that change something ask ChatGPT to confirm first. For a ChatGPT that can only look, pick Read only on the Harakumo sign-in screen.

## Access

- Read only: the assistant can look but not change anything. Read and manage: changes are allowed; tools that change or delete things are marked so the assistant asks you first.
- A connector acts with your own role, so an admin's connector also gets admin tools such as API keys, members and the audit log; an API key gets developer tools only. Money never leaves through a connector: withdrawals, refunds and payout setup are refused by design.
- A connection stays signed in and renews itself (the access token lasts 90 days and refreshes; unused for 180 days, it expires). Dashboard → Connectors lists every connection and cuts any of them off immediately.
- One workspace or all: the sign-in screen offers both, even if you belong to just one workspace today. All of them covers every workspace you belong to on each request, including ones you create or join later, and only that choice, with Read and manage, lets the assistant create workspaces.
- Sign-in secrets stay out of the chat. When the assistant adds sign-in to a project (`enable_auth` or `setup_app_backend`), the pool's client secret and JWT secret are stored as the project's environment variables `HARAKUMO_AUTH_CLIENT_SECRET` and `HARAKUMO_AUTH_JWT_SECRET`, and the assistant gets only their names. `list_env_vars` lists names and lengths, never values.
- An agent's app-events signing secret stays out of the chat too: when the assistant turns an agent's app events on or gives them a new secret (`update_agent` with `events`), the secret is stored as the project's environment variable `HARAKUMO_AGENT_<id>_EVENTS_SECRET`, for the app's server to sign events with, and the assistant gets only its name.

## Workspaces, team and plan from a chat

- `create_workspace` — a new workspace you own, on Hobby, with the same limits as the dashboard (how many you may own, a few a day). It needs a connection granted all of your workspaces; then `switch_organization` moves the connection into it. On a one-workspace connection, reconnect and choose all of them on the sign-in screen (it is offered even when you belong to only one).
- `invite_member` — invites someone by email as admin, developer or viewer, never owner. It sends an email, so the assistant confirms the address and role with you first. `list_invitations` and `revoke_invitation` manage the pending ones. Owners and admins only.
- `update_member_role` and `remove_member` — change a member's role to admin, developer or viewer, or remove them. Never your own role or membership, never an owner, and never to owner (transferring ownership stays in the dashboard). Removing someone lists the API keys they made, which keep working until revoked.
- `create_upgrade_link` and `create_add_funds_link` — the workspace owner gets a checkout page to open and pay; nothing is charged and no plan changes until you pay there. A plan that is already paid for is changed on the Billing page, because that charges the card on file at once.

## What stays in the dashboard

The connector has no tools for these, and `get_dashboard_link` gives the assistant the exact page to send you to: changing your password or email, two-factor sign-in, signed-in sessions, deleting your account or a workspace, downloading a copy of your personal data, transferring ownership, leaving a workspace, payment cards, auto-reload and requiring two-factor sign-in of every member (the assistant can read that rule with `get_workspace_security`). A connector is a long-lived token held by another app, and each of these could lock you out, carry off your sign-in history or decide what your card is charged.

Asked to make a billing change that charges the card on file or decides what it is charged, the API answers an owner's connector 403 `dashboard_only` (anyone else's is told their role does not allow it); asked to pay for a domain with the card, any connector gets `card_requires_owner`. Both carry `dashboard_url`, the page to send you to, and nothing is changed. Every such call and its answer is listed in Billing → What needs the dashboard.

## Sending email from your own domain

Six tools let an assistant set up a sending domain end to end: it adds the domain, gives you the two nameservers and the steps for your provider (its existing records are copied first), checks until the switch is seen, and sets the project's From address once the domain is verified. See Mail → Send from your own domain.

- `add_mail_domain` — start (or start again); returns the nameservers, numbered steps, provider hints and the copied records.
- `check_mail_domain` — check now: whether the nameservers switched, then each record found, missing or wrong.
- `list_mail_domains` and `get_mail_domain` — status, steps and every project's current From address.
- `set_mail_from` — a project's From address and Reply-To.
- `remove_mail_domain` — stop sending from a domain.
- Also: `host_domain_dns` hosts a bought domain's DNS here so every record is added for you, and `send_email` takes `from` and `replyTo`.

## New tools not showing?

Assistants remember a connector's tool list from when it was attached. After Harakumo adds tools, disconnect and reconnect the connector, then start a new conversation. The docs themselves are always live through get_docs.

## Try asking

- "What needs my attention?" — one call to needs_attention, most urgent first.
- "Build a landing page for my bakery and put it online."
- "Why did the last deploy fail?"
- "How much of my plan and AI credit is left?"
- "Buy fruitshop.com and connect it to the store project."
- "Set up fruitshop.com as a sending domain on Harakumo and walk me through it."
- "Create a workspace called Fruitly Labs and invite ana@example.com as a developer."

## Reference

**SDK**

```js
// Nothing to install — the connector is the interface.
```

**CLI**

```bash
claude mcp add --transport http harakumo https://harakumo.com/mcp
```

**REST**

```http
POST   /mcp                                        Authorization: Bearer hk_mcp_… or hk_live_…
GET    /.well-known/oauth-protected-resource/mcp   # discovery
GET    /api/mcp/connections                        # what is connected
DELETE /api/mcp/connections                        { "id": 1 }   # disconnect
```
