# Platform 2040 - Agent Guide

> **LLM-Friendly:** This guide is designed for AI agents and LLMs. Fetch this into your agent's context to discover and manage apps on the BrightWrapper Bubble server.

Platform 2040 is the private manager for 90+ aisloppy apps on the local server. It provides app discovery, semantic search, and infrastructure management (create, fork, rename, and delete apps).

Before changing an app's routes, deployment, service, data, secrets, or
cross-app integration, read the concise shared-server architecture contract at
<https://platform2040.com/architecture-guide.md>.

**Base URL:** `https://platform2040.com`

**Web routes:**
- Landing page: `/`
- Private dashboard: `/dashboard`
- Operator onboarding-interest list: `/interest`
- Shared-server architecture contract: `/architecture-guide.md`

**Onboarding interest endpoints:**
- Public `POST /api/onboarding-interest` with JSON `{"email":"person@example.com","source":"platform2040.com"}` records interest in custom VPS onboarding. Returns `201 subscribed`, `200 already_registered`, `400/415 validation`, `429 rate_limit`, or `500 persistence`. The endpoint is synchronous and idempotent by normalized email.
- Operator-only `GET /api/onboarding-interest` returns the collected entries, newest first.

## Authentication

All app inventory and management endpoints require authentication.

For machine-to-machine integration, Platform 2040 issues its own integration API keys — keys start with `p2040k_`.

```
X-API-Key: p2040k_your_key_here
```

Also accepted: `Authorization: Bearer p2040k_your_key_here`

Browser users authenticate on `platform2040.com` and then use Platform 2040's own routes. Browser code should use AuthReturn bearer tokens from `AuthReturn.init({ sessionScope: "local" })`; user-facing flows should stay on the app's own domain instead of redirecting people to raw AuthReturn app endpoints.

Integration key issuance is operator-only:
- Sign into Platform 2040 as an allowed operator through Platform 2040's own UI.
- Create keys with `POST /api/integration-keys`.
- List keys with `GET /api/integration-keys`.
- Revoke a key with `POST /api/integration-keys/<key_id>/revoke`.

Local-first rule:
- For browser and user-facing integrations, keep auth flows on `platform2040.com` and use Platform 2040's own endpoints such as `GET /api/auth/session` and `POST /api/integration-keys`.
- Do not send end users to `authreturn.com/api/apps/platform2040/...` as their primary interaction pattern.
- Direct AuthReturn login is only an operator automation fallback when you explicitly need a bearer JWT for headless scripting.

API key integrations are **locked by default** until explicitly enabled on the server via:

`/opt/secrets/platform2040.json`
```json
{"agent_integration_enabled": true}
```

When integration is locked, API key requests return `403`.

Protected management endpoints also enforce an operator allowlist. By default only:

`tremendous-agent@proton.me`

To change this allowlist, set `allowed_operator_emails` in `/opt/secrets/platform2040.json`.

**Auth-required endpoints:** `GET /api/projects`, `GET /api/server/apps`, `GET /api/server/apps/<name>/files`, `GET /api/server/apps/<name>/file`, `POST /api/search`, `POST /api/apps/validate`, `POST /api/apps/create`, `POST /api/apps/fork`, `POST /api/apps/rename`, `POST /api/apps/delete`, `GET /api/renames`, `GET /api/renames/<name>`, `POST /api/projects/rescan`, `GET /api/task/<task_id>`, `GET /api/infra/nginx-timeouts`, `POST /api/builds/record`, `GET /api/builds`, `POST /api/builder/run`, `GET /api/builder/task/<task_id>`, `POST /api/builder/cancel/<task_id>`, `GET /api/projects/<name>/loc-files`, `GET /api/app-farm/status`, `GET /api/app-farm/instances`, `GET /api/app-shell/audit`, `GET /api/app-shell/configs`, `PUT /api/app-shell/configs/default`, `PUT /api/app-shell/configs/<host>`, `DELETE /api/app-shell/configs/<host>`

### POST /api/apps/<name>/auth/cognito

Start direct Cognito Managed Login provisioning without AuthReturn. Optional body fields are
`domain`, `callback_urls`, and `logout_urls`; omitted URLs default to the app domain. Returns
`202` with the standard durable `task_id` contract. Duplicate active requests for one app are
deduplicated. The task terminates as `completed`, `failed`, or `timed_out` within 120 seconds;
its completed `result` is the non-secret app-owned Cognito configuration.

## Quick Start

### 1. Create An Integration Key

Preferred browser flow:

1. Sign into `https://platform2040.com`.
2. Create or rotate a key with `POST /api/integration-keys` from that authenticated session.

Headless operator fallback:

```bash
JWT=$(curl -s -X POST https://authreturn.com/api/apps/platform2040/login \
  -H "Content-Type: application/json" \
  -d '{"email":"tremendous-agent@proton.me","password":"YOUR_PASSWORD"}' \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['token'])")

API_KEY=$(curl -s -X POST https://platform2040.com/api/integration-keys \
  -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -d '{"label":"Hydraulic Butterfly","consumer_slug":"hydraulic-butterfly","replace_existing":true}' \
  | python3 -c "import json,sys; print(json.load(sys.stdin)['api_key'])")
```

If you already have a valid signed-in browser session, you do not need the direct AuthReturn login step above.

### 2. List All Apps

```bash
curl -s https://platform2040.com/api/projects \
  -H "X-API-Key: $API_KEY" | python3 -m json.tool | head -30
```

### 3. Search Apps by Description

```bash
curl -s -X POST https://platform2040.com/api/search \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "authentication and login"}' | python3 -m json.tool
```

### 4. Create a New App

