# Platform spec for AI assistants

This page is written for the AI tool (Claude, ChatGPT, Cursor, Copilot, ...) that writes an app for this platform. It is the complete contract. If something is not listed here, it is not available.

The platform runs small web apps and automations. One JavaScript file is a complete app. The platform provides hosting, login, a SQL database, key-value storage, file storage, background jobs and schedules. All of it is reached through one object called `ctx`.

## 1. The project contract

Minimal project (single file):

```
my-app/
  app.js            # the whole app:  export default { fetch, jobs }
  migrations/       # optional, only when the database is used
    0001_init.sql
  platform.json     # optional; "platform init" writes it
```

Folder layout for larger apps (same rules, more files are allowed later; today everything must be in the entry file):

```
my-app/
  platform.json
  src/index.js      # entry file, same shape as app.js
  migrations/
```

`platform.json`:

```json
{
  "name": "orders-portal",
  "entry": "app.js",
  "features": { "auth": true, "db": true, "files": false, "jobs": true, "kv": false, "mcp": false },
  "mcp_access": "users",
  "migrations": "migrations",
  "schedules": [{ "job": "sync-orders", "cron": "*/15 * * * *" }],
  "webhook_paths": ["/webhooks/shop-order"],
  "env": { "SUPPORT_EMAIL": "help@example.com" }
}
```

- `name`: letters, digits, dashes. Becomes the hostname `name.<platform domain>`.
- `entry`: the file with `export default`.
- `features`: which building blocks are switched on. Using a block that is off throws `"<Block> is not enabled for this project"`. `mcp` turns the app into an MCP server (section 2b).
- `mcp_access`: `"public"` (anyone with the URL can call the tools) or `"users"` (callers must log in; tools see `ctx.user`). Only used with `"mcp": true`.
- `schedules`: cron, five fields, UTC; up to 20 entries, each naming a unique key of `jobs`. Requires Jobs.
- `webhook_paths`: exact POST paths under `/webhooks/` that bypass interactive login. Verify provider signatures inside these handlers. All other routes retain the app access policy.
- The CLI applies provided configuration before every deploy. An `env` object replaces all plain variables; omitting it preserves them. Omitted access policy, schedules and webhook paths are preserved; use explicit values to change or clear them.
- `env`: plain configuration values, visible in code as `ctx.env.NAME`. Secrets (API keys, webhook secrets) are set in the dashboard, never in this file or in code, and also appear as `ctx.env.NAME`.

### The entry file

```js
export default {
  // HTTP. Standard Request in, standard Response out.
  async fetch(req, ctx) {
    const url = new URL(req.url);
    if (url.pathname === '/api/items') return Response.json(await ctx.db.query('SELECT * FROM items'));
    return new Response('Hello', { headers: { 'content-type': 'text/html' } });
  },
  // Background work. Each key is a job name. Jobs run from a queue, a schedule or an authenticated application route that enqueues work.
  jobs: {
    async 'send-invoice'(ctx, input) { /* input is whatever was passed to ctx.jobs.enqueue */ },
  },
};
```

Rules:
- Exactly one `export default` with an object. `fetch` is optional for job-only apps and MCP-only servers; `jobs` and `tools` are optional.
- ES modules, modern JavaScript (ES2023). Top-level `await` is not allowed. No `require`, no `import` of npm packages or other files.
- Routing is done by hand with `new URL(req.url).pathname` and `req.method`. Keep a `return new Response('Not found', { status: 404 })` at the end.
- Everything the app needs from the outside world comes through `ctx` or the standard web APIs (`fetch`, `Request`, `Response`, `Headers`, `URL`, `FormData`, `crypto`, `TextEncoder`, `atob`/`btoa`, `structuredClone`, `setTimeout`).

## 2. The `ctx` API

TypeScript shape (authoritative):

