# apps.ando.codes — App Authoring Guide

Apps are served at `https://apps.ando.codes/{slug}/`.

## Quick start

Every app is a source file plus a config. The platform compiles and hosts it.

| File | Purpose |
|------|---------|
| `config.json` | App type, title, and feature flags |
| `source.jsx` | Source code (or `source.md`, `source.html`, `api.ts` depending on type) |

That's it for a frontend app. For fullstack apps, add an `api.ts` alongside the source file.

### Minimal JSX app

**`config.json`:**

```json
{
  "type": "jsx",
  "compiler": "bun-react",
  "title": "My App",
  "description": "A short description for link previews."
}
```

**`source.jsx`:**

```jsx
import { useState } from "react";

export default function App() {
  const [count, setCount] = useState(0);
  return (
    <div className="min-h-screen bg-[#0a0a0a] text-[#e8e0d5] flex items-center justify-center">
      <button
        onClick={() => setCount(c => c + 1)}
        className="px-6 py-3 bg-[#c4956a] text-black rounded-lg text-lg hover:opacity-90"
      >
        Clicked {count} times
      </button>
    </div>
  );
}
```

Once published, live at `https://apps.ando.codes/my-app/`.

## App types

### JSX (type: "jsx", compiler: "bun-react")

React 19 component as the default export. Bundled with Bun. Tailwind CSS 4 is available
globally — just use class names, no imports needed.

Available packages (pre-installed, just import — do not `bun add` or `npm install`): `react`, `react-dom`, `marked`, `apps-sdk`. For anything else, load from a CDN at runtime.

Source file: **`source.jsx`**

### Markdown (type: "markdown")

Two compilers:

- **rendered-html** (default): Rendered to HTML with GitHub markdown styling and syntax highlighting.
- **raw**: Served as plain markdown (`text/markdown`). Good for machine-readable content.

Source file: **`source.md`**

### HTML (type: "html", compiler: "passthrough")

Raw HTML. Full documents (`<!doctype` or `<html`) are served as-is. Fragments get wrapped in an
HTML shell with the configured background color and meta tags.

Source file: **`source.html`**

## Server-side APIs

Apps can run persistent server-side code with HTTP endpoints, WebSockets, background jobs, and cron.

### Three modes

| Mode | Config | Files | URL |
|------|--------|-------|-----|
| **Fullstack** | `type: "jsx"`, `api: true` | `source.jsx` + `api.ts` | `/{slug}/` (frontend) + `/{slug}/api/` (backend) |
| **API-only** | `type: "api"`, `compiler: "bun-api"` | `api.ts` only | `/{slug}/api/` only |
| **Frontend-only** | any type without `api` | source file only | `/{slug}/` only |

### Writing an API

Create **`api.ts`** in your app directory. Export a default fetch handler:

```typescript
import { state, jobs, getSecret, app } from "apps-sdk";

export default async function fetch(req: Request): Promise<Response> {
  const url = new URL(req.url);

  if (url.pathname === "/hello") {
    return Response.json({ message: "Hello from " + app.slug });
  }

  if (url.pathname === "/data" && req.method === "POST") {
    const body = await req.json();
    await state.set("latest", body);
    return Response.json({ saved: true });
  }

  return new Response("Not Found", { status: 404 });
}
```

### Path stripping

The platform strips the `/{slug}/api` prefix before forwarding to your handler:

| Client requests | Your handler sees |
|----------------|-------------------|
| `GET /{slug}/api/hello` | `GET /hello` |
| `POST /{slug}/api/users/123` | `POST /users/123` |
| `GET /{slug}/api/` | `GET /` |

Route your handler based on the stripped path.

### Fullstack example

**`config.json`:**

```json
{
  "type": "jsx",
  "compiler": "bun-react",
  "title": "Notes App",
  "description": "A simple notes app with server-side storage.",
  "state": true,
  "api": true
}
```

**`source.jsx`:**

