# hygpo agent quickstart — build a music-generation app in one paste

**To the human:** copy this ENTIRE file, paste it into a fresh coding agent
(Claude Code, Cursor, Codex, …) opened in an EMPTY directory, and add nothing
else. You will need a hygpo account (email + password) — or an unused referral
code to register one. Credentials are typed into the app at runtime; they never
go into the code.

**To the agent:** everything below is your complete, self-sufficient spec. Do
not fetch other documentation, do not install anything, do not ask questions —
build exactly this and stop when the acceptance checklist passes.

---

## 1. The task

A single-page web app for generating music:

1. **Login panel** — email + password (plus an optional referral-code field:
   when filled, register the account first, then log in).
2. **Studio panel** (after login) — shows the credit balance; a prompt
   textarea; a duration input (default **60** seconds); a **Generate** button.
3. Generate submits an **async job**, then **polls** it, showing live status
   (`queued` → `running` → terminal) and elapsed seconds.
4. When the job is `done`, show an audio player per candidate and **play** it.
5. **History** — list past jobs with status; clicking a `done` job loads its
   candidates for playback.
6. Show failures honestly: a `failed` job's `error` text, `402` as
   "insufficient credits", expired candidates (`404`) as "expired".

## 2. Tech stack — fixed, not a suggestion

- **Exactly two files**: `server.js` and `index.html`. No `package.json`, no
  `npm install`, no build step, no framework, no CDN scripts.
- `server.js`: Node.js ≥ 18, built-in modules only. It serves `index.html`
  and **proxies `/v1/*` to `https://hygpo.com`**. Use the verbatim code in §3.
- `index.html`: one file, vanilla JS + CSS.
- Run: `node server.js` → open `http://localhost:8787`.

**Why the proxy is mandatory:** the hygpo API sends **no CORS headers**. A
browser page that calls `https://hygpo.com` directly gets every request blocked.
All frontend code must call same-origin paths (`/v1/...`) and let the proxy
forward them.

## 3. server.js — use this verbatim

```js
const http = require('node:http');
const fs = require('node:fs');
const path = require('node:path');
const { Readable } = require('node:stream');

const UPSTREAM = 'https://hygpo.com';
const PORT = 8787;

http.createServer(async (req, res) => {
  if (req.url.startsWith('/v1/')) {
    try {
      const headers = {};
      if (req.headers['authorization']) headers['authorization'] = req.headers['authorization'];
      if (req.headers['content-type']) headers['content-type'] = req.headers['content-type'];
      const body = (req.method === 'GET' || req.method === 'HEAD') ? undefined
        : await new Promise((ok, err) => {
            const chunks = [];
            req.on('data', c => chunks.push(c));
            req.on('end', () => ok(Buffer.concat(chunks)));
            req.on('error', err);
          });
      const upstream = await fetch(UPSTREAM + req.url, { method: req.method, headers, body });
      res.writeHead(upstream.status, {
        'content-type': upstream.headers.get('content-type') || 'application/octet-stream',
      });
      // Stream, don't buffer: candidate audio can be tens of MB.
      if (upstream.body) Readable.fromWeb(upstream.body).pipe(res);
      else res.end();
    } catch (e) {
      res.writeHead(502, { 'content-type': 'application/json' });
      res.end(JSON.stringify({ error: 'proxy error: ' + e.message }));
    }
    return;
  }
  fs.readFile(path.join(__dirname, 'index.html'), (err, data) => {
    if (err) { res.writeHead(500); res.end('missing index.html'); return; }
    res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
    res.end(data);
  });
}).listen(PORT, () => console.log('open http://localhost:' + PORT));
```

## 4. API facts

Base URL (through the proxy): `/v1/...`. JSON in and out. Every non-2xx
response body is `{"error":"<display-safe message>"}` — branch on the status
code. IDs are UUIDs; timestamps are RFC 3339 UTC.

### 4.1 Auth