```ts
interface PlatformUser { id: string; email: string; roles: string[] }
interface Ctx {
  user: PlatformUser | null;
  env: Record<string, string>;
  db: { query<T = any>(sql: string, ...params: unknown[]): Promise<T[]>;
        exec(sql: string, ...params: unknown[]): Promise<{ changes: number }> };
  kv: { get(k: string): Promise<string | null>; put(k: string, v: string, ttlSeconds?: number): Promise<void>; delete(k: string): Promise<void> };
  files: { put(key: string, body: BodyInit, contentType?: string): Promise<void>; get(key: string): Promise<Response | null>;
           url(key: string): string; delete(key: string): Promise<void> };
  jobs: { enqueue(name: string, input?: unknown, opts?: { delaySeconds?: number; idempotencyKey?: string }): Promise<void> };
  log: { info(...a: unknown[]): void; warn(...a: unknown[]): void; error(...a: unknown[]): void };
  auth: { require(role?: string): PlatformUser };
  waitUntil(p: Promise<unknown>): void;
}
```

### `ctx.user` and `ctx.auth` (feature `auth`)

The platform hosts the login pages and sessions. Code never sees passwords or tokens.

```js
ctx.user                      // { id, email, roles } or null when nobody is logged in
const user = ctx.auth.require();        // throws -> platform answers 401 / redirects to login
const admin = ctx.auth.require('admin'); // throws -> 403 when the role is missing
```

- Only invited users may sign in. Removing a user or changing roles immediately invalidates existing sessions and tokens; the user must sign in again.
- Roles are plain strings assigned in the dashboard (for example `admin`, `staff`). Check them with `user.roles.includes('admin')`.
- In jobs `ctx.user` is always `null`. Pass the user's email or id in the job input when a job needs it.
- Own the data per user by storing `ctx.user.id` or `ctx.user.email` in a column and filtering on it.

### `ctx.db` (feature `db`) - SQL database

SQLite dialect. One database per project environment.

```js
const rows = await ctx.db.query('SELECT * FROM orders WHERE email = ? ORDER BY id DESC', user.email); // array of plain objects
const [one] = await ctx.db.query('SELECT * FROM orders WHERE id = ?', id);                             // undefined when missing
const r = await ctx.db.exec('INSERT INTO orders (email, total) VALUES (?, ?)', email, total);         // { changes: 1 }
await ctx.db.exec('UPDATE orders SET status = ? WHERE id = ?', 'paid', id);
```

- Always use `?` placeholders. Never build SQL with string concatenation.
- `query` returns rows; `exec` returns the number of changed rows. For `INSERT ... RETURNING id` use `query`.
- One statement per call. No transactions API; design writes to be safe to repeat.
- Schema lives in `migrations/NNNN_name.sql`, applied in file-name order on deploy. Add a new file for each change; never edit an applied file. Use `TEXT` timestamps with `datetime('now')`, integers for money (cents), `INTEGER PRIMARY KEY` for ids.

### `ctx.kv` (feature `kv`) - key-value store

Strings only, eventually consistent, good for settings, caches, counters that may be slightly off.

```js
await ctx.kv.put('rates:eur', JSON.stringify(rates), 3600); // optional TTL in seconds
const cached = await ctx.kv.get('rates:eur');                // string or null
await ctx.kv.delete('rates:eur');
```

Not for data that must be exact or listed: use the database for that. Values are limited to 64 KiB, keys to 512 characters and TTL to 60–31536000 seconds.

### `ctx.files` (feature `files`) - file storage

```js
await ctx.files.put('reports/2026-10-22.csv', csvString, 'text/csv');         // body: string, ArrayBuffer, Blob, ReadableStream
await ctx.files.put(`uploads/${crypto.randomUUID()}.png`, req.body, req.headers.get('content-type'));
const res = await ctx.files.get('reports/2026-10-22.csv');                     // Response or null; return it directly from fetch
const href = ctx.files.url('reports/2026-10-22.csv');                          // URL served by the platform
await ctx.files.delete('reports/2026-10-22.csv');
```

Uploads are limited to 25 MiB. Keys are paths without a leading slash. Writing the same key again replaces the file. There is no list operation yet: keep a table of file keys in the database when you need to list them.

The generic `/_files/` route follows the app-wide login policy. It does not check ownership of individual files. For per-user private files, store the key under `private/` (blocked by the generic file route), implement an ownership check in an app route and use `ctx.files.get`; do not expose those keys through the generic file route. File responses have sandbox CSP and private/no-store caching.

