diff --git a/AGENTS.md b/AGENTS.md
index f7301ef..c5964af 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -595,8 +595,22 @@ session with **no other memory of this project**. So:
(link to Cordn + copy Docker + scan its QR), never an embedded runtime — the
phone-hosting story belongs to Cordn's own app. ⚠️ `CORDN_APP_URL` currently
points at `cordn.net`; the exact Android-app deep link is unconfirmed — fill
- it in when known. **Not built (future):** publishing the coordinator on the
- profile (discovery) and crew-level coordinators.
+ it in when known.
+ **⚠️ Crew-level Cordn chat ALREADY EXISTS — do NOT rebuild it
+ (2026-09-29):** `src/lib/cordn/{client,crypto,endpoint,types}.ts` +
+ `useCordnChat.ts` + `CrewChat.tsx` + `crew/CrewCoordinatorCard.tsx`, driven by
+ a crew's **`coordinator` tag** `['coordinator', , ,
+ ]` (`SkateCrew.coordinator`, parsed in `types.ts`,
+ `resolveCordnEndpoint()` → `CordnEndpoint`), documented in NIP.md ("Private
+ Crew Chat (Cordn / MLS)"). Every crew already has built-in NIP-17 E2E chat; the
+ tag *upgrades* it to sealed NIP-44 over ContextVM. **The only gap bridged this
+ session:** the crew form accepted a URL only, so a coordinator run from
+ Cordn's phone app (an nprofile, not a URL) was un-enterable — `CrewForm` now
+ uses the shared `parseCrewCoordinatorInput` / `crewCoordinatorInputValue`
+ (`lib/coordinator.ts`) and has a **"Use my coordinator"** button that prefills
+ from Settings → Coordinator. **Still future:** profile discovery, and real MLS
+ (`ts-mls`) instead of group-key crypto. **Lesson: grep `src` for the feature
+ before building it — this was nearly duplicated.**
- **Empty-map nudge (2026-09-28):** `MapPage` distinguishes the two empties — the
existing "No spots match" card is for *filters* (`filteredSpots.length === 0 &&
allLocations.length > 0`) and a new "No spots mapped yet — be the first" card
diff --git a/NIP.md b/NIP.md
index 4a1a989..cc8e065 100644
--- a/NIP.md
+++ b/NIP.md
@@ -2241,7 +2241,7 @@ bare `p` tag on the crew is only a claim until the member self-labels.
| `goal` | Optional **crew treasury** — the event id of a NIP-75 Zap Goal (kind `9041`) the crew is raising funds for. See Crew Treasury below. |
| `looking-for` | Optional **open call** — a Lab craft the crew is hiring for. Repeatable. Slugs: `filmer`, `photographer`, `editor`, `coach`, `builder`, `artist`, `musician`, `members`. Surfaces the crew's needs so matching Lab providers can reach out. |
| `sponsor` | Optional **sponsoring shop** — a NIP-15 stall coordinate (`30017:pubkey:d`) for a local skate shop that backs the crew. Renders a "Sponsored by [Shop]" banner linking to the shop. Only shown when the shop resolves. |
-| `coordinator` | Optional, **advanced/self-host**: routes the crew's private chat through a founder-run **Cordn** coordinator instead of the built-in NIP-17 chat. Shape: `["coordinator", , , ]`. `` is an `http(s)`/`wss` endpoint of a Cordn coordinator (a Delivery Service reached over ContextVM). The crew event stays public and discoverable; message content and keys live client-side; nothing crew-specific is published to public relays. `` is the coordinator service pubkey (hex) for ContextVM addressing; `` is the human (hex) who runs it, if delegated. Not required — every crew has E2E chat without it. |
+| `coordinator` | Optional, **advanced/self-host**: routes the crew's private chat through a founder-run **Cordn** coordinator instead of the built-in NIP-17 chat. Shape: `["coordinator", , , ]`. `` is an `http(s)`/`wss` endpoint of a Cordn coordinator (a Delivery Service reached over ContextVM). The crew event stays public and discoverable; message content and keys live client-side; nothing crew-specific is published to public relays. `` is the coordinator service pubkey (hex) for ContextVM addressing; `` is the human (hex) who runs it, if delegated. Not required — every crew has E2E chat without it. Clients that accept a pasted pointer **MUST** also accept the formats a coordinator run from Cordn's own app yields: an `nprofile`, or a `https://cordn.net/chat/coordinators?c=` link — deriving `` from the nprofile's relay hints (falling back to the ContextVM relay) and `` from its pubkey. A plain `http(s)`/`wss` endpoint remains valid. |
| `video` | Optional **crew video / montage** ("The Part") — a **YouTube** or **Instagram** URL, rendered as the crew's headline embed. Only these two sources are accepted (skate parts live on YouTube, clips on Instagram); any other URL is ignored. No native/self-hosted video. Clients must parse it to a known embed (YouTube video id / Instagram shortcode) and reject anything else rather than render an untrusted iframe. |
| `crew-role` | Optional **skate role** assigned to a roster member. Shape: `["crew-role", , ]`, repeatable (a member may hold several). `` ∈ the full Lab-craft set — `filmer`, `photographer`, `editor`, `coach`, `builder`, `artist`, `graffiti`, `tattoo`, `trainer`, `musician`, `judge` — plus the crew-specific `manager` and `og`. The Lab-craft roles mirror the Lab profession kinds (35001–35012), so a crew's "filmer" speaks the same language as a Lab Filmer profile. These are the *functions* on a skate team (the filmer is as vital as the skaters) and are purely a **flex/label** — they grant **no powers** (only the `admin` marker does). Only honoured for the founder or a listed member; roles on non-roster pubkeys are ignored. |
diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx
index 5eeed5f..1fa000e 100644
--- a/src/components/CrewForm.tsx
+++ b/src/components/CrewForm.tsx
@@ -22,6 +22,8 @@ import { CrewSponsorPicker } from '@/components/crew/CrewSponsorPicker';
import { NofferField } from '@/components/NofferField';
import { SKATE_CREW_KIND, SKATE_SPOT_KIND, SKATE_SHOP_KIND, parseAddrCoord, ZAP_GOAL_KIND, CREW_VIBE_LABELS, CREW_FOCUS_LABELS, CREW_FOCUS_DESCRIPTIONS, CREW_FOCUS_TAG, CREW_LOOKING_FOR_OPTIONS, CREW_ROLE_META, CREW_ROLE_ORDER, SHOW_CREW_TREASURY, isValidNoffer, type SkateCrew, type CrewFocus, type CrewRole } from '@/lib/types';
import { useCrewGoal } from '@/hooks/useCrewGoal';
+import { useCoordinator } from '@/hooks/useCoordinator';
+import { coordinatorAddUrl, crewCoordinatorInputValue, parseCrewCoordinatorInput, shortNpub } from '@/lib/coordinator';
import { useRelays } from '@/lib/relays';
import { cn } from '@/lib/utils';
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye, Store, Film, Check, ArrowRight, ArrowLeft as ArrowLeftIcon, Server } from 'lucide-react';
@@ -278,9 +280,15 @@ export function CrewForm({ existing }: CrewFormProps) {
const toggleLookingFor = (slug: string) =>
setLookingFor((prev) => (prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug]));
- // Sponsoring shop coordinate (30017:pubkey:d) and Cordn/MLS coordinator URL.
+ // Sponsoring shop coordinate (30017:pubkey:d) and Cordn/MLS coordinator pointer.
const [sponsorRef, setSponsorRef] = useState(existing?.sponsorRef ?? '');
- const [coordinatorUrl, setCoordinatorUrl] = useState(existing?.coordinator?.url ?? '');
+ const [coordinatorInput, setCoordinatorInput] = useState(
+ crewCoordinatorInputValue(existing?.coordinator),
+ );
+ // The coordinator the user set in Settings → Coordinator, for a one-tap prefill.
+ const { coordinator: myCoordinator } = useCoordinator();
+ // Live parse so the founder gets feedback (and the publish path reuses it).
+ const parsedCoordinator = parseCrewCoordinatorInput(coordinatorInput);
// Crew video / montage — YouTube or Instagram URL ("The Part").
const [videoUrl, setVideoUrl] = useState(existing?.videoUrl ?? '');
@@ -513,10 +521,14 @@ export function CrewForm({ existing }: CrewFormProps) {
tags.push(['sponsor', sponsorCoord.coord]);
}
- // Cordn / MLS coordinator for the crew's encrypted chat (https/wss URL only).
- const coordUrl = coordinatorUrl.trim();
- if (coordUrl && /^(https?|wss?):\/\//i.test(coordUrl)) {
- tags.push(['coordinator', coordUrl]);
+ // Cordn / MLS coordinator for the crew's encrypted chat. Accepts the same
+ // pointer formats as Settings → Coordinator (nprofile / cordn.net link /
+ // npub / hex) as well as a plain endpoint URL, so a coordinator run from
+ // Cordn's phone app works too. Tag: ['coordinator', url, pubkey?].
+ if (parsedCoordinator) {
+ const coordinatorTag = ['coordinator', parsedCoordinator.url];
+ if (parsedCoordinator.pubkey) coordinatorTag.push(parsedCoordinator.pubkey);
+ tags.push(coordinatorTag);
}
// Crew video / montage — only stored if it's a valid YouTube/Instagram URL.
@@ -1088,16 +1100,40 @@ export function CrewForm({ existing }: CrewFormProps) {
>
Cordn coordinator
{' '}
- (a small self-hosted MLS delivery service). Paste its URL to route chat through it instead. Leave blank
- to use the built-in chat.
+ — even on an old phone. Paste its nprofile, a cordn.net link, or an
+ endpoint URL. Leave blank to use the built-in chat.