```bash
# Start creation (returns task_id)
TASK=$(curl -s -X POST https://platform2040.com/api/apps/create \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"name": "my-new-app", "description": "A tool for tracking daily habits"}')

TASK_ID=$(echo $TASK | python3 -c "import sys,json; print(json.load(sys.stdin)['task_id'])")

# Poll until complete
while true; do
  STATUS=$(curl -s https://platform2040.com/api/task/$TASK_ID \
    -H "X-API-Key: $API_KEY")
  echo $STATUS | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['status'], d.get('logs',[''])[-1] if d.get('logs') else '')"
  echo $STATUS | python3 -c "import sys,json; sys.exit(0 if json.load(sys.stdin)['status'] in ('completed','failed') else 1)" && break
  sleep 3
done
```

### 5. Audit nginx timeout overrides

```bash
curl -s https://platform2040.com/api/infra/nginx-timeouts \
  -H "X-API-Key: $API_KEY" | python3 -m json.tool | head -80
```

## Cluster Standard: Long-Running Work

Use this as the default contract across services:

1. `POST /api/jobs/...` returns quickly with `202` and `task_id`
2. `GET /api/tasks/{task_id}` is the durable source of truth for status, logs, result, and error
3. `GET /api/tasks/{task_id}/events` is optional SSE for live observation only

Rules:
- Long-running compute must not sit behind ordinary sync request/response endpoints.
- Raised nginx timeout overrides on non-streaming routes are violations.
- Streaming routes may use longer idle timeouts, but they must actually stream data or heartbeats.
- SSE is transport, not durability. The task record is the durable state.

This guide's nginx audit endpoint helps identify existing violations in the fleet.

## API Reference

### GET /api/projects

List all apps with metadata. Merges cached LLM descriptions with live filesystem data.

**Auth:** Required

**Response:**
```json
{
  "projects": [
    {
      "name": "auth-return",
      "domain": "authreturn.com",
      "description": "Authentication service using AWS Cognito...",
      "last_modified": 1707422400.0,
      "type": "app"
    }
  ],
    "last_scan": "2025-01-15 10:30:00"
}
```

### GET /api/server/apps

List apps discovered on the current server. Use this for a live app picker when another app needs to browse local projects through Platform 2040 instead of reading `/home/ubuntu/apps` directly.

**Auth:** Required

**Response:**
```json
{
  "apps": [
    {
      "name": "tentacles",
      "domain": "tentacles.aisloppy.com",
      "url": "https://tentacles.aisloppy.com",
      "type": "app",
      "last_modified": 1707422400.0
    }
  ]
}
```

### GET /api/server/apps/<name>/files

Return indexed text files for one current-server app. Intended for code-exploration tools that need a safe file list over the network API.

**Auth:** Required

**Response:**
```json
{
  "app_name": "tentacles",
  "file_count": 42,
  "files": ["backend/server.py", "frontend/index.html"]
}
```

### GET /api/server/apps/<name>/file?path=<relative_path>

Read one text file from a current-server app.

**Auth:** Required

**Response:**
```json
{
  "app_name": "tentacles",
  "path": "backend/server.py",
  "content": "\"\"\"tentacles Backend..."
}
```

### GET /api/infra/nginx-timeouts

Inspect nginx site configs for timeout overrides and classify routes as standard, streaming, or suspicious.

**Auth:** Required

**Response:**
```json
{
  "summary": {
    "site_count": 104,
    "route_count": 233,
    "suspicious_timeout_override_count": 1,
    "streaming_timeout_override_count": 2,
    "streaming_route_count": 4
  },
  "sites": [
    {
      "config_path": "/etc/nginx/sites-available/toolong.aisloppy.com.conf",
      "server_names": ["toolong.aisloppy.com"],
      "locations": [
        {
          "match": "/",
          "classification": "suspicious_timeout_override",
          "recommendation": "Move long-running work behind an async job endpoint and keep request timeouts strict."
        }
      ]
    }
  ]
}
```

### POST /api/search

Semantic similarity search across app descriptions using embeddings.

**Auth:** Required

**Body:**
| Field | Required | Description |
|-------|----------|-------------|
| `query` | yes | Natural language search query |

**Response:**
```json
{
  "query": "authentication",
  "results": [
    {
      "name": "auth-return",
      "domain": "authreturn.com",
      "description": "Authentication service...",
      "similarity": 0.8734
    }
  ]
}
```

Results are sorted by similarity (highest first). Filter by `similarity > 0.3` for relevant matches.

### POST /api/apps/validate

Check if an app name is valid and available.

**Auth:** Required

**Body:**
| Field | Required | Description |
|-------|----------|-------------|
| `name` | yes | Proposed app name (lowercase, hyphens ok) |

**Response (valid):**
```json
{"valid": true, "normalized": "my-app", "domain": "my-app.aisloppy.com"}
```

**Response (invalid):**
```json
{"valid": false, "error": "App \"my-app\" already exists"}
```

### POST /api/apps/create

Create a new app with full infrastructure (Linux user, systemd service, nginx, SSL, favicon). Returns a task ID for async polling.

**Auth:** Required

**Body:**
| Field | Required | Description |
|-------|----------|-------------|
| `name` | yes | App name (lowercase, hyphens allowed, 2-30 chars) |
| `description` | no | What the app does (used for favicon generation) |

**Response:** `202 Accepted`
```json
{
  "task_id": "uuid",
  "status": "pending",
  "poll_url": "/api/task/uuid"
}
```

**What gets created:**
- Linux user `app_<name>`
- Code directory `/home/ubuntu/apps/<name>/`
- Data directory `/home/app_<name>/data/`
- Python venv with Flask + gunicorn
- Systemd service on next available port
- Nginx config with SSL (via certbot)
- AuthReturn app registration (app is ready for auth immediately)
- Local semantic header using the Platform Header Toolkit, plus an AuthReturn gate and `/api/me` identity endpoint; app content is private by default
- Standard administrators `tremendous-agent@proton.me` and `jessald@proton.me` are whitelisted; the agent account is bootstrapped and Jess can establish her own password through signup
- Grid Glance registration and tracker injection (enabled by default)
- AI-generated favicon
- Git repo initialized

