nixamp 0.68.2 → 0.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/main.js +9 -0
  2. package/dist/mcp.d.ts +2 -0
  3. package/dist/mcp.js +12 -2
  4. package/dist/school-cli.d.ts +2 -0
  5. package/dist/school-cli.js +151 -0
  6. package/dist/school-client.d.ts +61 -0
  7. package/dist/school-client.js +148 -0
  8. package/dist/school-discovery.d.ts +32 -0
  9. package/dist/school-discovery.js +204 -0
  10. package/dist/school-mcp.d.ts +180 -0
  11. package/dist/school-mcp.js +143 -0
  12. package/dist/server.js +10 -0
  13. package/dist/station-mcp.d.ts +6 -6
  14. package/package.json +1 -1
  15. package/src/main.ts +8 -0
  16. package/src/mcp.ts +14 -2
  17. package/src/school-cli.ts +131 -0
  18. package/src/school-client.ts +180 -0
  19. package/src/school-discovery.ts +212 -0
  20. package/src/school-mcp.ts +153 -0
  21. package/src/server.ts +9 -0
  22. package/web/dist/assets/{ar-BUn23r3N.js → ar-BAUYJ7CG.js} +1 -1
  23. package/web/dist/assets/{cs-FwQ-DhBJ.js → cs-D5Njj-lD.js} +1 -1
  24. package/web/dist/assets/{da-C8OtQ3BT.js → da-CQ52TUpt.js} +1 -1
  25. package/web/dist/assets/{de-CldRtYgJ.js → de-slj7FWP_.js} +1 -1
  26. package/web/dist/assets/{es-DSPjVfW5.js → es-wy-duArI.js} +1 -1
  27. package/web/dist/assets/{fi-XgwcnMwU.js → fi-D8tuiwIa.js} +1 -1
  28. package/web/dist/assets/{fr-BH9o9Stx.js → fr-AlVe-2YY.js} +1 -1
  29. package/web/dist/assets/{hi-DuMMcoiX.js → hi-BkrehL5x.js} +1 -1
  30. package/web/dist/assets/{hu-B2Hiutdz.js → hu-_3tyj6FI.js} +1 -1
  31. package/web/dist/assets/{id-BNctMtkh.js → id-O2_s9_ZD.js} +1 -1
  32. package/web/dist/assets/{it-C_ADjBqX.js → it-D6kuTye8.js} +1 -1
  33. package/web/dist/assets/{main-BVDC-f0Q.js → main-Bh0dWH3R.js} +2 -2
  34. package/web/dist/assets/{nl-BPTAZ6gI.js → nl-DOdu_ltu.js} +1 -1
  35. package/web/dist/assets/{ru-CMSzyodc.js → ru-B39HLLYh.js} +1 -1
  36. package/web/dist/assets/{sv-BqdBoD8p.js → sv-DTDAE9DG.js} +1 -1
  37. package/web/dist/assets/{uk-C3lM_pHS.js → uk-DmM6P2_W.js} +1 -1
  38. package/web/dist/assets/{vi-CkVfVKSH.js → vi-8EoMeh4v.js} +1 -1
  39. package/web/dist/assets/{zh-BDiROZo0.js → zh-DkGyIChT.js} +1 -1
  40. package/web/dist/index.html +1 -1
  41. package/web/dist/sw.js +19 -19
package/src/main.ts CHANGED
@@ -10,6 +10,7 @@ import { t as uiMessage, i18n, terminalUiLanguage, uiLanguage } from "./i18n.ts"
10
10
  import { USAGE as STATION_USAGE } from "./station-usage.ts";
11
11
  import { LIBRARY_COMMANDS, LIBRARY_USAGE } from "./library-usage.ts";
12
12
  import { MUSIC_USAGE } from "./music-cli.ts";
13
+ import { SCHOOL_USAGE } from "./school-cli.ts";
13
14
  import { createApp, themes, type BrailleCanvas, type Container, type KeyEvent, type Theme } from "@profullstack/hqtui";
14
15
  import { fileURLToPath } from "node:url";
15
16
  import { resolve } from "node:path";
@@ -237,6 +238,7 @@ keeps serving its browser remote. One per user.
237
238
  nixamp admin who is connected, and re-stream to them
238
239
  nixamp station make a folder into a station others can join (see: nixamp station help)
239
240
  nixamp music the AI label's songs as files: catalog, buy (10¢, $1, $2), what you bought (nixamp music help)
241
+ nixamp school classes on backtoschool.help: teach a station live, check, prune (nixamp school help)
240
242
 
241
243
  From inside the player, d hands the music to a daemon without stopping it.