```jsx
import { useState, useEffect } from "react";

const API = window.__ARTIFACT_API__;

export default function App() {
  const [notes, setNotes] = useState([]);
  const [text, setText] = useState("");

  useEffect(() => {
    if (API) fetch(API.url + "/notes").then(r => r.json()).then(setNotes);
  }, []);

  async function addNote() {
    if (!API || !text.trim()) return;
    const res = await fetch(API.url + "/notes", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ text }),
    });
    const note = await res.json();
    setNotes(prev => [...prev, note]);
    setText("");
  }

  return (
    <div className="min-h-screen bg-[#0a0a0a] text-[#e8e0d5] p-8 max-w-xl mx-auto">
      <h1 className="text-2xl font-bold mb-4">Notes</h1>
      <div className="flex gap-2 mb-6">
        <input
          value={text}
          onChange={e => setText(e.target.value)}
          className="flex-1 bg-[#131110] border border-[#1a1816] rounded px-3 py-2 text-sm"
          placeholder="Write a note..."
        />
        <button onClick={addNote} className="bg-[#c4956a] text-black px-4 py-2 rounded text-sm">
          Add
        </button>
      </div>
      {notes.map((n, i) => (
        <div key={i} className="bg-[#131110] border border-[#1a1816] rounded p-3 mb-2 text-sm">
          {n.text}
        </div>
      ))}
    </div>
  );
}
```

**`api.ts`:**

```typescript
import { state } from "apps-sdk";

export default async function fetch(req: Request): Promise<Response> {
  const url = new URL(req.url);

  if (url.pathname === "/notes" && req.method === "GET") {
    const notes = await state.get("notes") ?? [];
    return Response.json(notes);
  }

  if (url.pathname === "/notes" && req.method === "POST") {
    const body = await req.json();
    const notes = await state.get("notes") ?? [];
    const note = { text: body.text, createdAt: new Date().toISOString() };
    notes.push(note);
    await state.set("notes", notes);
    return Response.json(note);
  }

  return new Response("Not Found", { status: 404 });
}
```

### API-only example

For services with no frontend — bots, webhooks, data pipelines:

**`config.json`:**

```json
{
  "type": "api",
  "compiler": "bun-api",
  "title": "Webhook Handler",
  "api": true,
  "secrets": ["WEBHOOK_SECRET"],
  "cron": {
    "cleanup": { "schedule": "0 3 * * *", "endpoint": "/cron/cleanup" }
  }
}
```

**`api.ts`:**

```typescript
import { state, jobs, getSecret } from "apps-sdk";

export default async function fetch(req: Request): Promise<Response> {
  const url = new URL(req.url);

  if (url.pathname === "/webhook" && req.method === "POST") {
    const secret = getSecret("WEBHOOK_SECRET");
    if (req.headers.get("x-webhook-secret") !== secret) {
      return Response.json({ error: "unauthorized" }, { status: 401 });
    }
    const payload = await req.json();
    const jobId = await jobs.enqueue("process-webhook", payload);
    return Response.json({ queued: true, jobId });
  }

  // Job handler — called by the platform when a queued job is due
  if (url.pathname.startsWith("/jobs/")) {
    const name = url.pathname.split("/jobs/")[1];
    const payload = await req.json();
    if (name === "process-webhook") {
      await state.set("last-webhook", {
        data: payload,
        processedAt: new Date().toISOString(),
      });
      return Response.json({ processed: true });
    }
    return Response.json({ error: "unknown job" }, { status: 404 });
  }

  // Cron handler — called on schedule by the platform
  if (url.pathname === "/cron/cleanup") {
    await state.delete("old-data");
    return new Response("ok");
  }

  return new Response("Not Found", { status: 404 });
}
```

API-only apps are available at `https://apps.ando.codes/{slug}/api/{path}`. They have no
landing page at `/{slug}/`.

## WebSockets

API workers can handle WebSocket connections. Clients connect to
`wss://apps.ando.codes/{slug}/ws/{path}` and the platform proxies to your worker.

Export a `websocket` object alongside your fetch handler:

```typescript
// api.ts
export default async function fetch(req: Request): Promise<Response> {
  const url = new URL(req.url);

  if (url.pathname === "/status") {
    return Response.json({ connections: clients.size });
  }

  return new Response("Not Found", { status: 404 });
}

const clients = new Set();

export const websocket = {
  open(ws) {
    clients.add(ws);
    ws.send(JSON.stringify({ type: "connected", count: clients.size }));
  },
  message(ws, message) {
    const data = JSON.stringify({ type: "message", data: String(message) });
    for (const client of clients) {
      client.send(data);
    }
  },
  close(ws) {
    clients.delete(ws);
  },
};
```

Frontend connection:

```jsx
const slug = "my-app";
const ws = new WebSocket(`wss://apps.ando.codes/${slug}/ws/chat`);

ws.onmessage = (event) => {
  const msg = JSON.parse(event.data);
  console.log(msg);
};