### `ctx.jobs` and `jobs` (feature `jobs`) - background work

```js
// In fetch: hand off slow work and answer immediately
await ctx.jobs.enqueue('send-invoice', { orderId: 42 });
await ctx.jobs.enqueue('send-reminder', { orderId: 42 }, { delaySeconds: 3600 });

// In the export: the job itself
jobs: {
  async 'send-invoice'(ctx, { orderId }) { ... },
}
```

- A job receives `(ctx, input)`, with `{}` for a schedule and `ctx.user === null`.
- Enqueue persists a durable job before attempting queue delivery. The scheduler recovers failed sends. An `idempotencyKey` (max 200 characters) deduplicates enqueue calls for the lifetime of that job record. Use the same key on each retry of one webhook delivery.
- Delivery is at least once. Await work in the job handler. Throw to retry with exponential backoff, up to five attempts. Unknown jobs fail and remain visible. Inspect/retry failed jobs in the dashboard; there is no generic manual Run button.
- Side effects still need provider idempotency or upsert; enqueue deduplication alone cannot make external writes exactly once.
- Delay: 0–86400 seconds. The JSON envelope is limited to 64 KiB. Cron catches up missed occurrences over subsequent ticks. Local CLI dev does not emulate the hosted durable outbox or scheduler.

### Outside APIs

Managed Connections are not available yet. Store a provider token as an app secret and use standard `fetch`:

```js
const res = await fetch('https://api.hubapi.com/crm/v3/objects/deals', {
  method: 'POST',
  headers: { authorization: `Bearer ${ctx.env.HUBSPOT_TOKEN}`, 'content-type': 'application/json' },
  body: JSON.stringify({ properties: { dealname: 'Order 42' } }),
});
if (!res.ok) throw new Error(`HubSpot ${res.status}`);
```

Never hardcode credentials or log secrets. This does not provide managed OAuth refresh, scopes or a connection vault.

### `ctx.env` - configuration and secrets

`ctx.env.NAME` is a string. Values come from `platform.json` `env` (plain) and from the project's Environment and secrets panel (secret). Treat a missing value as a configuration error with a clear message.

### `ctx.log`

`ctx.log.info(...)`, `warn`, `error`. Goes to Worker logs; project log search in the dashboard is not available yet. `console.log` works too.

### `ctx.waitUntil(promise)`

Lets a promise finish after the response was sent (for example a log write). Do not use it for important work; use a job.

## 2b. MCP servers (feature `mcp`)

An MCP server is a menu of actions an AI assistant (Claude, ChatGPT, Cursor, ...) may use on behalf of a person. On this platform an MCP server is the same one file, with a `tools` object next to `fetch` and `jobs`. The platform serves the MCP protocol (Streamable HTTP, JSON-RPC 2.0) at `https://<name>.<platform domain>/mcp`. Code never touches the protocol.

```js
export default {
  tools: {
    book_session: {
      description: 'Book a coaching session on a given day. Use when the client asks for a slot.',
      input: { type: 'object', properties: { date: { type: 'string', description: 'YYYY-MM-DD' } }, required: ['date'] },
      async run(input, ctx) {
        const user = ctx.auth.require();          // who is calling; throws 401 when nobody is logged in
        await ctx.db.exec('INSERT INTO bookings (email, date) VALUES (?, ?)', user.email, input.date);
        return `Booked ${input.date} for ${user.email}`;
      },
    },
  },
  resources: {                                    // optional, read-only documents
    programme: { description: 'The 12-week programme', mimeType: 'text/markdown', async read(ctx) { return '# Week 1 ...'; } },
  },
  prompts: {                                      // optional, reusable conversation starters
    weekly_checkin: { description: 'Weekly check-in', args: [{ name: 'week', required: true }], async get(args, ctx) { return `Ask me about week ${args.week}`; } },
  },
  async fetch(req, ctx) { return new Response('Connect your assistant to /mcp'); },   // optional
};
```