Public routing is deliberately hybrid: nginx serves pages and assets directly
from `frontend/`, while Flask owns `/api/*` plus the explicitly proxied agent
guide routes. A public `/name` page must be `frontend/name.html`; a browser asset
such as `/favicon.ico` must be `frontend/favicon.ico`. Creation verifies the
generated favicon link and file before completing. Generated `AGENTS.md` records
this ownership contract, and browser-facing changes must be checked through the
public origin rather than only against the Flask port.

Platform-managed nginx hosts include the shared mutable-asset cache policy.
HTML, CSS, JavaScript, and other same-URL app responses may be stored locally,
but browsers must revalidate them using ETag or Last-Modified before reuse.
Apps therefore do not need handwritten version query strings for correctness.
Explicit nested locations may still declare immutable caching for genuinely
content-addressed assets.

Task snapshots include `started_at`, `step_current`, `step_total`, and measured
milestone `progress`. Completed results include `timing.infra_seconds`,
`timing.https_seconds`, and `timing.total_seconds`.

### GET /api/apps/create/timings

Return persisted create-time history, including serial and parallel strategy
sample counts and median infrastructure/total durations plus the 20 latest runs.

**Auth:** Required

### POST /api/apps/fork

Provision a fresh target app, then copy an existing source app's workspace into it and retarget the obvious app identifiers.

**Auth:** Required

**Body:**
| Field | Required | Description |
|-------|----------|-------------|
| `source_name` | yes | Existing app to copy from |
| `target_name` | yes | New app slug to provision |
| `description` | no | Provisioning description for the new target app; defaults to `Fork of <source_name>` plus cached description when available |

**Response:** `202 Accepted`
```json
{
  "task_id": "uuid",
  "status": "pending",
  "poll_url": "/api/task/uuid"
}
```

**Completed task result:**
```json
{
  "source_name": "overview",
  "name": "overview-agent",
  "domain": "overview-agent.aisloppy.com",
  "port": 9274,
  "service_active": true,
  "http_status": 200,
  "warnings": [
    "Runtime data directories are not copied when forking."
  ]
}
```

**Important fork semantics:**
- Fresh infra is created for the target app first (user, service, nginx, SSL, AuthReturn registration, and local Header Toolkit markup).
- Source code is copied into the new workspace and obvious identifiers are retargeted (`name`, domain, paths, app user).
- Runtime data directories are **not** copied.
- Legacy host-specific app-shell overrides are **not** cloned.
- App-specific secrets are copied when `/opt/secrets/<source>.json` exists, but secret values may need manual rotation afterward.

### POST /api/apps/rename

Rename an existing app, updating all 12+ sources of truth (directory, user, service, nginx, SSL, registry, app-shell host override, code references, and matching source/asset path basenames). Text rewrites and path renames are one contract: when a rewritten import or URL names the new slug, the referenced local file is renamed in the same step or the task fails. Host-level app-shell settings move from the old domain to the new domain without merging or weakening them; a conflicting target override fails the rename.

Rename preserves application routes by proxying the new domain to the renamed service, not only its `/api` routes. App slugs may contain hyphens; generated Unix users and `/home/app_<name>/data/` paths normalize them to underscores. JSON manifests such as `platform2040.json` are retargeted along with source files. The task is not complete until `service_active` is `true`; verify the new public health endpoint after polling the task to completion.

**Auth:** Required

**Body:**
| Field | Required | Description |
|-------|----------|-------------|
| `old_name` | yes | Current app name |
| `new_name` | yes | New app name |

**Response:** `202 Accepted` (same task pattern as create)

**Completed task result:**
```json
{
  "old_name": "old-app",
  "new_name": "new-app",
  "domain": "new-app.aisloppy.com",
  "service_active": true
}
```

### POST /api/apps/delete

Move an existing app and its local infrastructure resources to reversible trash.
The app's AuthReturn identity is renamed to a unique trash slug, releasing the
original slug for a replacement app. Restore reclaims the original identity and
fails before moving local files if that slug is now occupied.

**Auth:** Required

**Body:**
| Field | Required | Description |
|-------|----------|-------------|
| `name` | yes | Existing app name |

**Response:** `202 Accepted` (same task pattern as create)

### GET /api/apps/trash

List restorable trash entries. **Auth:** Required

### POST /api/apps/trash/<trash_id>/restore

Restore a trashed app and reconnect its service and nginx resources. Returns `202 Accepted`.

### DELETE /api/apps/trash/<trash_id>

Permanently remove one trash entry. This is the only destructive deletion path;
it also deletes the quarantined AuthReturn registration and Cognito pool.

### GET /api/renames

List all historical app renames, newest first.

**Auth:** Required

**Response:**
```json
{
  "renames": [
    {
      "old_name": "quest-viz",
      "new_name": "camelot",
      "timestamp": 1707422400,
      "date": "2025-02-08 22:40:00"
    }
  ]
}
```

### GET /api/renames/\<name\>

Check if an app name was renamed. Searches both old and new names. Use this when an app seems to be missing — it may have been renamed.

**Auth:** Required

**Response (found):**
```json
{
  "found": true,
  "old_name": "quest-viz",
  "new_name": "camelot",
  "timestamp": 1707422400,
  "date": "2025-02-08 22:40:00"
}
```

**Response (not found):**
```json
{"found": false}
```

### POST /api/projects/rescan

Trigger a full rescan of all apps — regenerates LLM descriptions and embeddings. Expensive operation (calls LLM for each app).

**Auth:** Required

**Response:** `202 Accepted` (same task pattern)

### GET /api/task/\<task_id\>

Poll an async task's status.

**Auth:** Required

**Status codes:**
- `202` — Task still running
- `200` — Task completed or failed

**Response:**
```json
{
  "status": "running",
  "progress": 45,
  "logs": ["[10:30:01] Processing 5/12: auth-return"],
  "result": null,
  "error": null
}
```

Status values: `pending` → `running` → `completed` | `failed`

### GET /api/favicons/\<domain\>