ws.onopen = () => {
  ws.send("hello");
};
```

WebSocket paths are stripped the same way as API paths — `/{slug}/ws/chat` arrives at your
handler as `/ws/chat`.

## SDK reference

Import from `apps-sdk` in your `api.ts`:

```typescript
import { state, jobs, getSecret, app } from "apps-sdk";
```

### `state` — Key-value storage

Server-side access to the same state store available to the frontend.

```typescript
await state.get("key");                    // Get a value (or null)
await state.set("key", { any: "json" });   // Set a value
await state.delete("key");                 // Delete a value
await state.list();                        // List all key-value pairs
```

### `jobs` — Background job queue

Enqueue work for asynchronous processing. The platform polls the queue and calls
`POST /jobs/{name}` on your worker with the payload as the request body.

```typescript
const jobId = await jobs.enqueue("process-data", { url: "..." });
const jobId = await jobs.enqueue("delayed-task", payload, { delay: 60000 }); // 60s delay
const result = await jobs.getResult(jobId);
```

Handle jobs in your fetch handler:

```typescript
if (url.pathname.startsWith("/jobs/")) {
  const name = url.pathname.split("/jobs/")[1];
  const payload = await req.json();
  // process...
  return Response.json({ done: true });
}
```

Job lifecycle: **pending** → **running** → **completed** or **failed**. Jobs are cleaned up
after 24 hours.

### `getSecret(name)` — Read secrets

```typescript
const apiKey = getSecret("OPENAI_KEY"); // reads SECRET_OPENAI_KEY env var
```

Secrets are declared in `config.json` and set by the platform owner. They are injected at
worker startup.

### `app` — App metadata

```typescript
app.slug    // "my-app"
app.baseUrl // "https://apps.ando.codes/my-app"
```

## Persistent state

Apps can store key-value data that persists across sessions.

- **None**: No state persistence (default).
- **Shared**: A single store shared across all visitors. Good for collaborative apps, leaderboards.
- **Per-user**: Each authenticated user gets their own isolated store. Requires authentication.

Set `"state": true` in `config.json` to enable shared state.

### Frontend usage

When state is enabled, the build system injects `window.__ARTIFACT_STATE__`:

```js
window.__ARTIFACT_STATE__ = {
  url: "/api/state/my-app"  // base URL for the state API
};
```

```jsx
import { useState, useEffect, useCallback } from "react";

function useAppState() {
  const cfg = window.__ARTIFACT_STATE__;

  const get = useCallback(async (key) => {
    if (!cfg) return null;
    try {
      const res = await fetch(cfg.url + "/" + key);
      if (res.ok) return res.json();
    } catch {}
    return null;
  }, [cfg]);

  const set = useCallback(async (key, value) => {
    if (!cfg) return;
    await fetch(cfg.url + "/" + key, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(value),
    });
  }, [cfg]);

  const del = useCallback(async (key) => {
    if (!cfg) return;
    await fetch(cfg.url + "/" + key, { method: "DELETE" });
  }, [cfg]);

  return { get, set, del, enabled: !!cfg };
}
```

### State endpoints

| Method | Path | Description |
|--------|------|-------------|
| GET | `{url}` | List all key-value pairs |
| GET | `{url}/{key}` | Get a single value (returns JSON) |
| PUT | `{url}/{key}` | Set a value (JSON body, max 64KB) |
| DELETE | `{url}/{key}` | Delete a value |

Where `{url}` is `window.__ARTIFACT_STATE__.url` (frontend) or accessed via the SDK (backend).

### Backend usage

API workers access the same state store via the SDK:

```typescript
import { state } from "apps-sdk";
await state.get("key");
await state.set("key", value);
```

## CORS proxy

Apps that need to fetch external websites can use the built-in CORS proxy.

Add `proxy_domains` to `config.json`:

```json
{
  "type": "jsx",
  "compiler": "bun-react",
  "title": "My App",
  "proxy_domains": ["example.com", "api.other-site.org"]
}
```

Only declared domains (and their subdomains) are allowed.

The build system injects `window.__ARTIFACT_PROXY__`:

```jsx
const PROXY = window.__ARTIFACT_PROXY__;