Tools:
- Key = tool name: letters, digits, `_` or `-`, max 64 characters. Pick verbs: `order_status`, `log_checkin`.
- `description`: written for the assistant, not for a human. Say what the tool does, when to use it, and what it returns. One to three sentences.
- `input`: a JSON Schema object (`type: 'object'`, `properties`, `required`). Describe each property. The platform passes the parsed arguments as `input`; still check values in code.
- `run(input, ctx)`: same `ctx` as `fetch`. Return a string (shown as text), a plain object or array (sent as JSON text), or `{ content: [...] }` in MCP format for images and mixed content. Throwing an `Error` becomes a tool error with the message; write messages the assistant can act on ("No order 1042 for that email").
- Tools may be called more than once with the same input. Make writes safe to repeat (look up before insert, use unique keys).

Resources: `read(ctx)` returns a string (or `{ content, mimeType }`). Prompts: `args` is a list of `{ name, description?, required? }`; `get(args, ctx)` returns a string or an array of `{ role, content }` messages.

Who is calling:
- `mcp_access: "public"`: anyone with the URL. `ctx.user` is `null`. Never expose private data; ask for a matching email or order number inside the tool and return only what matches.
- `mcp_access: "users"`: the platform handles login for the assistant (OAuth with an email link, or a personal token from `/_auth/tokens`). `ctx.user` is `{ id, email, roles }`. Use `ctx.auth.require()` in tools that need a person and `ctx.auth.require('admin')` for owner-only tools. Store `user.email` or `user.id` in rows and filter on it.
- `fetch` and `jobs` work as before next to `tools`; `fetch` is a good place for a one-page "how to connect" explanation.

`platform.json`: `"features": { "mcp": true, ... }` and `"mcp_access": "public" | "users"`.

Connecting an assistant (the dashboard shows the exact URL):
- Claude.ai / Claude Desktop: Settings > Connectors > Add custom connector > `https://<name>.<platform domain>/mcp`
- Claude Code: `claude mcp add --transport http <name> https://<name>.<platform domain>/mcp`
- Cursor and others (`mcp.json`): `{ "mcpServers": { "<name>": { "url": "https://<name>.<platform domain>/mcp" } } }`
- Any client with a bearer token: header `Authorization: Bearer <token>`

Testing: the dashboard MCP console lists and calls tools. For private servers, supply an app personal token; platform-owner login does not grant an app identity. Fix descriptions until the assistant picks the right tool on its own.

## 3. What is NOT available

- No Node.js APIs: no `fs`, `path`, `process`, `Buffer`, `http`, `child_process`, `os`, `crypto` from Node (use the Web Crypto `crypto.subtle`), no `__dirname`.
- No filesystem. Files go through `ctx.files`.
- No npm packages, no `import`/`require` of anything. No Express, Koa, Fastify, Prisma, Knex, Sequelize, axios, lodash, moment, dotenv.
- No long-running processes, no `setInterval` loops, no WebSockets, no listening on ports. The app is invoked per request and per job.
- No in-memory state between requests. Module-level variables may vanish at any time. Use the database or `kv`.
- Limits: router CPU cap 50 ms per request, job CPU cap 30 seconds, 50 subrequests. Provider runtime limits also apply. Split heavier work into jobs; await external calls and use timeouts. Source uploads are limited to 1 MiB.
- No multi-file projects yet: everything in the one entry file. No TypeScript at the moment; write plain JavaScript.
- No transactions API on the database, no `LISTEN/NOTIFY`, no stored procedures. SQLite dialect only.
- No managed Connections, customer custom domains, automatic backups or project log search yet. No email, PDF or AI helpers yet. Use a provider API with `fetch` and a secret.

## 4. Common mistakes when porting Express / Flask / Next.js code