Serve a cached favicon for an app domain. If the domain has no cached favicon yet or no site favicon is available, the endpoint returns a default icon immediately and refreshes the cache in the background. Public and local-only app icons are reconciled every 15 minutes; browser copies revalidate within 5 minutes. Known icons use one conditional request per interval and only repeat page discovery when the saved source stops working.

**Auth:** Not required

**Response:** Image binary (PNG, ICO, WebP, SVG, or default fallback with correct Content-Type)

### GET /api/builds

List recent Claude Code builds, newest first (max 50). Automatically refreshes status of running builds by probing BrightWrapper.

**Auth:** Required

**Response:**
```json
{
  "builds": [
    {
      "task_id": "uuid",
      "project_name": "my-app",
      "prompt": "A tool for tracking daily habits",
      "status": "completed",
      "started_at": 1707422400.0,
      "completed_at": 1707422800.0,
      "error": null
    }
  ]
}
```

Status values: `running`, `completed`, `failed`

### POST /api/builds/record

Manually record a build that was started externally (e.g., via direct BrightWrapper API call).

**Auth:** Required

**Body:**
| Field | Required | Description |
|-------|----------|-------------|
| `task_id` | yes | BrightWrapper task ID |
| `project_name` | yes | App name |
| `prompt` | no | Build description/prompt |

**Response:** `201 Created`
```json
{"status": "recorded", "task_id": "uuid"}
```

### POST /api/builder/run

Start a Claude Code build session for an app. Sends an enriched prompt to BrightWrapper's claude-code endpoint.

**Auth:** Required

**Body:**
| Field | Required | Description |
|-------|----------|-------------|
| `project_name` | yes | App name (must have systemd service) |
| `prompt` | yes | What to build |
| `client` | no | Headless harness; currently `codex` (default). |
| `deadline_s` | no | Overall deadline, 300–21600 seconds (default: 3600). |

**Response:** `202` with `task_id`, `status`, and `poll_url`. An active build
for the same owner and project is returned instead of duplicated.

### GET /api/builder/task/\<task_id\>

Poll a durable coding-agent task. Active tasks return `202`; terminal tasks
return `200`. States are `queued`, `running`, `cancelling`, then `completed`,
`failed`, `cancelled`, or `timed_out`. `output` is bounded selectable JSONL
from the harness; `error` preserves the concrete failure.

**Auth:** Required

### POST /api/builder/cancel/\<task_id\>

Requests cancellation and returns the task snapshot. The worker terminates and
reaps the complete process group before persisting `cancelled`.

### GET /api/builder/tasks

Lists the authenticated integration owner's recent durable build tasks.

### GET /api/version

**Auth:** Not required

**Response:** `{"version": "2.11.0"}`

### GET /api/config

Return non-secret frontend config.

**Auth:** Not required

**Response:**
```json
{
  "auth_return_app_id":"<authreturn-app-id>",
  "architecture_page_id":"<diagram-page-id>"
}
```

## Platform Header Toolkit (canonical)

Platform 2040 is a header design toolkit, not an application shell. The app
owns the complete semantic header and navigation in its local HTML; Platform
2040 supplies shared styling, active-route enhancement, optional AuthReturn
integration, responsive behavior, and implementation guidance.

Load the toolkit explicitly:

```html
<link rel="stylesheet" href="https://platform2040.com/header-toolkit.css">
<script defer src="https://authreturn.com/static/auth_component.js"></script>
<script defer src="https://platform2040.com/header-toolkit.js"></script>
```

Author the header locally beside the routes it exposes:

```html
<header
  data-platform-header
  data-platform-header-theme="light"
  data-auth-app="my-app"
  data-auth-session="local"
  data-identity-endpoint="/api/me">
  <a data-platform-brand href="/">
    <img src="/favicon.svg" alt="">
    <span data-platform-brand-label>My App</span>
  </a>
  <nav data-platform-nav aria-label="Primary navigation">
    <a href="/">Home</a>
    <a href="/architecture">Architecture</a>
    <a href="/admin" data-platform-visibility="admin" hidden>Admin</a>
  </nav>
  <div data-platform-auth-slot></div>
</header>
```

Contract:

- Use one real `<header data-platform-header>` per page. It is app source, not
  an empty mount and not remotely generated content.
- Put the brand and every global navigation link in local markup. Route changes
  and navigation changes belong in the same commit.
- Use a linked `[data-platform-brand]`, a labeled semantic
  `<nav data-platform-nav>`, real anchors, and optional
  `[data-platform-auth-slot]`.
- Load AuthReturn before the toolkit when `data-auth-app` is present. The
  toolkit initializes the canonical AuthReturn component. Restricted links use
  `data-platform-visibility="authenticated"` or `"admin"` and start `hidden`;
  admin links require a same-origin identity endpoint returning boolean
  `is_admin`.
- App code needing the auth client uses
  `const {auth} = await PlatformHeaderToolkit.whenReady()`. The promise has a
  ten-second default deadline. `platform-header-auth-pending` marks transition
  start; `platform-header-auth-change` fires only at signed-in/signed-out
  terminal state with `state`, `user`, `admin`, and `header` in `detail`.
- The unenhanced HTML remains visible, selectable, navigable, and correctly
  structured if either hosted asset fails. Authentication failure is shown in
  the auth slot rather than replacing navigation.
- Declare the dependency in `platform2040.json` as
  `"header_toolkit":{"provider":"platform2040","version":1}`.
- Platform 2040 does not inject a header, fetch navigation configuration, or
  own application information architecture.

The public `PlatformHeaderToolkit` object exposes `enhance(root)`,
`enhanceAll()`, `markCurrentRoute(root)`, and `whenReady(root, timeoutMs)` for
explicit local composition. Normal pages rely on automatic enhancement after
`DOMContentLoaded`.

## Legacy App Shell Compatibility (deprecated)

The app-shell runtime and configuration endpoints remain available only so
existing applications do not break during migration. Do not use them for new
apps or new pages; migrate an app by replacing the empty mount with local
semantic header markup and the Header Toolkit assets.