242
244
  `,
@@ -402,6 +404,7 @@ export function helpFor(topic: string | undefined): string {
402
404
  if (topic === "station" || topic === "stations") return STATION_USAGE;
403
405
  if (topic === "library") return LIBRARY_USAGE;
404
406
  if (topic === "music") return MUSIC_USAGE;
407
+ if (topic === "school") return SCHOOL_USAGE;
405
408
  return (topic ? TOPICS[topic] : undefined) ?? HELP;
406
409
  }
407
410
 
@@ -566,6 +569,11 @@ export async function main(): Promise<void> {
566
569
  process.exitCode = await music(rest);
567
570
  return;
568
571
  }
572
+ if (first === "school" || first === "class" || first === "classes") {
573
+ const { school } = await import("./school-cli.ts");
574
+ process.exitCode = await school(rest);
575
+ return;
576
+ }
569
577
  if (first === "station" || first === "stations") {
570
578
  const { station } = await import("./station-cli.ts");
571
579
  process.exitCode = await station(rest);
package/src/mcp.ts CHANGED
@@ -33,6 +33,7 @@ import { personaLines, readPersona, readVoices, writePersona } from "./profile.t
33
33
  import { STATION_TOOLS, callStationTool, isStationTool, type StationToolOptions } from "./station-mcp.ts";
34
34
  import { PUBLIC_TOOLS, callPublicTool, isPublicTool, type PublicToolOptions } from "./directory-mcp.ts";
35
35
  import { LIBRARY_TOOLS, callLibraryTool, isLibraryTool } from "./library-mcp.ts";
36
+ import { SCHOOL_TOOLS, callSchoolTool, isSchoolTool } from "./school-mcp.ts";
36
37
 
37
38
  export const PROTOCOL_VERSION = "2025-06-18";
38
39
 
@@ -271,6 +272,7 @@ export const TOOLS: ToolDefinition[] = [
271
272
  ...STATION_TOOLS,
272
273
  ...(PUBLIC_TOOLS as unknown as ToolDefinition[]),
273
274
  ...LIBRARY_TOOLS,
275
+ ...SCHOOL_TOOLS,
274
276
  ];
275
277
 
276
278
  interface TrollboxLine {
@@ -303,6 +305,8 @@ export interface McpOptions {
303
305
  /** How a file is described for its record; the tests hand in a fake. */
304
306
  describe?: (path: string) => Promise<Described>;
305
307
  say?: (line: string) => void;
308
+ /** Where the class tools go; backtoschool.help (or NIXAMP_SCHOOL_URL) otherwise. */
309
+ school?: string;
306
310
  }
307
311
 
308
312
  /** A kept transcript, as a tool answers it. */
@@ -365,6 +369,14 @@ export async function callTool(name: string, args: Record<string, unknown>, opti
365
369
  return failed((error as Error).message);
366
370
  }
367
371
  }
372
+ // backtoschool.help classes: the same token, sent to the school's host.
373
+ if (isSchoolTool(name)) {
374
+ return callSchoolTool(name, args, {
375
+ ...(options.fetcher ? { fetcher: options.fetcher } : {}),
376
+ ...(options.session !== undefined ? { token: options.session?.token ?? null } : {}),
377
+ ...(options.school ? { site: options.school } : {}),
378
+ });
379
+ }
368
380
  // Stations first: on this machine they need the daemon, not a sign-in.
369
381
  if (isStationTool(name) || isLibraryTool(name)) {
370
382
  const local = { ...(options.fetcher ? { fetcher: options.fetcher } : {}), ...(options.session !== undefined ? { session: options.session } : {}), ...(options.stations ?? {}) };
@@ -653,9 +665,9 @@ export async function handleMessage(message: Request, options: McpOptions = {}):
653
665
  return reply({
654
666
  protocolVersion: PROTOCOL_VERSION,
655
667
  capabilities: { tools: { listChanged: false } },
656
- serverInfo: { name: "nixamp", title: "nixamp: stations, watch parties, rooms and transcripts", version: "2" },
668
+ serverInfo: { name: "nixamp", title: "nixamp: stations, classes, watch parties, rooms and transcripts", version: "2" },
657
669
  instructions:
658
- "Watch parties on nixamp: a party lives on the site hosting the film and is bridged here as a room every nixamp client can join; codes are the ones that site shows, positions are seconds into the film. Rooms: say and read trollbox lines, hear a recording. Transcripts: a file, a link or a live is written down once by nixamp.com's own ear and kept under what it is; ask for it in another language and it is translated once and kept too.",
670
+ "Watch parties on nixamp: a party lives on the site hosting the film and is bridged here as a room every nixamp client can join; codes are the ones that site shows, positions are seconds into the film. Rooms: say and read trollbox lines, hear a recording. Classes on backtoschool.help: classes_list and class_get for anybody; class_teach puts a station link on the air as a class in one call, classes_check finds live classes whose broadcast stopped playing. Transcripts: a file, a link or a live is written down once by nixamp.com's own ear and kept under what it is; ask for it in another language and it is translated once and kept too.",
659
671
  });
660
672
  }
661
673
  // Notifications carry no id and are answered with silence, which is what
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `nixamp school`: classes on backtoschool.help from the terminal.
3
+ *
4
+ * A class is a live event on nixamp's accounts, so `nixamp login` is the
5
+ * sign-in here too. `teach` is the short path: a station link in, a class on
6
+ * the air out. `check` and `prune` exist because a class stays live after
7
+ * its broadcast is gone, and the page then shows a player that never plays.
8
+ */
9
+ import {
10
+ broadcastHealth, checkClasses, classUrl, createClass, deleteClass, getClass, listClasses, moveClass, teach, updateClass,
11
+ type SchoolClass,
12
+ } from "./school-client.ts";
13
+
14
+ export const SCHOOL_USAGE = `nixamp school: classes on backtoschool.help (sign in with \`nixamp login\`)
15
+
16
+ nixamp school [list] [--status live|scheduled|draft|ended] [--mine]
17
+ nixamp school show SLUG one class, and whether its broadcast plays
18
+ nixamp school teach LINK --title T [--description D] [--topic T] [--unlisted|--private]
19
+ make a class of a broadcast and start it now
20
+ nixamp school create --title T [LINK] [--starts 2026-11-01T17:00:00Z] [--description D] [--topic T]
21
+ nixamp school update SLUG [--title T] [--description D] [--topic T] [--link URL] [--public|--unlisted|--private]
22
+ nixamp school start SLUG
23
+ nixamp school end SLUG
24
+ nixamp school delete SLUG
25
+ nixamp school check your live classes, and which ones no longer play
26
+ nixamp school prune [--end|--delete] end or delete the ones that no longer play (prints them without a flag)
27
+
28
+ LINK is what plays: a station link (\`nixamp station show ID\`), a nixamp.com share link, a
29
+ Pairux room, or an https .m3u8/.mp4/.mp3. SLUG is the class's slug or its /live/ link.
30
+ --json machine-readable NIXAMP_SCHOOL_URL another site (backtoschool.help by default)
31
+ `;
32
+
33
+ function flag(argv: string[], name: string): string | undefined {
34
+ const at = argv.indexOf(name);
35
+ return at === -1 ? undefined : argv[at + 1];
36
+ }
37
+
38
+ /** The first argument after the command that is not a flag or a flag's value. */
39
+ function positional(argv: string[]): string | undefined {
40
+ const valued = new Set(["--title", "--description", "--topic", "--starts", "--link", "--status"]);
41
+ for (let at = 1; at < argv.length; at += 1) {
42
+ const one = argv[at]!;
43
+ if (valued.has(one)) { at += 1; continue; }
44
+ if (!one.startsWith("--")) return one;
45
+ }
46
+ return undefined;
47
+ }
48
+
49
+ function fields(argv: string[]): Record<string, unknown> {
50
+ const out: Record<string, unknown> = {};
51
+ for (const [name, key] of [["--title", "title"], ["--description", "description"], ["--topic", "topic"], ["--starts", "startsAt"], ["--link", "broadcastUrl"]] as const) {
52
+ const value = flag(argv, name);
53
+ if (value !== undefined) out[key] = value;
54
+ }
55
+ for (const visibility of ["public", "unlisted", "private"]) if (argv.includes(`--${visibility}`)) out["visibility"] = visibility;
56
+ return out;
57
+ }
58
+
59
+ function print(item: SchoolClass): void {
60
+ console.log(`${item.status.padEnd(9)} ${item.title}\n ${classUrl(item)}${item.broadcastUrl ? `\n plays ${item.broadcastUrl}` : ""}`);
61
+ }
62
+
63
+ export async function school(argv: string[]): Promise<number> {
64
+ const [sub = "list"] = argv;
65
+ const json = argv.includes("--json");
66
+ const slug = positional(argv);
67
+ const out = (value: unknown, human: () => void): void => { if (json) console.log(JSON.stringify(value, null, 2)); else human(); };
68
+ try {
69
+ if (sub === "help" || argv.includes("--help")) {
70
+ console.log(SCHOOL_USAGE);
71
+ return 0;
72
+ }
73
+ if (sub === "list") {
74
+ const status = flag(argv, "--status");
75
+ const items = await listClasses({ ...(status ? { status } : {}), mine: argv.includes("--mine") });
76
+ out(items, () => { if (items.length === 0) console.log("No classes match."); for (const one of items) print(one); });
77
+ return 0;
78
+ }
79
+ if (sub === "check" || sub === "prune") {
80
+ const checked = await checkClasses();
81
+ const broken = checked.filter((one) => one.health.ok === false);
82
+ const act = sub === "prune" ? (argv.includes("--delete") ? "delete" : argv.includes("--end") ? "end" : "") : "";
83
+ for (const { item } of act ? broken : []) {
84
+ if (act === "delete") await deleteClass(item.id);
85
+ else await moveClass(item.id, "end");
86
+ }
87
+ out(checked.map(({ item, health }) => ({ slug: item.slug, ok: health.ok, reason: health.reason, ...(act && health.ok === false ? { done: act } : {}) })), () => {
88
+ if (checked.length === 0) console.log("You have no live classes.");
89
+ for (const { item, health } of checked) console.log(`${health.ok === true ? "plays " : health.ok === false ? "BROKEN" : " ? "} ${item.slug} (${health.reason})`);
90
+ if (sub === "prune" && broken.length > 0) console.log(act ? `\n${act === "delete" ? "Deleted" : "Ended"} ${broken.length}.` : `\n${broken.length} no longer play. --end or --delete them.`);
91
+ });
92
+ return 0;
93
+ }
94
+ if (sub === "teach" || sub === "create") {
95
+ const input = fields(argv);
96
+ if (slug) input["broadcastUrl"] = slug;
97
+ if (!input["title"]) { console.error(`nixamp school ${sub} needs --title`); return 2; }
98
+ if (sub === "teach" && !input["broadcastUrl"]) { console.error("nixamp school teach needs the LINK that plays"); return 2; }
99
+ const item = sub === "teach" ? await teach(input) : await createClass(input);
100
+ out(item, () => { print(item); if (sub === "create") console.log(`\nnixamp school start ${item.slug} puts it on the air.`); });
101
+ return 0;
102
+ }
103
+ if (!slug) { console.error(`nixamp school ${sub} needs a class (its slug or /live/ link)\n\n${SCHOOL_USAGE}`); return 2; }
104
+ if (sub === "show") {
105
+ const item = await getClass(slug);
106
+ const health = await broadcastHealth(item.broadcastUrl);
107
+ out({ ...item, broadcast: health }, () => { print(item); console.log(` broadcast: ${health.ok === true ? "playing" : health.ok === false ? "NOT playing" : "unknown"} (${health.reason})`); });
108
+ return 0;
109
+ }
110
+ if (sub === "update") {
111
+ const item = await updateClass(slug, fields(argv));
112
+ out(item, () => print(item));
113
+ return 0;
114
+ }
115
+ if (sub === "start" || sub === "end") {
116
+ const item = await moveClass(slug, sub);
117
+ out(item, () => print(item));
118
+ return 0;
119
+ }
120
+ if (sub === "delete" || sub === "rm") {
121
+ await deleteClass(slug);
122
+ out({ ok: true, slug }, () => console.log(`Deleted ${slug}.`));
123
+ return 0;
124
+ }
125
+ console.error(`nixamp school: no command "${sub}".\n\n${SCHOOL_USAGE}`);
126
+ return 2;
127
+ } catch (error) {
128
+ console.error(`nixamp school: ${(error as Error).message}`);
129
+ return 1;
130
+ }
131
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * backtoschool.help as a client: its classes are nixamp's live events, served
3
+ * by the same process on another host, so one nixamp token signs in to both.
4
+ *
5
+ * The CLI (school-cli.ts) and the MCP tools (school-mcp.ts) are both this
6
+ * file; neither talks HTTP itself.
7
+ *
8
+ * `broadcastHealth` is the reason this exists as much as the CRUD: a class
9
+ * stays "live" until somebody ends it, long after the channel it points at
10
+ * was stopped, and the page then shows a player that never plays. It asks the
11
+ * broadcast itself, with only what the public link already carries.
12
+ */
13
+ import { readSession } from "./session.ts";
14
+
15
+ export const SCHOOL_SITE = "https://backtoschool.help";
16
+
17
+ type Fetcher = typeof fetch;
18
+
19
+ export interface SchoolOptions {
20
+ /** backtoschool.help unless NIXAMP_SCHOOL_URL says otherwise; the tests hand in a local server. */
21
+ site?: string;
22
+ /** The session on disk (or NIXAMP_TOKEN) unless given; null means signed out. */
23
+ token?: string | null;
24
+ fetcher?: Fetcher;
25
+ }
26
+
27
+ export interface SchoolClass {
28
+ id: string;
29
+ slug: string;
30
+ title: string;
31
+ status: string;
32
+ visibility: string;
33
+ kind: string;
34
+ version: number;
35
+ ownerId: string;
36
+ broadcastUrl?: string | null;
37
+ description?: string;
38
+ topic?: string;
39
+ hostName?: string;
40
+ startsAt?: string | null;
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ export class SchoolError extends Error {
45
+ constructor(message: string, readonly status = 0) {
46
+ super(message);
47
+ }
48
+ }
49
+
50
+ export function schoolSite(options: SchoolOptions = {}): string {
51
+ return (options.site ?? process.env["NIXAMP_SCHOOL_URL"] ?? SCHOOL_SITE).replace(/\/+$/, "");
52
+ }
53
+
54
+ export function classUrl(item: Pick<SchoolClass, "slug">, options: SchoolOptions = {}): string {
55
+ return `${schoolSite(options)}/live/${item.slug}`;
56
+ }
57
+
58
+ function tokenOf(options: SchoolOptions): string | null {
59
+ if (options.token !== undefined) return options.token;
60
+ return readSession()?.token ?? null;
61
+ }
62
+
63
+ async function call(path: string, init: { method?: string; body?: unknown; auth?: boolean } = {}, options: SchoolOptions = {}): Promise<Record<string, unknown>> {
64
+ const token = tokenOf(options);
65
+ if (init.auth && !token) throw new SchoolError("Not signed in. Run `nixamp login`, or set NIXAMP_TOKEN.", 401);
66
+ const headers: Record<string, string> = { accept: "application/json" };
67
+ if (token) headers["authorization"] = `Bearer ${token}`;
68
+ if (init.body !== undefined) headers["content-type"] = "application/json";
69
+ const response = await (options.fetcher ?? fetch)(`${schoolSite(options)}${path}`, {
70
+ method: init.method ?? "GET",
71
+ headers,
72
+ ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }),
73
+ });
74
+ const body = (await response.json().catch(() => ({}))) as Record<string, unknown>;
75
+ if (!response.ok) throw new SchoolError(String(body["error"] ?? `backtoschool.help answered ${response.status}`), response.status);
76
+ return body;
77
+ }
78
+
79
+ const eventOf = (body: Record<string, unknown>): SchoolClass => (body["event"] ?? body) as SchoolClass;
80
+ const ref = (value: string): string => encodeURIComponent(value.replace(/^.*\/live\//, "").replace(/[?#].*$/, ""));
81
+
82
+ export async function listClasses(query: { status?: string; kind?: string; mine?: boolean; limit?: number } = {}, options: SchoolOptions = {}): Promise<SchoolClass[]> {
83
+ const params = new URLSearchParams();
84
+ if (query.status) params.set("status", query.status);
85
+ if (query.kind) params.set("kind", query.kind);
86
+ if (query.mine) params.set("mine", "true");
87
+ params.set("limit", String(query.limit ?? 60));
88
+ const body = await call(`/api/v1/events?${params}`, { auth: query.mine === true }, options);
89
+ return (body["events"] as SchoolClass[]) ?? [];
90
+ }
91
+
92
+ /** A class by its slug, id, or the /live/<slug> link. */
93
+ export async function getClass(slug: string, options: SchoolOptions = {}): Promise<SchoolClass> {
94
+ return eventOf(await call(`/api/v1/events/${ref(slug)}`, {}, options));
95
+ }
96
+
97
+ export async function createClass(input: Record<string, unknown>, options: SchoolOptions = {}): Promise<SchoolClass> {
98
+ return eventOf(await call("/api/v1/events", { method: "POST", body: { kind: "class", visibility: "public", ...input }, auth: true }, options));
99
+ }
100
+
101
+ export async function updateClass(slug: string, patch: Record<string, unknown>, options: SchoolOptions = {}): Promise<SchoolClass> {
102
+ const current = await getClass(slug, options);
103
+ return eventOf(await call(`/api/v1/events/${ref(current.id)}`, { method: "PATCH", body: { ...patch, version: current.version }, auth: true }, options));
104
+ }
105
+
106
+ /** start, end, doors, encore, cancel or archive. Each wants the version it saw, so it is read first. */
107
+ export async function moveClass(slug: string, action: "start" | "end" | "doors" | "encore" | "cancel" | "archive", options: SchoolOptions = {}): Promise<SchoolClass> {
108
+ const current = await getClass(slug, options);
109
+ return eventOf(await call(`/api/v1/events/${ref(current.id)}/${action}`, { method: "POST", body: { version: current.version }, auth: true }, options));
110
+ }
111
+
112
+ export async function deleteClass(slug: string, options: SchoolOptions = {}): Promise<void> {
113
+ await call(`/api/v1/events/${ref(slug)}`, { method: "DELETE", auth: true }, options);
114
+ }
115
+
116
+ /** Make a class of a broadcast and put it on the air in one go. */
117
+ export async function teach(input: Record<string, unknown>, options: SchoolOptions = {}): Promise<SchoolClass> {
118
+ const made = await createClass(input, options);
119
+ return moveClass(made.slug, "start", options);
120
+ }
121
+
122
+ export interface Health {
123
+ ok: boolean | null;
124
+ reason: string;
125
+ }
126
+
127
+ /**
128
+ * Does this broadcast play? true and false are answers; null is "cannot tell
129
+ * from here" (a Pairux room), which a prune must leave alone.
130
+ */
131
+ export async function broadcastHealth(link: string | null | undefined, fetcher: Fetcher = fetch, timeoutMs = 8000): Promise<Health> {
132
+ if (!link) return { ok: false, reason: "no broadcast link" };
133
+ let url: URL;
134
+ try {
135
+ url = new URL(link);
136
+ } catch {
137
+ return { ok: false, reason: "not a link" };
138
+ }
139
+ const get = (target: string, headers: Record<string, string> = {}): Promise<Response> =>
140
+ fetcher(target, { headers, signal: AbortSignal.timeout(timeoutMs) });
141
+ try {
142
+ // A station page: its own token opens its state.
143
+ const station = /^\/station\/([a-z0-9]{10})\/?$/.exec(url.pathname)?.[1];
144
+ if (station && url.hostname.endsWith(".nixamp.com")) {
145
+ const response = await get(`${url.origin}/api/v1/stations/${station}/state?t=${encodeURIComponent(url.searchParams.get("t") ?? "")}`);
146
+ if (!response.ok) return { ok: false, reason: `station ${station} answered ${response.status}` };
147
+ const state = (await response.json()) as { state?: string };
148
+ return state.state === "live" ? { ok: true, reason: `station ${station} is live` } : { ok: false, reason: `station ${station} is ${state.state ?? "not live"}` };
149
+ }
150
+ // nixamp.com/?url=<server>/view/<listen key>&play=channel:<id>: the listen key reads the server's channels.
151
+ if ((url.hostname === "nixamp.com" || url.hostname === "www.nixamp.com") && url.searchParams.get("url")) {
152
+ const server = new URL(url.searchParams.get("url")!);
153
+ const key = /\/view\/([^/?#]+)/.exec(server.pathname)?.[1] ?? "";
154
+ const play = url.searchParams.get("play") ?? "live";
155
+ const response = await get(`${server.origin}/api/streams?k=${encodeURIComponent(key)}`);
156
+ if (!response.ok) return { ok: false, reason: `${server.host} answered ${response.status}` };
157
+ const streams = (await response.json()) as { server?: { live?: boolean }; channels?: Array<{ id: string }> };
158
+ if (play.startsWith("channel:")) {
159
+ const id = play.slice("channel:".length);
160
+ return (streams.channels ?? []).some((one) => one.id === id)
161
+ ? { ok: true, reason: `channel ${id} is on ${server.hostname}` }
162
+ : { ok: false, reason: `channel ${id} is no longer on ${server.hostname}` };
163
+ }
164
+ if (play === "live") return streams.server?.live ? { ok: true, reason: `${server.hostname} is live` } : { ok: false, reason: `${server.hostname} is not live` };
165
+ return { ok: true, reason: `${server.hostname} is up` };
166
+ }
167
+ if (url.hostname === "pairux.com" || url.hostname === "www.pairux.com") return { ok: null, reason: "a Pairux room; cannot tell from here" };
168
+ // Direct media: one byte is enough to know it is there.
169
+ const response = await get(url.href, { range: "bytes=0-0" });
170
+ return response.ok ? { ok: true, reason: `${url.hostname} serves it` } : { ok: false, reason: `${url.hostname} answered ${response.status}` };
171
+ } catch (error) {
172
+ return { ok: false, reason: `unreachable: ${(error as Error).message}` };
173
+ }
174
+ }
175
+
176
+ /** Your live classes, each with whether its broadcast plays. */
177
+ export async function checkClasses(options: SchoolOptions = {}, status = "live"): Promise<Array<{ item: SchoolClass; health: Health }>> {
178
+ const items = await listClasses({ mine: true, status }, options);
179
+ return Promise.all(items.map(async (item) => ({ item, health: await broadcastHealth(item.broadcastUrl, options.fetcher ?? fetch) })));
180
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * backtoschool.help's own front door for agents: llms.txt, an OpenAPI
3
+ * document of the class API, and MCP at /mcp/public (reading) and /mcp
4
+ * (teaching, as the bearer).
5
+ *
6
+ * The school is served by the nixamp process under another Host, so before
7
+ * this its /llms.txt and /mcp answered as nixamp.com: an agent landing on
8
+ * the school was told about radio stations and handed no class tools. The
9
+ * routes here run only for the school's host, ahead of nixamp's.
10
+ *
11
+ * The tools are school-mcp.ts, called over HTTP against the school's own
12
+ * origin with the caller's token, exactly as from a CLI. Sign-in is nixamp's:
13
+ * one account, one token, and nixamp.com is the authorization server.
14
+ */
15
+ import type { IncomingMessage, ServerResponse } from "node:http";
16
+ import { SCHOOL_PUBLIC_TOOLS, SCHOOL_TOOLS, callSchoolTool, isSchoolPublicTool, isSchoolTool } from "./school-mcp.ts";
17
+ import { SCHOOL_SITE } from "./school-client.ts";
18
+
19
+ export interface SchoolDiscoveryContext {
20
+ /** This school's origin, e.g. https://backtoschool.help. */
21
+ site: string;
22
+ /** nixamp.com: where tokens come from and where OAuth is. */
23
+ accountSite: string;
24
+ version: string;
25
+ whoIs: (token: string) => Promise<unknown>;
26
+ token: string;
27
+ readBody: (request: IncomingMessage, limit: number) => Promise<string>;
28
+ json: (response: ServerResponse, status: number, body: unknown) => void;
29
+ fetcher?: typeof fetch;
30
+ }
31
+
32
+ const CORS = {
33
+ "access-control-allow-origin": "*",
34
+ "access-control-allow-methods": "GET, POST, DELETE, OPTIONS",
35
+ "access-control-allow-headers": "authorization, content-type, mcp-session-id, mcp-protocol-version",
36
+ };
37
+
38
+ const PATHS = new Set(["/llms.txt", "/openapi.json", "/mcp", "/mcp/public", "/.well-known/oauth-protected-resource"]);
39
+
40
+ /** Is this host the school? backtoschool.help (or NIXAMP_SCHOOL_URL), with or without www. */
41
+ export function isSchoolSite(site: string | undefined, env: NodeJS.ProcessEnv = process.env): boolean {
42
+ if (!site) return false;
43
+ const bare = (value: string): string => new URL(value).hostname.replace(/^www\./, "");
44
+ try {
45
+ return bare(site) === bare(env["NIXAMP_SCHOOL_URL"] ?? SCHOOL_SITE);
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ export function schoolLlmsTxt(site: string, accountSite: string): string {
52
+ return `# BackToSchool.help
53
+
54
+ > Live classes anybody can teach and anybody can join: a broadcast (a nixamp station, a screen share, a video link) with chat and raised hands around it. Free to watch. Accounts are nixamp accounts.
55
+
56
+ ## Web
57
+ - ${site}/ : classes live now and coming up
58
+ - ${site}/live/<slug> : one class
59
+
60
+ ## MCP
61
+ - Public, no account (what is on, one class): ${site}/mcp/public
62
+ - Signed in (teach, start, end, delete, check your classes): ${site}/mcp , a nixamp token as the bearer (\`nixamp token create\`) or OAuth via ${accountSite}
63
+ - Local over stdio: npx -y nixamp mcp (the class tools are among nixamp's)
64
+
65
+ Tools: classes_list, class_get, class_teach (a broadcast link in, a live class out), class_create, class_update, class_start, class_end, class_delete, classes_check (which of your live classes no longer play).
66
+
67
+ ## CLI
68
+ - Install: curl -fsSL ${accountSite}/install.sh | sh (or npx -y nixamp)
69
+ - Sign in: nixamp login
70
+ - nixamp school list --status live
71
+ - nixamp school teach <station link> --title "..." make a class and start it
72
+ - nixamp school check | prune --end find and end classes whose broadcast stopped
73
+ - nixamp school help
74
+
75
+ ## API
76
+ - OpenAPI: ${site}/openapi.json
77
+ - GET ${site}/api/v1/events?status=live : classes (public ones; ?mine=true with a token)
78
+ - GET ${site}/api/v1/events/<slug> : one class
79
+ - POST ${site}/api/v1/events : make one {title, broadcastUrl, description, topic, visibility, startsAt}
80
+ - POST ${site}/api/v1/events/<slug>/start|end {version} : the version you last read
81
+ - PATCH ${site}/api/v1/events/<slug> {..., version}, DELETE ${site}/api/v1/events/<slug>
82
+ - Auth: Authorization: Bearer <nixamp token>
83
+
84
+ ## What a broadcast can be
85
+ A nixamp station link (https://<server>.nixamp.com:4321/station/<id>?t=<token>), a nixamp.com share link, a Pairux room, or a direct https .m3u8/.mp4/.mp3. Only broadcast what you have the right to: your own teaching, or openly licensed courses (MIT OpenCourseWare is CC BY-NC-SA: free, credited, no ads).
86
+ `;
87
+ }
88
+
89
+ export function schoolOpenapi(site: string, accountSite: string, version: string): Record<string, unknown> {
90
+ const ok = { description: "OK", content: { "application/json": { schema: { type: "object" } } } };
91
+ const auth = { security: [{ bearer: [] }] };
92
+ const slug = [{ name: "slug", in: "path", required: true, schema: { type: "string" }, description: "The class's slug or id." }];
93
+ const versioned = { content: { "application/json": { schema: { type: "object", properties: { version: { type: "integer" } }, required: ["version"] } } } };
94
+ const fields = {
95
+ title: { type: "string", maxLength: 160 }, description: { type: "string", maxLength: 5000 }, topic: { type: "string", maxLength: 100 },
96
+ broadcastUrl: { type: "string", format: "uri" }, visibility: { type: "string", enum: ["public", "unlisted", "private"] },
97
+ startsAt: { type: "string", format: "date-time" }, chatEnabled: { type: "boolean" }, handRaiseEnabled: { type: "boolean" },
98
+ };
99
+ return {
100
+ openapi: "3.1.0",
101
+ info: { title: "BackToSchool.help", version, description: "Live classes. Reading is open; teaching wants a nixamp token as the bearer.", license: { name: "MIT", identifier: "MIT" } },
102
+ servers: [{ url: site }],
103
+ externalDocs: { description: "llms.txt: MCP, CLI and API in one place", url: `${site}/llms.txt` },
104
+ components: { securitySchemes: { bearer: { type: "http", scheme: "bearer", description: `A nixamp token (\`nixamp token create\`) or an OAuth access token from ${accountSite}.` } } },
105
+ paths: {
106
+ "/api/v1/events": {
107
+ get: {
108
+ summary: "Classes", tags: ["classes"], responses: { 200: ok },
109
+ parameters: [
110
+ { name: "status", in: "query", schema: { type: "string", enum: ["draft", "scheduled", "starting", "live", "encore", "ended", "cancelled", "archived"] } },
111
+ { name: "mine", in: "query", schema: { type: "boolean" }, description: "Only yours (needs the bearer)." },
112
+ { name: "limit", in: "query", schema: { type: "integer", maximum: 100 } },
113
+ ],
114
+ },
115
+ post: { summary: "Make a class (a draft; start it to go live)", tags: ["classes"], ...auth, requestBody: { content: { "application/json": { schema: { type: "object", properties: { kind: { type: "string", enum: ["class", "talk", "concert"] }, ...fields }, required: ["title"] } } } }, responses: { 200: ok } },
116
+ },
117
+ "/api/v1/events/{slug}": {
118
+ parameters: slug,
119
+ get: { summary: "One class", tags: ["classes"], responses: { 200: ok } },
120
+ patch: { summary: "Change a class you host", tags: ["classes"], ...auth, requestBody: { content: { "application/json": { schema: { type: "object", properties: { ...fields, version: { type: "integer" } }, required: ["version"] } } } }, responses: { 200: ok } },
121
+ delete: { summary: "Delete a class you host", tags: ["classes"], ...auth, responses: { 200: ok } },
122
+ },
123
+ "/api/v1/events/{slug}/start": { parameters: slug, post: { summary: "Go live", tags: ["classes"], ...auth, requestBody: versioned, responses: { 200: ok } } },
124
+ "/api/v1/events/{slug}/end": { parameters: slug, post: { summary: "End it", tags: ["classes"], ...auth, requestBody: versioned, responses: { 200: ok } } },
125
+ "/api/v1/events/{slug}/chat": { parameters: slug, get: { summary: "The class chat", tags: ["chat"], responses: { 200: ok } }, post: { summary: "Say something", tags: ["chat"], ...auth, responses: { 200: ok } } },
126
+ "/api/v1/auth/me": { get: { summary: "Who the bearer is", tags: ["auth"], ...auth, responses: { 200: ok } } },
127
+ "/mcp/public": { post: { summary: "MCP (streamable HTTP, JSON-RPC): classes_list, class_get", tags: ["mcp"], responses: { 200: ok } } },
128
+ "/mcp": { post: { summary: "MCP (streamable HTTP, JSON-RPC): every class tool, as the bearer", tags: ["mcp"], ...auth, responses: { 200: ok } } },
129
+ },
130
+ };
131
+ }
132
+
133
+ type Message = { id?: unknown; method?: unknown; params?: Record<string, unknown> };
134
+
135
+ export async function handleSchoolDiscovery(request: IncomingMessage, response: ServerResponse, path: string, ctx: SchoolDiscoveryContext): Promise<boolean> {
136
+ if (!PATHS.has(path)) return false;
137
+ const method = request.method ?? "GET";
138
+ const site = ctx.site.replace(/\/+$/, "");
139
+ if (method === "OPTIONS") {
140
+ response.writeHead(204, CORS);
141
+ response.end();
142
+ return true;
143
+ }
144
+ if (path === "/llms.txt" || path === "/openapi.json" || path === "/.well-known/oauth-protected-resource") {
145
+ if (method !== "GET" && method !== "HEAD") { ctx.json(response, 405, { error: "GET only" }); return true; }
146
+ const [type, body] = path === "/llms.txt"
147
+ ? ["text/plain; charset=utf-8", schoolLlmsTxt(site, ctx.accountSite)]
148
+ : ["application/json; charset=utf-8", JSON.stringify(path === "/openapi.json"
149
+ ? schoolOpenapi(site, ctx.accountSite, ctx.version)
150
+ : { resource: site, authorization_servers: [ctx.accountSite], bearer_methods_supported: ["header"], resource_name: "BackToSchool.help" }, null, 2)];
151
+ response.writeHead(200, { ...CORS, "content-type": type, "cache-control": "public, max-age=300" });
152
+ response.end(method === "HEAD" ? undefined : body);
153
+ return true;
154
+ }
155
+ // MCP. GET has nothing to push; DELETE ends a session nobody keeps.
156
+ if (method === "GET") { response.writeHead(405, { ...CORS, allow: "POST, DELETE" }); response.end(); return true; }
157
+ if (method === "DELETE") { response.writeHead(204, CORS); response.end(); return true; }
158
+ if (method !== "POST") { ctx.json(response, 405, { error: "POST a JSON-RPC message" }); return true; }
159
+ const signed = path === "/mcp";
160
+ if (signed && (await ctx.whoIs(ctx.token)) === null) {
161
+ const error = JSON.stringify({ error: "sign in: a nixamp token as the bearer (`nixamp token create`), or OAuth" });
162
+ response.writeHead(401, {
163
+ ...CORS,
164
+ "content-type": "application/json; charset=utf-8",
165
+ "www-authenticate": `Bearer resource_metadata="${site}/.well-known/oauth-protected-resource"`,
166
+ });
167
+ response.end(error);
168
+ return true;
169
+ }
170
+ let parsed: unknown;
171
+ try {
172
+ parsed = JSON.parse(await ctx.readBody(request, 256 * 1024));
173
+ } catch {
174
+ ctx.json(response, 400, { jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } });
175
+ return true;
176
+ }
177
+ const tools = signed ? SCHOOL_TOOLS : SCHOOL_PUBLIC_TOOLS;
178
+ const allowed = signed ? isSchoolTool : isSchoolPublicTool;
179
+ const answers: unknown[] = [];
180
+ for (const message of (Array.isArray(parsed) ? parsed : [parsed]) as Message[]) {
181
+ const id = message?.id ?? null;
182
+ const name = typeof message?.method === "string" ? message.method : "";
183
+ if (name.startsWith("notifications/")) continue;
184
+ if (name === "initialize") {
185
+ answers.push({ jsonrpc: "2.0", id, result: {
186
+ protocolVersion: (message.params?.["protocolVersion"] as string | undefined) ?? "2025-06-18",
187
+ capabilities: { tools: { listChanged: false } },
188
+ serverInfo: { name: "backtoschool", title: "BackToSchool.help: live classes", version: "1" },
189
+ instructions: signed
190
+ ? "Live classes on BackToSchool.help. classes_list / class_get to see what is on; class_teach puts a broadcast (a nixamp station link, a Pairux room, an https video) on the air as a class in one call; classes_check finds your live classes whose broadcast stopped, and class_end / class_delete clear them. Only broadcast what you have the right to."
191
+ : "Live classes on BackToSchool.help: classes_list and class_get. To teach, use /mcp with a nixamp token as the bearer.",
192
+ } });
193
+ } else if (name === "ping") {
194
+ answers.push({ jsonrpc: "2.0", id, result: {} });
195
+ } else if (name === "tools/list") {
196
+ answers.push({ jsonrpc: "2.0", id, result: { tools } });
197
+ } else if (name === "tools/call" && allowed(String(message.params?.["name"] ?? ""))) {
198
+ const result = await callSchoolTool(String(message.params!["name"]), (message.params?.["arguments"] ?? {}) as Record<string, unknown>, {
199
+ site, token: ctx.token || null, ...(ctx.fetcher ? { fetcher: ctx.fetcher } : {}),
200
+ });
201
+ answers.push({ jsonrpc: "2.0", id, result });
202
+ } else if (name === "tools/call") {
203
+ answers.push({ jsonrpc: "2.0", id, error: { code: -32001, message: "only classes_list and class_get are public; for the rest use /mcp with a nixamp token as the bearer (`nixamp token create`), or OAuth" } });
204
+ } else {
205
+ answers.push({ jsonrpc: "2.0", id, error: { code: -32601, message: `no method ${name}` } });
206
+ }
207
+ }
208
+ if (answers.length === 0) { response.writeHead(202, CORS); response.end(); return true; }
209
+ response.writeHead(200, { ...CORS, "content-type": "application/json; charset=utf-8" });
210
+ response.end(JSON.stringify(Array.isArray(parsed) ? answers : answers[0]));
211
+ return true;
212
+ }