# Agent Guide for teach-server

This document describes how an HTTP-capable agent (e.g. **Claude Code** desktop
or CLI) interacts with a teach-server deployment to upload single-HTML pages
for invited users. Claude Code is the recommended client because it has the
`WebFetch`, `Bash`, and `Write` tools needed for the full flow; Claude Cowork
does not currently expose those, so its agents cannot complete the bootstrap.

## Bootstrap (first run on a user's machine)

1. Ask the user for their teach-server base URL (e.g. `https://tmuh.ai`) and
   their invited email. Confirm both before proceeding.
2. Ask the user for their password **in-memory only**. Never `Write` it to
   disk, never echo it in a Bash command, never include it in a later message.
3. `GET /auth/salt?email=<email>` (use `WebFetch` or `curl`).
4. Compute `hash = SHA256(password + salt)` as lowercase hex (64 chars). In
   Claude Code: `echo -n "$PW$SALT" | sha256sum` inside a Bash call where
   `$PW` is the in-memory password — do **not** write the password to a file
   first.
5. `POST /auth/login` with `{ email, hash }`; receive `{ api_key, username }`.
6. Write `<workdir>/.teach-server/credentials.json` and `chmod 600`:

   ```json
   {
     "base_url": "https://tmuh.ai",
     "username": "alice",
     "api_key": "tk_xxxxxx…"
   }
   ```

7. `GET <base_url>/llms.txt` and write it to
   `<workdir>/.teach-server/agent-guide.md` for self-reference.

## Uploading a page

The agent accepts the following inputs from the user:

- `slug`: URL segment, `/^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$/`.
- `title`: optional human-friendly title.
- `html`: full HTML file (with inline CSS/JS), up to 5 MB.
- `has_ai` (bool): does this page call the AI proxy? If true:
  - `ai_password`: 1–8 alphanumeric characters. The agent MUST explain to
    the user that this password will be required from anyone who wants to
    use the AI feature on the page. The password cannot be viewed later,
    only reset.

Request:

```
POST /api/pages
Authorization: Bearer <api_key>
Content-Type: application/json

{ slug, title, html, has_ai, ai_password }
```

Response: `{ url, updated_at }`. Tell the user both pieces.

## Error handling

Errors follow the shape:

```json
{ "error": { "code": "kebab-case", "message": "...", "http": 401 } }
```

Common codes:

| Code                  | Action                                                    |
|-----------------------|-----------------------------------------------------------|
| `not-invited`         | Ask user to contact an admin for an invitation link.      |
| `invite-not-redeemed` | User must open the invite URL in a browser first.         |
| `invalid-credentials` | Password wrong — re-prompt, don't retry automatically.    |
| `invalid-api-key`     | `credentials.json` stale; re-run login flow.              |
| `payload-too-large`   | HTML > 5 MB; ask user to slim or remove embedded assets.  |
| `daily-limit-exceeded`| Quota hit; surface the info rather than retry.            |
| `slug-taken`          | Rare race; retry with same slug (overwrites).             |

## Building AI-enabled pages

**Allowed models (strict allowlist — any other ID returns 400 `unsupported-model`):**

| Model              | Provider |
|--------------------|----------|
| `gpt-4.1-mini`     | OpenAI   |
| `gpt-4o-mini`      | OpenAI   |
| `gpt-5-nano`       | OpenAI   |
| `gemini-2.5-flash` | Google (also accepts `models/gemini-2.5-flash`) |

Dated variants (e.g. `gpt-4o-mini-2024-07-18`) are **not** accepted. Canonical bare IDs only.

When generating an HTML page that calls AI, include the following snippet
(or equivalent) in the page so viewers can enter the password:

```javascript
async function askAI(messages, model = 'gpt-4o-mini') {
  const pw = sessionStorage.getItem('pw') || prompt('AI password for this page:');
  sessionStorage.setItem('pw', pw);
  const res = await fetch('/api/ai/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Page-Password': pw,
      'X-Page-Slug': location.pathname,
    },
    body: JSON.stringify({ model, messages }),
  });
  if (res.status === 401) { sessionStorage.removeItem('pw'); throw new Error('wrong password'); }
  if (!res.ok) throw new Error((await res.json()).error?.message || res.statusText);
  return res.json();
}
```

## HTML format requirements (read this before generating any page)

Uploaded pages are served with a strict Content-Security-Policy. If you
ignore these rules, the CSS will silently fail to apply or the JS will
throw — the page will look broken to the user. The exact CSP is:

```
default-src 'self' https:;
script-src  'self' 'unsafe-inline' https:;
style-src   'self' 'unsafe-inline' https:;
img-src     'self' data: https:
```

### Do

- Produce a single self-contained `.html` file: inline `<style>`, inline
  `<script>`, and embed small images as `data:` URIs.