### GET /api/app-shell/config

Return resolved cross-app shell config for a host. This endpoint is public so edge/runtime shell injection can fetch config without app-level credentials.

**Auth:** Not required
**CORS:** `Access-Control-Allow-Origin: *`

**Query parameters:**
- `host` (optional): Hostname to resolve. If omitted, server infers from request host headers.

**Response:**
```json
{
  "host": "sora-test.aisloppy.com",
  "config": {
    "enabled": true,
    "brand_label": "Aisloppy",
    "brand_subtitle": "",
    "brand_mark": "",
    "brand_icon": "/favicon.ico",
    "brand_href": "https://platform2040.com/dashboard",
    "links": [
      {"label": "Home", "href": "/", "visibility": "public"},
      {"label": "Library", "href": "/library", "visibility": "admin"}
    ],
    "hide_selectors": [],
    "mount_selector": "",
    "right_widget": "",
    "header_variant": "dark",
    "auth_policy": "app",
    "auth_app_id": "sora-test",
    "auth_session_scope": "local",
    "identity_endpoint": "/api/me"
  },
  "meta": {
    "source": "default",
    "updated_at": 1770000000.0,
    "schema_version": 9
  }
}
```

### GET /api/app-shell/configs

Return the full app-shell configuration document (default + host overrides).

**Auth:** Required

### GET /api/app-shell/audit

Audit every discovered app that declares shared-shell adoption. The response includes fleet totals and per-app compliance errors for mount count, empty ownership mount, shared script, fallback markup, and competing local headers.

**Auth:** Required

### PUT /api/app-shell/configs/default

Patch default shell configuration.

**Auth:** Required

**Body keys (optional):** `enabled`, `brand_label`, `brand_subtitle`, `brand_mark`, `brand_icon`, `brand_href`, `links`, `hide_selectors`, `mount_selector`, `right_widget`, `header_variant`, `auth_policy`, `auth_app_id`, `auth_session_scope`, `identity_endpoint`, `profile_href`
`profile_href` optionally turns the authenticated email in the shared header into an accessible link to the consuming app's profile page.
When the same-origin `identity_endpoint` returns a non-empty `display_name`, the authenticated header shows it instead of the AuthReturn email; email remains the fallback.
Each link accepts `visibility`: `public` (default), `authenticated`, or `admin`. Restricted links require `auth_app_id`; `admin` is determined from the consuming app's same-origin `identity_endpoint`, whose authenticated JSON response must include boolean `is_admin`. Links sharing a non-empty `group` label render in one compact disclosure menu.
`right_widget` values: `""`, `"bw_auth"`, `"graphtutor_auth"`, `"agent_manager"`, `"domain_gen"`, `"authreturn_basic"`
`header_variant`: `dark` by default; use `light` for an explicitly light app. An empty host value inherits the default. Other lowercase variant slugs matching `^[a-z0-9][a-z0-9_-]{0,63}$` remain supported.
`auth_policy`: `app`, `authreturn`, or `trusted_network`. Host `finance` should use `trusted_network`; public hosts normally use `authreturn` or app-owned policy.
`enabled: false` leaves the required ownership mount empty and hidden, resolves
`PlatformAppShell.ready` with state `disabled`, and renders no global header.

### PUT /api/app-shell/configs/\<host\>

Patch host-specific shell overrides.

**Auth:** Required

**Body keys (optional):** same as the default endpoint above.
`right_widget` values: `""`, `"bw_auth"`, `"graphtutor_auth"`, `"agent_manager"`, `"domain_gen"`, `"authreturn_basic"`
`header_variant`: `dark` by default; use `light` for an explicitly light app. An empty host value inherits the default. Other lowercase variant slugs matching `^[a-z0-9][a-z0-9_-]{0,63}$` remain supported.
`enabled: false` is the supported host-level opt-out for visible shell UI; the
empty ownership mount and script remain in the consuming page.

### DELETE /api/app-shell/configs/\<host\>

Delete host-specific shell override.

**Auth:** Required

### Legacy App Shell Event Protocol (v1)

The injected runtime (`https://platform2040.com/static/infra-inject.js`) publishes global shell events via `window` `CustomEvent`s:

- `infra-app-shell-ready`
- `infra-auth-change`
- `infra-nav-change`
- `infra-shell-layout-change`

Each payload includes:

- `protocol_version`
- `emitted_at`
- `host`

Adapter API exposed by runtime:

- `window.InfraAppShellEvents.emitAuthChange(detail)`
- `window.InfraAppShellEvents.emitNavChange(detail)`
- `window.InfraAppShellEvents.emitShellLayoutChange(detail)`

Decision record and payload contract:

- `docs/app-shell-migration-contract.md`

Legacy integration pattern for unmigrated app pages:

- Do not adopt this pattern for new work. Existing apps may retain one empty `<platform-app-shell></platform-app-shell>` until they migrate.
- Import the shared implementation explicitly in the app source: `<script defer src="https://platform2040.com/app-shell.js"></script>`. Never inject it invisibly through nginx.
- Declare the dependency in `platform2040.json` as `"shared_shell":{"provider":"platform2040","version":1,"mount":"platform-app-shell"}`.
- Include Platform 2040's hosted runtime (`https://platform2040.com/static/infra-inject.js`) so the page gets the shared event/runtime contract.
- Keep exactly one visible top header; do not stack a second fixed global header over an app-owned header.
- Treat `GET /api/app-shell/config` as the source of truth for brand, links, visibility, authentication, active-route behavior, and layout.
- Load AuthReturn before `app-shell.js` when `auth_app_id` is configured. The `PlatformAppShell` global is private renderer state and app code must never access it. Mark visibility-only content with `data-platform-auth="signed-out" hidden` or `data-platform-auth="signed-in" hidden`; the shell resolves and maintains it. Page-owned identity and authenticated API work use an app-owned same-origin endpoint such as `/api/me`, always with an explicit timeout. Listen for `platform-shell-auth-change` only as a signal to refetch that endpoint; never consume its payload as an app API.
- The shared renderer owns all shell markup and responsive styling. It renders exactly one terminal state: a complete header or a conspicuous error message.
- `https://platform2040.com/static/app-header.js` has been retired; do not use it for new app work.
- Use Browser Driver for screenshot-based migration verification; confirm local links, logo, alignment, auth controls, mobile behavior, and no duplicate top header.