- `POST /v1/sessions` `{"email":"...","password":"..."}` →

  ```json
  {"session_token": "opaque-signed-string",
   "refresh_token": "opaque-single-use-string",
   "user": {"id": "0198c1f0-5a2e-7d31-9f4a-2b8e6c1d0a42",
            "email": "you@example.com", "role": "author",
            "created_at": "2026-07-19T15:54:26.147Z"}}
  ```

  Bad credentials → `401 {"error":"invalid credentials"}`.
- Send `Authorization: Bearer <session_token>` on every other call. The
  session token expires after **15 minutes**. Simplest correct handling (do
  this, including inside the polling loop): keep the email/password from the
  login form in memory; on any `401`, log in again once, replace the token,
  retry the request once.
- Optional registration (only when the referral-code field is filled):
  `POST /v1/users` `{"referral_code":"...","email":"...","password":"min 8 chars"}`
  → `201 {"id","email","role","created_at"}`. It does NOT log you in — call
  login next. `400` = bad/expired code; `409` = code already used or email
  already registered.

### 4.2 Balance — `GET /v1/billing/balance` (authenticated)

```json
{"balance_milli": 4920000}
```

Money is integer **milli-credits**: divide by 1000 for credits, by 1 000 000
for dollars (`4920000` → `4920 credits` → `$4.92`). Display both, e.g.
`4920 credits ($4.92)`.

### 4.3 Submit a generation — `POST /v1/jobs` (authenticated)

```json
{"capability": "music.synth", "op": "generate",
 "params": {"caption": "warm lo-fi hip hop, dusty keys, 80 bpm",
            "duration": 60, "batch": 1}}
```

- `caption` is the prompt (required). `duration` is seconds (≤ 600; default
  120 when unset — always send it explicitly). `batch` ≤ 4 variants; use
  **1** in this app. Optional extras you may expose but don't have to:
  `lyrics`, `vocal_language`, `bpm`, `seed`.
- `202` → the job DTO below, `status:"queued"`.
- `402` → insufficient credits; the job was cancelled. Show the message and
  the balance.
- Identical `(capability, op, params)` resubmits return the SAME job — not an
  error, not a duplicate.

### 4.4 Poll — `GET /v1/jobs/{id}` (owner only; a wrong id is `404`)

**This is the exact wire shape. While the job is pending there is NO `result`
field, NO progress field, NO percentage — do not invent them.**

While queued (also the `202` response of submit):

```json
{"id": "0198d402-1b7e-7c55-8e21-6f3a9b0c4d17",
 "owner_id": "0198c1f0-5a2e-7d31-9f4a-2b8e6c1d0a42",
 "capability": "music.synth", "op": "generate",
 "status": "queued",
 "created_at": "2026-07-23T08:12:01.339Z",
 "updated_at": "2026-07-23T08:12:01.339Z"}
```

While running: same shape, `"status": "running"` (still no `result`).

When done — `result` appears, and only now:

```json
{"id": "0198d402-1b7e-7c55-8e21-6f3a9b0c4d17",
 "owner_id": "0198c1f0-5a2e-7d31-9f4a-2b8e6c1d0a42",
 "capability": "music.synth", "op": "generate",
 "status": "done", "cost_seconds": 60,
 "created_at": "2026-07-23T08:12:01.339Z",
 "updated_at": "2026-07-23T08:14:32.907Z",
 "result": {"candidates": [
   {"index": 0, "format": "wav",
    "metadata": {"bpm": 80, "keyscale": "C major", "duration": 60,
                 "vocal_language": "en", "seed": 555601209}}]}}
```

A candidate has only `index`, `format`, `metadata` — duration and friends live
INSIDE `metadata`, whose exact keys vary.

When failed:

```json
{"id": "0198d402-1b7e-7c55-8e21-6f3a9b0c4d17",
 "owner_id": "0198c1f0-5a2e-7d31-9f4a-2b8e6c1d0a42",
 "capability": "music.synth", "op": "generate",
 "status": "failed", "error": "generation backend unavailable",
 "created_at": "2026-07-23T08:12:01.339Z",
 "updated_at": "2026-07-23T08:12:44.120Z"}
```