| Express / Flask habit | Do this instead |
| --- | --- |
| `const app = express(); app.get('/x', ...)` | `if (url.pathname === '/x' && req.method === 'GET')` inside `fetch` |
| `app.listen(3000)` | Nothing. The platform calls `fetch`. |
| `req.params.id` | `const m = url.pathname.match(/^\/orders\/(\d+)$/)` |
| `req.query.page` | `url.searchParams.get('page')` |
| `req.body` (JSON) | `await req.json()` |
| `req.body` (form) | `const form = await req.formData(); form.get('email')` |
| `res.json(data)` / `res.send(html)` | `return Response.json(data)` / `return new Response(html, { headers: { 'content-type': 'text/html' } })` |
| `res.status(404).send()` | `return new Response('Not found', { status: 404 })` |
| `res.redirect('/')` | `return Response.redirect(new URL('/', req.url), 303)` |
| `res.cookie(...)` / sessions / passport / JWT | Delete it. `ctx.user` and `ctx.auth.require()` |
| `process.env.X` / `.env` / `dotenv` | `ctx.env.X` |
| `fs.writeFileSync('out.csv')` | `await ctx.files.put('out.csv', csv, 'text/csv')` |
| `new Pool()` / `sqlite3` / Prisma | `ctx.db.query(sql, ...params)` with `?` placeholders |
| `axios.post(url, body, { headers: { Authorization } })` | `fetch(url, { method: 'POST', body, headers: { Authorization: 'Bearer ' + ctx.env.API_TOKEN } })` |
| `setTimeout(work, 60000)` / cron libraries / `setInterval` | `ctx.jobs.enqueue(name, input, { delaySeconds })` and `schedules` in `platform.json` |
| `Buffer.from(x).toString('base64')` | `btoa(...)` or `TextEncoder` + manual base64 |
| `crypto.createHmac('sha256', key)` | `crypto.subtle.importKey` + `crypto.subtle.sign('HMAC', ...)` |
| Global cache object `let cache = {}` | `ctx.kv` with a TTL |
| `module.exports = ...` / `require()` | `export default { ... }` only |

## 5. Checklist before handing the app over

- [ ] One file, `export default { fetch, jobs }`, no imports, no `app.listen`.
- [ ] Every route returns a `Response`; a 404 at the end.
- [ ] Pages that need a user call `ctx.auth.require()`; admin actions call `ctx.auth.require('admin')`.
- [ ] All SQL uses `?` placeholders. All tables exist in `migrations/NNNN_name.sql`.
- [ ] Jobs are listed under `jobs`, take `(ctx, input)`, and are safe to run twice.
- [ ] MCP tools have a JSON Schema `input`, a description written for the assistant, and are safe to call twice. Private data only behind `ctx.auth.require()` or a matching email/order check.
- [ ] Webhook routes store + enqueue + return 202; no outside API calls inside the webhook request.
- [ ] Outside APIs use `fetch` with credentials from `ctx.env`; no hardcoded keys or tokens.
- [ ] Configuration through `ctx.env`; names of required secrets written in a comment at the top.
- [ ] Files through `ctx.files`; no Node modules, no filesystem, no in-memory state.
- [ ] `platform.json` lists the features that are used (including `mcp`), `mcp_access`, webhook paths and schedules.
- [ ] Short comments in plain words so the owner can read the file.

## 6. Deploying

```
npx @platform/cli init      # or: platform init   -> writes platform.json
platform dev                # local run with a local database, --as me@example.com:admin to fake a login
platform deploy             # uploads app.js + migrations, prints the URL
platform versions / platform rollback <n>
```

Or paste the file in the dashboard ("New project > Paste a file"). The dashboard also accepts SQL migration files. The CLI additionally synchronizes `platform.json`, including schedules and webhook paths. App feature/access changes can restrict live traffic immediately; new bindings and code require deploy.

## 7. Complete examples

- `examples/hello` - the smallest app.
- `examples/customer-portal` - login, per-user rows, admin role, nightly job.
- `examples/webhook-to-crm` - webhook in, store, job calls HubSpot with an app secret and stable enqueue key; configure a unique provider property for upsert.
- `examples/scheduled-report` - nightly aggregation to a CSV in file storage, manual trigger.
- `examples/coach-mcp` - MCP server for a coach: programme, check-ins, progress, bookings, login required, admin tool.
- `examples/shop-mcp` - public MCP server for a webshop: order status, returns, product FAQ, opening hours.

Each folder has a `PROMPT.md` with variations to ask an AI for.