### Shared Markdown Page (v1)

Use the hosted component for app-owned Markdown plans and other protected
documents. The app remains responsible for storing the source document and
enforcing authorization on its same-origin endpoint.

```html
<script defer src="https://platform2040.com/markdown-page.js"></script>
<platform-markdown-page
  src="/api/admin/plan"
  loading-label="Loading plan…"
  timeout-ms="10000"></platform-markdown-page>
```

After initializing the app's authentication client, provide a token function
and load the document:

```js
const page = document.querySelector('platform-markdown-page');
page.setTokenProvider(() => auth.getValidToken());
page.load();
```

The endpoint must return `text/markdown` or `text/plain`. The component uses
DOM construction rather than raw HTML insertion, validates the response
content type, applies an explicit deadline, and visibly terminates in
`completed`, `failed`, or `timed_out`. It emits
`platform-markdown-complete` and `platform-markdown-error`; errors include a
retry control. Use the `public` attribute only for intentionally public
documents.

The component renders on the host page's surface and does not paint its own
background, so the host owns the pairing. Declare `color-scheme` on the page
(`:root{color-scheme:dark}` for a dark surface) — the component's default ink,
links, rules, code background, and retry button resolve through `light-dark()`
against it, so a dark page gets legible text instead of near-black prose on a
near-black background. Override any token explicitly with
`--platform-md-ink`, `--platform-md-muted`, `--platform-md-line`,
`--platform-md-code`, `--platform-md-link`, `--platform-md-error`; an explicit
token always wins over the scheme default. A page with a dark background that
declares no `color-scheme` gets the light defaults and unreadable text.

### App Shell Acceptance Gate

Run migration gate checks (contract + host checks):

```bash
python3 scripts/app_shell_acceptance_gate.py --hosts "pictonode.aisloppy.com wordlewithatimer.com"
```

Machine-readable output:

```bash
python3 scripts/app_shell_acceptance_gate.py --hosts "pictonode.aisloppy.com" --json
```

### GET /api/auth/session

Return server-verified browser session state from a Bearer token.

**Auth:** Optional

**Response (authorized session):**
```json
{
  "authenticated": true,
  "authorized": true,
  "user": {"id": "user_id", "email": "operator@example.com"}
}
```

**Response (no session or invalid token):**
```json
{"authenticated": false, "authorized": false, "user": null}
```

### GET /api/app-farm/status

Return App Farm reachability/version from Platform 2040.

**Auth:** Required

### GET /api/app-farm/instances

Return the signed-in operator's App Farm instances through Platform 2040.

**Auth:** Required

## Async Task Pattern

All mutating operations (create, rename, rescan) are async:

1. **POST** the request → get `202` with `task_id`
2. **Poll** `GET /api/task/<task_id>` every 2-3 seconds
3. **Check status:** `202` = still running, `200` = done
4. **Read result:** `status: "completed"` has `result`, `status: "failed"` has `error`

## App Installation Workflow

For deploying code from a laptop to the server:

1. **Create the app** via `POST /api/apps/create` (sets up all infrastructure)
2. **rsync your code** to the server:
   ```bash
   rsync -avz ./my-app/ user@server:/home/ubuntu/apps/my-app/
   ```
3. **Restart the service:**
   ```bash
   ssh user@server "sudo systemctl restart my-app"
   ```

The create step handles user creation, ports, nginx, SSL — you just need to deploy your code.

## New Platform 2040 Host Runbook

Use this process to adopt a trusted Ubuntu VPS as a locked-down Platform 2040
development host. The intended boundary is direct operator SSH access; Platform,
generated apps, and their management APIs remain bound or firewalled away from
the public internet.

### 1. Prepare and harden the host

- Use Ubuntu 24.04 LTS with an encrypted volume and a dedicated operator SSH key.
- Require public-key authentication; disable root, password, and keyboard-
  interactive SSH login.
- Enable Fail2ban and keep EC2/VPS termination protection where available.
- Permit SSH port forwarding. Do not leave a bootstrap server's persistent key
  in `authorized_keys`; short-lived EC2 Instance Connect keys are suitable for
  unattended adoption.

### 2. Enroll the host in the SOPS store

1. Generate the host age identity at `/etc/sops/age/keys.txt`.
2. On the secrets-store authority, add its public recipient to `.sops.yaml` and
   map that recipient to the server name in `hosts.map`.
3. Re-encrypt every shared secret for the new recipient with `sops updatekeys`.
4. Add host-scoped `platform2040.json`, `hotspot-admin.json`, and
   `linecount.json` under `hosts/<server>/`. Locked-down Platform configuration
   must set `locked_down=true` and `auth_required=false`.
5. Deploy the encrypted store and run `sudo /opt/secrets-store/sync.sh`.

Never write durable secrets directly into `/opt/secrets`; the next sync replaces
them. The bootstrap validates all required decrypted secrets before it mutates
packages or services.

### 3. Deploy source and bootstrap

```bash
git clone git@github.com:QualityCopperShovel/agent-config.git /home/ubuntu/agent-config
git clone <existing-platform2040-origin> /home/ubuntu/apps/platform2040
cd /home/ubuntu/apps/platform2040
sudo python3 scripts/bootstrap_locked_down_host.py \
  --linecount-source <server-name> \
  --deadline-seconds 1800
```

The bootstrap owns a persisted, resumable lifecycle at
`/var/lib/platform2040-bootstrap/status.json`:

`validate → packages → account → agent_configuration → agent_tools → runtime → service → health`