**Polling rules:** poll every **1000 ms** (side-effect free). Statuses:
`queued` → `running` → exactly one of the terminal states **`done` /
`failed` / `cancelled`**. Stop polling ONLY on a terminal status. Do not add
a client-side timeout — jobs run on a serial GPU queue and can sit `queued`
for minutes; show elapsed time instead.

### 4.5 Play a candidate — `GET /v1/jobs/{id}/candidates/{k}`

Returns the raw audio bytes (`Content-Type: audio/wav` or `audio/mpeg`) of
candidate `k` (the `index` field). Owner only.

**An `<audio src>` cannot send the Authorization header.** Fetch the bytes with
`fetch(url, {headers: {Authorization: 'Bearer ...'}})`, turn the response into
a Blob, and set `audio.src = URL.createObjectURL(blob)`.

`404` = job not done, index out of range, or the candidate **expired**
(uncommitted candidates live ~24 h) — show "expired", don't treat it as an
auth failure.

### 4.6 History — `GET /v1/jobs?limit=20` (authenticated)

Newest first, keyset-paginated:

```json
{"items": [
   {"id": "0198d402-1b7e-7c55-8e21-6f3a9b0c4d17",
    "owner_id": "0198c1f0-5a2e-7d31-9f4a-2b8e6c1d0a42",
    "capability": "music.synth", "op": "generate",
    "status": "done", "cost_seconds": 60,
    "created_at": "2026-07-23T08:12:01.339Z",
    "updated_at": "2026-07-23T08:14:32.907Z"},
   {"id": "0198d3f1-88a0-7b02-b5c7-0d9e2f4a6c88",
    "owner_id": "0198c1f0-5a2e-7d31-9f4a-2b8e6c1d0a42",
    "capability": "music.synth", "op": "generate",
    "status": "failed", "error": "generation backend unavailable",
    "created_at": "2026-07-23T07:58:10.412Z",
    "updated_at": "2026-07-23T07:58:55.001Z"}],
 "next": ""}
```

List entries **never carry `result`** — to play a history item, `GET
/v1/jobs/{id}` first, then fetch its candidates. Empty `next` = last page
(pass a non-empty one back as `?after=`).

## 5. How credits work (display this correctly)

- Generation is billed **per second of produced audio**: **1 credit = $0.001
  per second** at launch. A 60-second track = 60 credits = $0.06; a 3-minute
  track ≈ $0.18.
- Submitting **reserves** an estimate (`duration × batch` credits, so 60 × 1 =
  60 credits here); insufficient balance → `402` and the job is cancelled
  unstarted.
- On completion the reserve is **settled** against the measured seconds; the
  difference is refunded automatically. A `failed` or `cancelled` job refunds
  in full — a job that delivered nothing costs nothing.
- Refresh the displayed balance after every terminal job status.

## 6. Do NOT

- Do not call `https://hygpo.com` from browser code — no CORS; use `/v1/...`
  through the proxy.
- Do not invent response fields (`progress`, `percent`, `eta`, `result` before
  `done`).
- Do not write the generation as a synchronous request — the `202` body has no
  audio in it.
- Do not put an `Authorization` header requirement on `<audio src>` — use the
  fetch-to-Blob pattern (§4.5).
- Do not treat `404` as a login problem — it means not-yours / not-found /
  expired, by design.
- Do not add npm dependencies, CDN scripts, or a build step.

## 7. Acceptance checklist (verify before declaring done)

1. `node server.js` starts with no install step; `http://localhost:8787`
   serves the app.
2. Login works; the balance renders in credits (milli ÷ 1000).
3. Entering a prompt and clicking Generate shows `queued` → `running` →
   `done` with elapsed time, then an audio player that actually plays.
4. The history list shows past jobs; clicking a `done` one loads and plays its
   candidates; an expired candidate shows "expired".
5. A `failed` job shows its `error` text; a `402` shows an insufficient-credits
   message; a mid-poll `401` recovers by re-login without user action.

---

*Beyond this app: candidates expire unless committed into your permanent
library (`POST /v1/jobs/{id}/commit`), which unlocks posts, publishing and
comments — the full reference lives at `https://hygpo.com/docs/api.md`.*