- Use `https://` CDNs for external scripts, stylesheets, or fonts (jsDelivr,
  unpkg, Google Fonts all work). Bare `http://` is blocked.
- For images: inline SVG, `data:image/...` URIs, or `https://` URLs.
- For AI calls: use the `/api/ai/v1/chat/completions` proxy (see section
  below). The proxy is same-origin, so `fetch('/api/ai/...')` works under
  the default `connect-src`.
- Test mental model: if you'd need to add a `<link rel="stylesheet" href="style.css">`
  pointing to a second file, don't — inline the CSS into the page itself.

### Do not

- Do **not** use `eval()`, `new Function()`, `setTimeout('code', ...)`, or
  `Function` constructors. The CSP does not enable `'unsafe-eval'`, so all
  of these will throw at runtime. If you need a templating library, pick
  one that compiles without eval (e.g. small hand-rolled template literals).
- Do **not** rely on `@font-face src: url(data:...)` — the font directive
  falls back to `default-src` which does not include `data:`. Use an
  `https://` font CDN instead.
- Do **not** load resources over `http://`; browsers block mixed content
  and the CSP rejects it.
- Do **not** include the user's `api_key` inside uploaded HTML.
- Do **not** hard-code the AI password inside the HTML — the password is
  meant to be entered by viewers.
- Do **not** upload any HTML that fetches `credentials.json` or other
  local files.
- Do **not** exceed 5 MB for the entire HTML file (inline assets count
  toward this limit).

### Self-check before uploading

Before calling `POST /api/pages`, run this mental checklist on your generated
HTML:

1. No `<link rel="stylesheet" href="...">` to a separate file? Everything
   inline or HTTPS?
2. No `<script src="./foo.js">` — scripts are inline or HTTPS CDN?
3. No `eval(`, `new Function(`, or `setTimeout('` with a string argument?
4. Images are `data:`, inline SVG, or HTTPS URLs?
5. Total size under 5 MB?

If any check fails, rewrite before uploading.

## Auditing HTML you did not write

A common flow: the user built the page themselves (or used a tool that
doesn't know about teach-server's CSP) and now asks you to upload it. **You
must audit the file against the CSP above before uploading.** If you skip
this step and the upload violates the CSP, the server responds 200 OK, the
page URL works, but CSS silently fails to apply or JS throws — the user
sees a broken page and (reasonably) blames the host. Catching this in the
agent saves a support round-trip.

### Audit procedure

`Read` the HTML file, then search it for each pattern below. Claude Code
users can use the built-in `Grep` tool (ripgrep-backed); other agents can
shell out via `Bash: grep -En '<pattern>' page.html`.

**Blocking issues (do not upload until fixed):**

| Pattern to search | Why it breaks |
|---|---|
| `http://[^"'\s]` | Mixed content — browser blocks the resource load |
| `eval\s*\(` | CSP blocks without `'unsafe-eval'` |
| `new\s+Function\s*\(` | Same as `eval()` under CSP |
| `setTimeout\s*\(\s*['"\`]` | String-form timer = implicit eval |
| `setInterval\s*\(\s*['"\`]` | Same |
| `<script[^>]+src="(?!https:)[^"]+"` | External script not on https (excluding same-origin `./` which will 404 anyway) |
| `<link[^>]+href="(?!https:)[^"]+\.css` | External CSS not on https |

**Warnings (upload is fine, but tell the user what will still not work):**

| Pattern | Effect |
|---|---|
| `@font-face[\s\S]*?src:\s*url\(\s*data:` | The data: font is rejected by `font-src` fallback; text falls back to system font. Suggest switching to an https:// font CDN. |
| `<iframe[^>]+src="(?!https:)` | Non-https iframes blocked by `frame-src` fallback. |

**Size check:** `Bash: wc -c page.html` must be ≤ 5242880 (5 MB).

### How to report findings to the user

1. If there are **blockers**, do not upload. Show the user each violation
   with line number and the reason, and offer to fix them in-place (e.g.
   rewrite `eval()` into a function reference, change `http://` to
   `https://`, inline a referenced `style.css`). Re-audit after fixes.
2. If there are only **warnings**, you may upload, but mention each one in
   the confirmation message so the user knows what will silently degrade.
3. If the file passes clean, upload and report the URL as usual.

### One-liner audit (copy-paste ready)

For Claude Code users, this Bash command flags the blockers at once:

```bash
grep -En "http://[^\"'\\s]|eval[[:space:]]*\\(|new[[:space:]]+Function[[:space:]]*\\(|setTimeout[[:space:]]*\\([[:space:]]*['\"\`]|setInterval[[:space:]]*\\([[:space:]]*['\"\`]" page.html || echo "no blockers found"
```

Empty output = no blockers. Any matches = show them to the user and fix
before uploading.
