# Kisko Echo — service guide

Kisko Echo is a relay-friendly HTTP/WebSocket service for isolated echo spaces (token-scoped KV, WebSocket rooms, optional encryption, ephemeral file blobs) and projects (static site publishing on hostnames under a configurable base domain). It does not define how your clients should behave above the transport layer; that responsibility belongs entirely to implementations you ship.

This page describes how to use the service and what each surface expects. It does not include anything you only learn after creating a space or project (no room tokens, project ids, assigned `*.echo.kisko.dev` names, or API bearer values).

**Remote MCP:** If you are an agent or tool author integrating **Model Context Protocol** (Streamable HTTP) with Echo, read **[§ MCP (Model Context Protocol, remote)](#mcp-model-context-protocol-remote)** below. That section is enough to discover OAuth metadata, call `/mcp`, and use the built-in KV tools—no separate doc is required.

---

## Capability URLs, exposure, and lifetime

- Design: A space is addressed by a capability URL — the deployment base URL plus room token in paths (`/api/s/:token/…`, `/ws/s/:token`). Embedding `BASE_URL` and the room token in static client code, query strings, or invite links is expected and matches the core model: whoever holds the capability uses the room. That is not a bug or accidental leakage; isolation is hard-to-guess tokens plus short lifetime, not hidden server-side identity.
- Operator contrast: The project API bearer is different: it is not a capability URL and should stay out of shipped frontends; it manages projects and static uploads.
- Ephemeral spaces: Spaces are temporary. On this instance the default room TTL is 604800 seconds; when a token expires, it stops working and its KV/files are out of scope. Plan for recreation, rotation (`book` on projects), and no long-term durability of room state unless you add your own backing store.

---

## Global conventions

- CORS is permissive (`Access-Control-Allow-Origin: *`, broad methods and headers) so browser and non-browser clients can call APIs from any origin when they hold the right capability.
- Rate limits apply per client IP and per room token on REST and WebSocket upgrade paths.
- JSON is used for most REST bodies and responses unless a route documents otherwise.

---

## MCP (Model Context Protocol, remote)

Use this section to implement a **remote MCP client** (for example Cursor, Claude Desktop, or a custom agent) against Echo. Replace `BASE_URL` with this deployment’s origin (scheme + host, no trailing slash), e.g. `https://echo.kisko.dev`.

### What to configure

- **MCP server URL (Streamable HTTP):** `BASE_URL/mcp` — single endpoint for `POST` (JSON-RPC messages), `GET` (optional; this server returns `405` if you only need POST), and `DELETE` (session teardown when using `MCP-Session-Id`).
- **Discovery (OAuth 2.0, MCP authorization):**
  - Protected resource metadata: `GET BASE_URL/.well-known/oauth-protected-resource/mcp` — lists `resource` (the MCP URL), `authorization_servers`, `scopes_supported`.
  - Authorization server metadata: `GET BASE_URL/.well-known/oauth-authorization-server/oauth` — `issuer`, `authorization_endpoint`, `token_endpoint`, PKCE `code_challenge_methods_supported` (`S256`), `grant_types_supported` (`authorization_code`, `refresh_token`), `token_endpoint_auth_methods_supported` (`none` for public clients).

### Agent workflow (recommended order)

1. **Unauthenticated try:** `POST BASE_URL/mcp` with a JSON-RPC `initialize` request and header `Accept: application/json, text/event-stream` (and `MCP-Protocol-Version: 2025-11-25` once you standardize on a version). If the server returns **401**, read the `WWW-Authenticate` header: it includes `resource_metadata=...` pointing at the protected-resource document above.
2. **OAuth:** Follow **Authorization Code + PKCE** against the discovered `authorization_endpoint` and `token_endpoint`. Authorize with query params: `response_type=code`, `redirect_uri` (must be `https` or `http://localhost` / `http://127.0.0.1`), `code_challenge`, `code_challenge_method=S256`, optional `client_id` / `state` / `scope` (use `echo.full` if unsure). The browser step at `GET BASE_URL/oauth/authorize` performs **passkey** sign-in; then exchange the `code` at `POST BASE_URL/oauth/token` (`grant_type=authorization_code`, `code`, `redirect_uri`, `code_verifier`, optional `client_id`). **Access token** = Echo **IAM bearer** for that room (same secret as `Authorization: Bearer` on REST when IAM is enabled). Refresh: `grant_type=refresh_token` with `refresh_token` and optional `client_id`.
3. **Authenticated MCP:** Retry `initialize` with `Authorization: Bearer <access_token>`. On success, the response includes headers **`MCP-Session-Id`** and **`MCP-Protocol-Version`** — send **`MCP-Session-Id`** on subsequent `POST` requests to the same endpoint; send **`MCP-Protocol-Version`** on requests as required by your client. **`DELETE BASE_URL/mcp`** with `MCP-Session-Id` ends the session.
4. **Without OAuth (IAM off only):** If the space has **no** IAM, you may use **`Authorization: Bearer <room_token>`** where `<room_token>` is the same capability token as in `/api/s/:token/...`. This does not apply when IAM is enabled (then you must use the IAM bearer from passkey/OAuth or project IAM APIs).

### Tools exposed over MCP

**Two session kinds:** `initialize` chooses tools by credential type.

- **Space session** — `Authorization: Bearer` is a **room token** (or IAM access token from OAuth when IAM is on). `tools/list` returns **room** tools (`echo_kv_*`, `echo_file_*`, …) below.
- **Operator session** — `Authorization: Bearer` is the **deployment project API token** (same secret as `POST /api/projects` and `GET /api/projects/:id`; configured as `API_BEARER_TOKEN`). That token is resolved **before** room-token lookup, so it is never mistaken for a space. `tools/list` returns **project** tools: `echo_project_create`, `echo_project_get`, `echo_project_book` (rotate room token), `echo_project_add_space` (when multi-space), `echo_project_static_presign`, `echo_project_static_list`. Subsequent requests may use `MCP-Session-Id` as usual; the stored session remembers operator vs space.

After `initialize`, call `tools/list` then `tools/call`. **Room** tools mirror the **space REST API** and the **HTML room experience** (dashboard text, system prompt). **Encrypted KV:** `echo_kv_get` / `echo_kv_set` are not available for encrypted rooms over MCP (use REST with the encryption envelope, or disable encryption). Files, Web Push, and the system prompt still work when those subsystems are enabled.

| Tool | Maps to | Purpose |
| --- | --- | --- |
| `echo_space_info` | `/s/:token` dashboard facts | JSON: token, expiry, protocol, encryption flag, optional `encryption_key`, `websocket_url`, REST path prefixes. |
| `echo_system_prompt` | `GET /s/:token/prompt` | Full `<ECHO_PROMPT>` / contract text for the room. |
| `echo_kv_get` | `GET /api/s/:token/kv/:key` | Read KV value (blocked for encrypted-room KV; use REST). |
| `echo_kv_set` | `POST /api/s/:token/kv/:key` | Write KV (same encryption rule). |
| `echo_file_list` | file index | Lists file metadata for the room. |
| `echo_file_upload` | `POST /api/s/:token/files` | Upload with `data_base64`, optional `name`, `content_type`. |
| `echo_file_download` | `GET /api/s/:token/files/:file_id` | Returns metadata and `data_base64`. |
| `echo_push_vapid_public` | `GET …/push/vapid-public` | VAPID public key + subject for Web Push. |
| `echo_push_subscribe` | `POST …/push/subscribe` | Same body shape as REST (`subscription`, optional `uid` / `subs`). |
| `echo_push_unsubscribe` | `POST …/push/unsubscribe` | `endpoint` URL. |
| `echo_push_notify` | `POST …/push/notify` | `title`, `body`, optional `data`, `tuid`, `sub`. |

**Operator (project API bearer) tools:**

| Tool | Maps to | Purpose |
| --- | --- | --- |
| `echo_project_create` | `POST /api/projects` | Create a project (JSON body matches REST). |
| `echo_project_get` | `GET /api/projects/:id` | Status: metadata, `app_token`, spaces, `system_prompt`. |
| `echo_project_book` | `POST /api/projects/:id/book` | Rotate room token; optional `space_token` if multi-space. |
| `echo_project_add_space` | `POST /api/projects/:id/spaces` | Add another room when `multi_space` is enabled. |
| `echo_project_static_presign` | `POST …/static/presign` | Presigned upload URL for static files. |
| `echo_project_static_list` | `GET …/static` | List published static file metadata. |

**Session:** MCP accepts any IAM scope that matches at least one of: `kv_read`, `file_read`, `ws_read`, `kv_write`, `file_write`, `ws_write` (same rules as REST per tool). WebSocket live relay is not wrapped as an MCP tool—use `echo_space_info` for the `wss://…/ws/s/:token` URL.

### Transport and security notes

- **Origin:** The server validates the HTTP `Origin` header against the request host (DNS rebinding mitigation). For local development, operators may set **`MCP_RELAX_ORIGIN=true`** to relax checks.
- **Disable MCP entirely:** **`MCP_ENABLED=false`** — OAuth routes and well-known metadata return `503` for MCP features.

### Operator logging

Set **`LOG_MCP=true`** for structured logs with `event: mcp` and `event: oauth` (methods, outcomes, IP; **no raw tokens**).

---

## Clients, experiences, and responsibility

Echo exposes HTTP and WebSocket surfaces; it does not prescribe how you solve a problem, what your interface looks like, or which application-level protocol your clients use. Semantics are negotiated and implemented in your code. The service does not validate game rules, business logic, or who is allowed to do what in your app—that is entirely on the client (and any backends you add), including authentication and identity if you need them.

Experience first: aim for meaningful interaction for people, not for operating this service. Capability material (base URL, room token) may appear in shipped artifacts and links; spaces are temporary. This guide documents transport only, not UX or architecture choices.

Operator reference (`/service/description`, project `…/description`) is for tools and people deploying the system, not a template for end-user copy.

---

## Two different credentials: space token vs project API bearer

1. Space (room) token — Appears in paths such as `/api/s/:token/...` and `/ws/s/:token`. Anyone who knows `:token` can use that space’s KV, files, and WebSocket until the room expires (spaces are ephemeral). The token is a capability: it is intended to be embedded in clients, pasted into invite links, and shared with participants; exposure in that sense is by design, not a flaw. Still treat distribution as sensitive (anyone with the link has access until expiry).
2. Project API bearer — Project management (`/api/projects/...`) requires `Authorization: Bearer <token>`. Use the bearer token your administrator gives you; it is not a capability URL and is not meant to ship inside anonymous static sites. If this deployment does not offer project API access, those endpoints respond with `503` and `api_auth_not_configured`.

---

## Landing and echo spaces (browser-oriented)

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/` | Landing HTML to create a space, unless the request `Host` is a project static hostname (then static files are served). |
| `POST` | `/spaces` | Create a new space (form post). Some deployments require a password to create spaces; some use extra CSRF protection on this form only. |
| `GET` | `/s/:token` | HTML “room dashboard” for the space identified by `:token`. |
| `GET` | `/s/:token/prompt` | Plain-text system prompt for that space (copy/paste for tools). |

After `POST /spaces`, the client is redirected to `/s/:token`; that `:token` is the capability for the APIs below.

---

## Space-scoped REST (capability URL: `:token`)

All of these use the space token in the path — not the project API bearer.

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/api/s/:token/kv/:key` | Read KV value (format negotiable; see spec for protobuf vs JSON). |
| `POST` | `/api/s/:token/kv/:key` | Write KV value (body format matches room protocol / encryption rules). |
| `POST` | `/api/s/:token/files` | Multipart file upload into the space blob store. |
| `GET` | `/api/s/:token/files/:file_id` | Download a previously uploaded file. |
| `GET` | `/api/s/:token/push/vapid-public` | Web Push VAPID public key (when configured). |
| `POST` | `/api/s/:token/push/subscribe` | Register `PushSubscription` + optional `uid` (for direct routing) and `subs` (subspace names); IAM: `kv_write`. |
| `POST` | `/api/s/:token/push/unsubscribe` | Remove subscription by `endpoint`; IAM: `kv_write`. |
| `POST` | `/api/s/:token/push/notify` | Fan-out push (optional JSON `tuid`, `sub` for targeted/subspace routing); IAM: `ws_write`. |

KV (JSON): `POST …/kv/:key` expects JSON with a required `value` field that is a JSON string. The server does not accept an object, array, or number in place of `value`; stringify structured data yourself (for example `JSON.stringify` in JavaScript). Optional fields: `ttl_seconds`, `expires_at`, `encrypt`. A wrong shape returns `400` with `invalid_payload`.

Typical errors: `404` (unknown token or missing key/file), `413` (payload too large), `429` (rate limited), `400` family for invalid key/payload/encryption.

---

## WebSocket room

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/ws/s/:token` | Upgrade to WebSocket for real-time echo traffic for the space `:token`. |

Optional query parameters may be accepted (e.g. `tz`, `loc`) for telemetry; see implementation.

Origin policy (instance-dependent): WebSocket upgrades do not require a matching `Origin` header.

---

## Project API (requires `Authorization: Bearer …`)

Header: `Authorization: Bearer <token>` (the value your administrator issued).

| Method | Path | Purpose |
| --- | --- | --- |
| `POST` | `/api/projects` | Create a project (JSON body: `name`, optional `app_name`, `domain`, `encryption_enabled`, `protocol`, `system_prompt`, …). Returns 201 with `project_id`, assigned `domain`, `app_token`, `system_prompt`, etc. |
| `GET` | `/api/projects/:project_id` | Fetch project metadata and effective `system_prompt`. |
| `GET` | `/api/projects/:project_id/description` | Markdown reference for that project (includes secrets for that project; still requires bearer). |
| `POST` | `/api/projects/:project_id/book` | Rotate the project’s room token (`app_token`). |
| `PUT` | `/api/projects/:project_id/prompt` | Set custom `system_prompt` JSON field. |
| `GET` | `/api/projects/:project_id/static` | List published static objects. |
| `POST` | `/api/projects/:project_id/static/presign` | Request a direct upload URL when supported (presigned URL flow). |
| `PUT` | `/api/projects/:project_id/static/*path` | Upload bytes through Echo (proxy). |
| `DELETE` | `/api/projects/:project_id/static/*path` | Delete an object. |

---

## Web Push (browser notifications; optional)

**Space (capability token)** vs **project id:** Subscriptions under `/api/s/:token/push/...` are keyed by `token` (same room as WebSocket and KV). Clients with the space token can call `POST .../push/notify` without the project API bearer; routing matches WebSocket semantics: omit `tuid`/`sub` for room-wide broadcast, set `sub` for subspace-only broadcast, or set `tuid` (and optional `sub`) for a direct push to one browser `uid` registered at subscribe time. **Project** routes under `/api/projects/:project_id/push/...` store subscriptions per project id and require the bearer for `notify` only.

| Method | Path | Auth | Purpose |
| --- | --- | --- | --- |
| `GET` | `/api/projects/:project_id/push/vapid-public` | None | JSON: `vapid_public_key`, `subject` (for `pushManager.subscribe`). `503` if Web Push is not configured. |
| `POST` | `/api/projects/:project_id/push/subscribe` | None (rate-limited by IP) | JSON body: either `{"subscription": { … }}` (browser `PushSubscription`) or a flat subscription object with `endpoint` (and keys). Requires valid `project_id`. |
| `POST` | `/api/projects/:project_id/push/unsubscribe` | None | JSON: `{"endpoint": "<subscription endpoint URL>"}` removes that subscription from storage. |
| `POST` | `/api/projects/:project_id/push/notify` | `Authorization: Bearer …` | JSON: `title`, `body`, optional `data` — server encodes and sends to **all** stored subscriptions for the project; response includes counts (e.g. `sent`, `attempted`). `503` if Web Push is not configured. |

Project room token (`app_token`): After creation or booking, use `app_token` as `:token` in `/ws/s/:token` and space-style APIs if you attach workflows to that room — it is a space token for that project’s backing room.

Published site: Static assets are served at `https://<assigned-project-hostname>/…` where hostnames are normally under `echo.kisko.dev`. Root document is typically `index.html`.

Direct upload URLs: On this deployment you can often obtain a short-lived upload URL via `POST .../static/presign`. If that fails, the response may indicate storage is not ready for that flow. For the presigned `PUT`, send the `Content-Type` header **exactly** as returned in the JSON `headers` entry (same bytes the server signed). A different value, a missing header, or a tool default such as `curl -T` without matching `-H` produces AWS `SignatureDoesNotMatch` (403).

---

## Maintaining a static site project (suggested layout)

For a codebase that publishes here, keeping scripts beside the project (for example under `bin/`) is often easier to maintain than ad hoc commands. A typical pattern is `bin/deploy` that creates the Echo project when it does not exist yet, then uploads your static tree (for example everything under `assets/`) using `PUT .../static/<path>` or a presign flow, depending on this deployment.

After the first successful `POST /api/projects`, write the returned `project_id` into a `.echo-project` file at the repository root. Later runs can read that id so you do not create duplicate projects. Storing the API bearer token alongside the project (for example in a gitignored file or only in the environment) is a common approach; treat it like any other secret.

When uploading static files, exclude the tooling itself from what gets published: skip `bin/` and the deploy script (and similar paths) so those files are never uploaded as site objects. It also helps to skip any path matched by `.gitignore` (or an equivalent ignore list) so secrets, build artifacts, and other non-site files stay out of the static tree.

---

## Limits and behavior on this instance

Values below reflect this running deployment (your administrator may run other settings elsewhere).

| Topic | On this instance |
| --- | --- |
| Project site hostname suffix | `echo.kisko.dev` |
| Static file hosting | supports direct upload URLs when you use `POST .../static/presign` |
| Default room lifetime (seconds) | 604800 |
| Landing form extra protection | standard |
| Max KV value size (approx., bytes) | 262144 |
| Max REST body size (approx., bytes) | 294912 |
| Max single space file upload (bytes) | 10485760 |
| Max total space file storage (bytes) | 104857600 |
| Web Push | not configured (`503` on push routes until operator sets VAPID keys) |

---

## Health

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/healthz` | JSON `{"status":"ok","service":"kisko-echo"}`. |

---

## This document

| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/service/description` | This page (`text/markdown`). No authentication. Includes **remote MCP** instructions (discovery, OAuth, tools) so agents can integrate from this URL alone. |

Low-level protocol details and deployment topics are outside this guide; ask whoever operates this instance if you need them.