async function fetchPage(url) {
  if (!PROXY) return null;
  const res = await fetch(`${PROXY.url}?url=${encodeURIComponent(url)}`);
  if (!res.ok) return null;
  return await res.text();
}
```

Constraints: GET/HEAD only, 5MB response limit, 10s timeout, declared domains only, redirects
not followed, 120 requests/minute per IP.

## Cron jobs

Declare scheduled jobs in `config.json`. Requires `api: true`.

```json
{
  "cron": {
    "sync-data": { "schedule": "0 */6 * * *", "endpoint": "/cron/sync" },
    "daily-cleanup": { "schedule": "0 3 * * *", "endpoint": "/cron/cleanup" }
  }
}
```

Standard 5-field cron syntax: `minute hour day-of-month month day-of-week`. When a job fires,
the platform makes a GET request to the endpoint on your worker.

## Secrets

Declare secret names in `config.json`. Requires `api: true`.

```json
{
  "secrets": ["OPENAI_KEY", "DB_URL"]
}
```

Values are set by the platform owner and injected at worker startup. Access them via the SDK:

```typescript
import { getSecret } from "apps-sdk";
const key = getSecret("OPENAI_KEY");
```

## Authentication

Apps can optionally require login. When enabled, visitors are redirected to a login page.

- **Any authenticated user**: Default when auth is enabled.
- **Specific users or groups**: Restrict to named users or groups. Others get 403.

Auth settings are managed by the platform owner, not in `config.json`.

## Config reference

| Field | Required | Type | Description |
|-------|----------|------|-------------|
| `type` | Yes | string | `"jsx"`, `"markdown"`, `"html"`, or `"api"` |
| `compiler` | Yes | string | `"bun-react"`, `"rendered-html"`, `"raw"`, `"passthrough"`, or `"bun-api"` |
| `title` | Yes | string | Page title and link preview text |
| `description` | No | string | Short description for meta tags and Open Graph |
| `bgColor` | No | string | Background color for the HTML shell. Default: `"#0a0a0a"` |
| `state` | No | boolean | Enable persistent key-value state |
| `proxy_domains` | No | string[] | Domains allowed for CORS proxy |
| `api` | No | boolean | Enable API worker. Required for server-side code. Auto-set for type `"api"` |
| `cron` | No | object | Scheduled jobs. Requires `api`. Format: `{"name": {"schedule": "cron-expr", "endpoint": "/path"}}` |
| `secrets` | No | string[] | Secret names. Values set by platform owner. Requires `api` |

## Slug rules

- Lowercase alphanumeric and hyphens only: `a-z`, `0-9`, `-`
- Must start and end with a letter or number
- 2–60 characters
- Examples: `my-app`, `rome`, `notes`, `budget-tracker`

## Publishing

There are two ways to get an app live.

### Option A: Git (if you have repo access)

Push to a `claude/` branch — never directly to `main`.

```
git checkout -b claude/build-my-app
# create apps/my-app/config.json and apps/my-app/source.jsx
git add apps/my-app/
git commit -m "new app: my-app"
git push origin claude/build-my-app
```

A PR is created automatically. The owner merges it and the app is live within seconds.

Place files in `apps/{slug}/` in the repo:

| App mode | Files in `apps/{slug}/` |
|----------|------------------------|
| Frontend-only | `config.json` + `source.jsx` (or `source.md`, `source.html`) |
| Fullstack | `config.json` + `source.jsx` + `api.ts` |
| API-only | `config.json` + `api.ts` |

Do not commit built output (`index.html`, `content.md`). The build pipeline generates these.

### Option B: Provide files to the owner

If you don't have repo access, produce the source files and the owner will upload them:

1. Write your source file (`source.jsx`, `source.md`, `source.html`, or `api.ts`).
2. The owner handles config, slug assignment, and deployment.

You don't need to write `config.json` yourself — just note any features you need (state,
API endpoint, secrets, cron, proxy domains) and the owner will configure them during upload.

## Design guidelines

**Dark background by default.** All apps get `background: #0a0a0a` on `html` and `body`,
plus a matching `<meta name="theme-color">`. Override with `bgColor` in config.

**House style (optional).** The landing page uses: `#0a0a0a` background, `#e8e0d5` text,
`#c4956a` warm accent. Fonts: JetBrains Mono for UI, Instrument Serif italic for headings.
Apps are independent — match the house style or use your own.

## Common mistakes

- **Wrong source filename**: Must be `source.jsx`, `source.md`, or `source.html` — not `App.jsx`, `index.jsx`, etc.
- **Missing `api: true`**: If you write an `api.ts`, the config must have `"api": true`.
- **Importing Tailwind**: Tailwind CSS 4 is available globally in JSX apps. Don't import it — just use class names.
- **Wrong API paths**: Your handler receives stripped paths. A client calling `/{slug}/api/users` reaches your handler as `/users`.
- **Pushing to main** (git path only): Always use a `claude/` branch.
- **Committing built files** (git path only): Don't commit `index.html` or `content.md` — the build pipeline generates these.