It always reaches `completed` or `failed`, records the current step and concrete
error, applies per-command timeouts plus the overall deadline, and safely skips
completed steps after reconnect. A bootstrap schema change intentionally resets
the state and revalidates the complete host contract.

Platform runs as the `ubuntu` host operator because it must create dedicated app
users, systemd units, and nginx configuration through passwordless sudo.
Generated apps still run as their own `app_<slug>` users. The bootstrap also:

- makes `/home/ubuntu` traversable without making its directory listing public;
- keeps Platform on `127.0.0.1:9150`;
- installs Codex CLI and Claude Code as `ubuntu` under `~/.local`;
- publishes conflict-safe `/usr/local/bin/codex` and `claude` links;
- links shared `AGENTS.md`, `CLAUDE.md`, settings, and skills into both agents;
- validates every shared skill's required YAML manifest before linking anything;
- writes a local trusted-workspace Codex profile with approval prompts disabled
  and the full-access sandbox, while refusing to overwrite divergent config;
- restarts failed systemd units before its final health check.

### 4. Finish the coding-agent profile

Agent credentials are per-host and must never be copied from another server.
Complete each interactive login as the `ubuntu` operator:

```bash
codex login --device-auth
claude auth login
```

Configure the commit identity, then verify the shared rules and local Codex
policy installed by the bootstrap:

```bash
git config --global user.name "<operator-name>"
git config --global user.email "<operator-email>"
codex login status
test "$(readlink -f ~/.codex/AGENTS.md)" = ~/agent-config/AGENTS.md
test "$(readlink -f ~/.claude/settings.json)" = ~/agent-config/settings.json
grep -F 'approval_policy = "never"' ~/.codex/config.toml
grep -F 'sandbox_mode = "danger-full-access"' ~/.codex/config.toml
```

The full-access policy is intentional only for a trusted development host. It
removes command approvals and filesystem sandboxing, so do not reuse this
profile on an untrusted or multi-user server.

GitHub access is optional. Add a dedicated SSH credential only when this host
must pull or push the private `agent-config` origin; never copy another server's
private key. Verify bounded connectivity before enabling direct updates:

```bash
ssh -o BatchMode=yes -o ConnectTimeout=10 -T git@github.com
GIT_SSH_COMMAND='ssh -o BatchMode=yes -o ConnectTimeout=10' \
  timeout 30 git -C ~/agent-config pull --rebase origin main
```

Without GitHub credentials, deploy `agent-config` from the trusted bootstrap
source as a Git bundle and retain the configured origin for later enrollment.

### 5. Verify the adopted host

```bash
sudo cat /var/lib/platform2040-bootstrap/status.json
sudo systemctl status platform2040 --no-pager
curl --fail --max-time 5 http://127.0.0.1:9150/api/version
ss -ltn | grep 9150
codex --version
claude --version
```

Success requires bootstrap status `completed`, an active Platform service, and
port 9150 listening only on `127.0.0.1`.

### 6. Reach local-only apps from a laptop

Use a direct SOCKS tunnel rather than exposing management ports:

```sshconfig
Host <server>-socks
    HostName <public-ip>
    User ubuntu
    IdentityFile ~/.ssh/<operator-key>
    DynamicForward 127.0.0.1:1081
    ServerAliveInterval 30
    ServerAliveCountMax 3
    ExitOnForwardFailure yes
```

Keep it alive with `autossh -M 0 -N <server>-socks`. For a browser URL such as
`http://<server>:9150`, add `127.0.0.1 <server>` to the VPS `/etc/hosts`, then
configure FoxyProxy for SOCKS5 at `127.0.0.1:1081`, proxy DNS enabled, and
pattern `*://<server>:*`. SSH `Host` aliases and browser hostnames are independent.

### 7. Create and verify the first app

Call `POST /api/apps/create`, persist the returned task ID, and poll
`GET /api/task/<task_id>` to a terminal state with an overall deadline.
Locked-down creation omits public DNS, SSL, AuthReturn, and Grid Glance. Success
requires the generated app's `/api/health`, active systemd unit, and
`GET /api/app-shell/audit` with zero violations.

### Failure map

| Symptom | Contract failure | Durable correction |
|---|---|---|
| Platform `status=200/CHDIR` | Service user cannot traverse a parent directory | Re-run the current bootstrap; it enforces host path traversal |
| Import fails for a secret | SOPS host enrollment is incomplete | Add the host-scoped secret through the encrypted store and sync |
| App task says success but unit fails | Outdated builder lacks startup verification | Upgrade Platform; current creation requires `/api/health` |
| `codex: command not found` | Agent tools or global links were not provisioned | Re-run current bootstrap and verify `/usr/local/bin/codex` |
| `ERR_SOCKS_CONNECTION_FAILED` | Tunnel absent, remote name unresolved, or port closed | Check port 1081, VPS `/etc/hosts`, proxy DNS, then destination health |

## Integration Pattern

```python
import json
import requests
import time

API_KEY = "p2040k_your_key"
BASE = "https://platform2040.com"
HEADERS = {"X-API-Key": API_KEY, "Content-Type": "application/json"}

# Search for apps
resp = requests.post(
    f"{BASE}/api/search",
    json={"query": "image generation"},
    headers=HEADERS,
    timeout=10,
)
resp.raise_for_status()
for app in resp.json()["results"][:5]:
    print(f"{app['name']}: {app['similarity']:.0%} - {app['description']}")

# Create a new app
resp = requests.post(
    f"{BASE}/api/apps/create",
    json={
        "name": "my-tool",
        "description": "A productivity tool for managing daily tasks",
    },
    headers=HEADERS,
    timeout=10,
)
resp.raise_for_status()
task_id = resp.json()["task_id"]

# Poll to a terminal state with an overall deadline
deadline = time.monotonic() + 600
while time.monotonic() < deadline:
    poll = requests.get(
        f"{BASE}/api/task/{task_id}",
        headers=HEADERS,
        timeout=10,
    )
    poll.raise_for_status()
    task = poll.json()
    if task["status"] == "completed":
        print(f"App created! Domain: {task['result']['domain']}")
        break
    if task["status"] in {"failed", "cancelled", "timed_out"}:
        raise RuntimeError(task["error"])
    time.sleep(3)
else:
    raise TimeoutError(f"Platform task {task_id} exceeded 600 seconds")
```

## DNS Management

DNS for app domains is managed via the shared Namecheap API toolset at `/home/ubuntu/apps/namecheap/`.

### List All DNS Records

```bash
HOME=/opt/secrets python3 /home/ubuntu/apps/namecheap/list_dns_records.py
```

Shows all A records for aisloppy.com with their target IPs.

### Check Where a Domain Points

```bash
python3 /home/ubuntu/apps/namecheap/check_dns_ready.py my-app.aisloppy.com
```

Resolves the domain and checks if it points to the expected IP. Useful before requesting SSL certs.

### Add a DNS Record

```bash
HOME=/opt/secrets python3 /home/ubuntu/apps/namecheap/add-dns-record-namecheap.py my-app
# Adds: my-app.aisloppy.com → 100.50.104.176 (default IP)

# Custom domain/IP:
HOME=/opt/secrets python3 /home/ubuntu/apps/namecheap/add-dns-record-namecheap.py my-app aisloppy.com 1.2.3.4
```

Preserves all existing records and adds/updates the specified subdomain. Has safety checks to never lose `@` or `www` records.

### Update All Records to New IP

```bash
HOME=/opt/secrets python3 /home/ubuntu/apps/namecheap/update_dns_to_new_ip.py --old-ip 1.2.3.4 --new-ip 5.6.7.8
```

Bulk-updates all A records pointing to the old IP. Used during server migrations.

### Credentials

`/opt/secrets/namecheap.json` — contains `api_user`, `api_key`, and `api_ip` (whitelisted IP for API access). The scripts read `~/namecheap.json`, so run them with `HOME=/opt/secrets`.

---

## App Farm Infrastructure

Platform 2040 manages 90+ apps running on shared servers. This section documents cross-cutting patterns that apply when integrating multiple apps together.

### Secrets

Each service receives app-owned `0600` secret copies through read-only systemd
bind mounts. The canonical decrypted files remain root-only sync inputs; app
services cannot read them or unrelated apps' copies.

```bash
# Example inside an app service: the bind mount preserves the stable path
cat /opt/secrets/brightwrapper.json
# {"api_key": "bw_..."}
```

**API keys are per-app.** Each shared service (BrightWrapper, AuthReturn, Camelot, etc.) issues separate keys per consumer app. A BrightWrapper key won't work on Camelot.

### Shared API Key Handling

Multiple apps on the same server may currently receive copies containing the same service key. **Read the key from disk on every API call** — never cache it in a module-level variable or at import time.

```python
# CORRECT — reads fresh each call
def _brightwrapper_key():
    with open('/opt/secrets/brightwrapper.json') as f:
        return json.load(f)['api_key']

resp = requests.post(url, headers={'X-API-Key': _brightwrapper_key()}, ...)

# WRONG — stale after key regeneration
BRIGHTWRAPPER_KEY = json.load(open('/opt/secrets/brightwrapper.json'))['api_key']
```

**Why:** Regenerating a key instantly revokes all previous keys for that user+app. If any session regenerates the shared key (e.g. while debugging a 401), every running process that cached the old key in memory will start getting 401 Unauthorized until restarted. Reading from disk means all services pick up the new key automatically.

### Service Architecture

Each app runs as a systemd service on a dedicated port, behind nginx with SSL.

| Component | Convention |
|-----------|-----------|
| Linux user | `app_<name>` |
| Code directory | `/home/ubuntu/apps/<name>/` |
| Data directory | `/home/app_<name>/data/` |
| Port | Defined in `/etc/systemd/system/<name>.service` |
| Secrets | App-owned `0600` copies, read-only bind-mounted at stable `/opt/secrets/<name>.json` paths |

**Ports are assigned, not chosen.** The port is set in the systemd service file. Apps must read it from the `PORT` environment variable — never hardcode or use a fallback default.

### Running Scripts as App User

Scripts that produce data (imports, migrations, backfills) must run as the app user, not `ubuntu`. Otherwise files end up owned by `ubuntu` and the service can't write to them.

```bash
# CORRECT
sudo -u app_my_app python3 backend/import_data.py

# WRONG — data owned by ubuntu, service can't use it
python3 backend/import_data.py
```

### Shared Services Mesh

Apps communicate with shared services via their public APIs. Each service publishes an agent guide at `/agent-guide.md`:

| Service | URL | Purpose |
|---------|-----|---------|
| BrightWrapper | brightwrapper.com/agent-guide.md | LLM chat, images, structured output, frontend components |
| AuthReturn | authreturn.com/agent-guide.md | Authentication, login/signup |
| Platform 2040 | platform2040.com/agent-guide.md | App management, discovery |
| Camelot | camelot.aisloppy.com/agent-guide.md | Quest/goal tracking |
| Diagram Notes | diagram-notes.aisloppy.com/agent-guide.md | Annotated Mermaid diagrams |
| FaviconGen | favicon-gen.aisloppy.com/agent-guide.md | AI favicon generation |
| Browser Driver | browser-driver.aisloppy.com/agent-guide.md | Browser automation, screenshots |
| Synaptic | synaptic.aisloppy.com/agent-guide.md | Cross-app dependency tracking |
| Option Table | option-table.aisloppy.com/agent-guide.md | Weighted decision matrices |

When integrating a service, fetch its agent guide and follow the instructions there. Treat it like a third-party SaaS — use the public API, not the source code.

---

## Error Responses

All errors return JSON:

```json
{"error": "Description of what went wrong"}
```

| Status | Meaning |
|--------|---------|
| 400 | Invalid request (bad name, missing fields) |
| 401 | Missing or invalid API key |
| 404 | Task or resource not found |
| 500 | Server error |
