name: aeries-daeya description: "Aeries Daeya: Privacy-first avatar social platform. Create a digital identity, style it with AI from photo references, share experiences without exposing your real face. Public discovery feed + mutation explorer for iterating on looks and backgrounds." version: 1 vars: project_name: "" service_name: "daeya-api" worker_name: "media-worker" app_name: "studio-ui" steps: # --- Infrastructure --- create-project: action: api method: POST endpoint: /project body: name: "{{ .vars.project_name }}" description: "Aeries Daeya: Privacy-first avatar social platform" outputs: - project_id: .data.name - domain: .data.domain add-db: description: CockroachDB for characters, looks, albums, posts, mutation history depends_on: [create-project] action: api method: POST endpoint: "/projects/{{ .outputs.create-project.project_id }}/components" body: { type: postgres, name: "daeya-db" } add-redis: description: Redis for SSE pub/sub and AI generation job queue depends_on: [create-project] action: api method: POST endpoint: "/projects/{{ .outputs.create-project.project_id }}/components" body: { type: redis, name: "event-bus" } add-components: description: Add daeya-api + media-worker + studio-ui as a single atomic commit depends_on: [add-db, add-redis] action: api method: POST endpoint: "/projects/{{ .outputs.create-project.project_id }}/components/batch" body: components: - type: service name: "{{ .vars.service_name }}" - type: worker name: "{{ .vars.worker_name }}" - type: app-react name: "{{ .vars.app_name }}" wait-infra: description: Wait for CI to build and deploy all components depends_on: [add-components] action: wait_pipeline project_id: "{{ .outputs.create-project.project_id }}" max_attempts: 120 poll_interval: 10 # --- Feature 1: Avatar & Look Data Model --- implement-avatar-model: description: "Characters, looks, albums, posts, mutation history — full data layer + CRUD API + public discovery endpoints" depends_on: [wait-infra] action: api method: POST endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" body: prompt: "/implement-feature avatar-model --requirements 'IMPORTANT: Do NOT modify or reimplement the existing skeleton auth system. The skeleton already ships working OTP email auth at /auth/otp/send and /auth/otp/verify using NOTIFY_URL and NOTIFY_API_KEY env vars. Only add new domain-specific endpoints below. DB migrations in daeya-api: (1) CREATE TABLE characters (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, handle TEXT UNIQUE NOT NULL, name TEXT NOT NULL, published BOOLEAN NOT NULL DEFAULT false, skin_tone INT NOT NULL DEFAULT 5, face_shape TEXT NOT NULL DEFAULT ''oval'', hair_color TEXT NOT NULL DEFAULT ''brown'', hair_style TEXT NOT NULL DEFAULT ''medium'', eye_color TEXT NOT NULL DEFAULT ''brown'', body_type TEXT NOT NULL DEFAULT ''average'', description TEXT NOT NULL DEFAULT '''', tagline TEXT NOT NULL DEFAULT '''', avatar_url TEXT, status TEXT NOT NULL DEFAULT ''pending'', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); (2) CREATE TABLE looks (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE, name TEXT NOT NULL, outfit_description TEXT NOT NULL DEFAULT '''', outfit_url TEXT, source_photo_urls TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], status TEXT NOT NULL DEFAULT ''pending'', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); (3) CREATE TABLE albums (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, name TEXT NOT NULL, cover_url TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); (4) CREATE TABLE posts (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, character_id UUID REFERENCES characters(id), look_id UUID REFERENCES looks(id), album_id UUID REFERENCES albums(id), image_url TEXT, video_url TEXT, caption TEXT NOT NULL DEFAULT '''', location_name TEXT, location_lat DECIMAL, location_lng DECIMAL, prompt_text TEXT, show_prompt BOOLEAN NOT NULL DEFAULT false, frame_style TEXT NOT NULL DEFAULT ''none'', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()); (5) CREATE TABLE mutation_history (id UUID PRIMARY KEY DEFAULT gen_random_uuid(), character_id UUID NOT NULL REFERENCES characters(id) ON DELETE CASCADE, parameters_json JSONB NOT NULL, result_url TEXT, committed BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()). Domain models: Character, Look, Album, Post, MutationRecord with same fields. Services with interfaces in ports: CharacterService (Create, GetByID, GetByHandle, ListByUser, ListPublished, Update, SetPublished, UpdateAvatarURL), LookService (Create, GetByID, ListByCharacter, UpdateOutfitURL), AlbumService (Create, GetByID, ListByUser), PostService (Create, GetByID, ListByAlbum, ListByUser), MutationService (Create, GetByCharacter, Commit). Endpoints under /api/daeya-api: PUBLIC (no auth): GET /characters/public?limit&offset (returns published=true characters ordered by created_at desc, includes avatar_url, name, handle, tagline, look count), GET /c/:handle (returns single published character by handle including looks list). AUTHENTICATED (require JWT auth via skeleton auth.Middleware): POST /characters (body: {name, handle, description, tagline?, skin_tone?, face_shape?, hair_color?, hair_style?, eye_color?, body_type?} → 202 + character), GET /characters (returns user characters), GET /characters/{id}, PATCH /characters/{id} (update fields including published bool), POST /characters/{id}/looks (body: {name, outfit_description?, source_photo_urls?[]} → 202 + look), GET /characters/{id}/looks, POST /characters/{id}/mutations (body: {skin_tone?, background?, lighting?, style_filter?} → 202 + mutation), POST /characters/{id}/mutations/{mutation_id}/commit (commits mutation as new character baseline), POST /albums (body: {name}), GET /albums, GET /albums/{id}/posts, POST /posts (body: {character_id, look_id?, album_id?, caption?, location_name?, location_lat?, location_lng?, prompt_text?, show_prompt?, frame_style?} → post), GET /posts/{id}. After creating a character enqueue job {type:generate_character, character_id}. After creating a look enqueue job {type:generate_look, look_id}. After creating a mutation enqueue job {type:generate_mutation, mutation_id}. Publish SSE events via SSE hub: character_updated {character_id, status, avatar_url} to channel:daeya, look_updated {look_id, status, outfit_url} to channel:daeya, mutation_ready {mutation_id, character_id, result_url} to channel:daeya.'" auto_commit: true auto_push: true git_clone_url: "https://git.threesix.ai/threesix/{{ .outputs.create-project.project_id }}.git" outputs: - build_id: .data.task_id wait-avatar-model: depends_on: [implement-avatar-model] action: wait_build build_id: "{{ .outputs.implement-avatar-model.build_id }}" max_attempts: 120 poll_interval: 5 # --- Feature 2: AI Generation Pipeline --- implement-generation: description: "Character portrait gen → look styling from photos → mutation explorer → action expressions" depends_on: [wait-avatar-model] action: api method: POST endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" body: prompt: "/implement-feature ai-generation --requirements 'Implement generation pipeline in media-worker using skeleton ai-client package (LAOZHANG_API_KEY for image generation, GEMINI_API_KEY for Gemini vision photo analysis). Worker consumes jobs from queue by type: JOB generate_character {character_id}: Load character row. Build a portrait prompt from character fields: face_shape, hair_color, hair_style, eye_color, skin_tone (map 1-10 to descriptive terms: 1=very fair, 3=light, 5=medium, 7=tan, 9=deep, 10=very dark), body_type, description. Prompt template: \"Portrait photo of a [gender-neutral] person. [description]. [skin_tone_desc] skin, [face_shape] face, [hair_color] [hair_style] hair, [eye_color] eyes. [body_type] build. Front-facing, neutral expression, soft studio lighting, clean white background. High quality, photorealistic.\" Generate one image via LAOZHANG_API_KEY. Save URL to character.avatar_url, set status=ready. Publish SSE character_updated {character_id, status:ready, avatar_url}. JOB generate_look {look_id}: Load look + character rows. If source_photo_urls non-empty: use Gemini vision (GEMINI_API_KEY) to analyze first photo and extract style descriptors (garment type, color palette, fabric, silhouette, styling details). Build outfit prompt using descriptors + character appearance. If no source photos: use outfit_description directly. Prompt template: \"The same person from the reference portrait is now wearing [outfit_description]. Full body view. [character appearance details]. Natural lighting. High quality, photorealistic.\" Use character.avatar_url as style reference if available. Generate one image. Save to look.outfit_url, set status=ready. Publish SSE look_updated {look_id, status:ready, outfit_url}. JOB generate_mutation {mutation_id}: Load mutation_history + character rows. Parameters from parameters_json may include: skin_tone (1-10), background (city_day|city_night|nature_park|studio_white|studio_dark|beach|mountain), lighting (natural|golden_hour|dramatic|neon|soft_box), style_filter (realistic|stylized|anime|painterly). Build variation prompt applying parameter overrides. Background mapping: city_day=busy city street daytime, city_night=city street at night with neon lights, nature_park=lush green park, studio_white=plain white studio, studio_dark=dark minimalist studio, beach=sandy beach golden hour, mountain=mountain vista clear sky. Style filter suffix: anime=anime art style illustration, painterly=oil painting impressionist style (others=photorealistic). Generate image. Save to mutation_history.result_url. Publish SSE mutation_ready {mutation_id, character_id, result_url}. JOB generate_action {character_id, look_id, action_text, post_id}: Load character + look. Build action prompt: the character performing the action described (spin while showing outfit=full body 3/4 turn pose; say Happy Birthday=person holding birthday cake smiling waving; wave=person waving at camera smiling). Generate image. Save image_url to posts table for post_id. Publish SSE action_ready {post_id, image_url}.'" auto_commit: true auto_push: true git_clone_url: "https://git.threesix.ai/threesix/{{ .outputs.create-project.project_id }}.git" outputs: - build_id: .data.task_id wait-generation: depends_on: [implement-generation] action: wait_build build_id: "{{ .outputs.implement-generation.build_id }}" max_attempts: 120 poll_interval: 5 wait-deploy-2: description: Deploy generation pipeline depends_on: [wait-generation] action: wait_pipeline project_id: "{{ .outputs.create-project.project_id }}" max_attempts: 120 poll_interval: 10 # --- Feature 3: Studio UI --- implement-studio-ui: description: "Public discovery feed, character profile, creation wizard, mutation explorer, look styling" depends_on: [wait-deploy-2] action: api method: POST endpoint: "/projects/{{ .outputs.create-project.project_id }}/builds" body: prompt: |- /implement-feature studio-ui --requirements 'Build the React studio UI using skeleton packages (@project/ui, @project/auth, @project/layout, @project/realtime, @project/api-client). DESIGN SYSTEM: Use these exact CSS custom properties or Tailwind custom tokens throughout — bg-base:#0a0a0f, bg-card:#12121a, bg-panel:#1a1a2e, border-default:#2a2a3e, text-primary:#e0e0e0, text-muted:#888888, accent:#ff6b9d, accent-dark:#c44569, success:#4ade80, error:#ef4444. Apply: cards use bg-card + 16px border-radius + 1px solid border-default + hover:translateY(-4px) + hover:box-shadow 0 8px 30px rgba(0,0,0,0.3). Primary buttons use gradient background linear-gradient(135deg,#ff6b9d,#c44569) + 12px border-radius + hover:translateY(-2px) + hover:box-shadow 0 8px 20px rgba(255,107,157,0.3). Secondary buttons use border 1px solid border-default + text-muted + hover:border-accent + hover:text-accent. Inputs/textareas use bg-base + border 1px solid border-default + 12px border-radius + focus:border-accent. Pills (selected state): border accent + bg rgba(255,107,157,0.1) + text white. Status badges: pending=bg rgba(42,42,62,1) text #888, generating=bg rgba(255,107,157,0.2) text #ff6b9d, ready=bg rgba(74,222,128,0.2) text #4ade80. All transitions 200ms ease. Body font: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif. ROUTE STRUCTURE — two surfaces, same domain: PUBLIC ROUTES (no auth required): / = Discovery feed, /c/:handle = Public character profile. PROTECTED ROUTES (require auth via ProtectedRoute from @project/auth): /login = OTP login, /studio = Your characters dashboard, /studio/characters/new = Creation wizard, /studio/characters/:id = Character detail + mutation explorer, /studio/albums = Album management. DISCOVERY FEED (/): Full-width page, bg-base. Top nav: logo left, "Create Your Character" button right (links to /login if not authed, else /studio/characters/new). Hero: centered headline "Meet characters created by real people" + subtext "Browse, follow, and collaborate without sharing your real face." Below hero: responsive grid (auto-fill minmax 260px) of CharacterCard components fetched from GET /api/daeya-api/characters/public. CharacterCard: bg-card, 16px radius, overflow hidden, hover elevation. Top: avatar_url as square image (aspect-ratio:1, object-fit:cover). Bottom: padding 16px, name in white 600 weight, handle in text-muted 14px (@handle), tagline in text-muted 13px truncated, row of up to 4 look-count and status badges. Click card → /c/:handle. Empty discovery state: "No characters published yet. Be the first." Infinite scroll or Load More button calling GET /characters/public?offset=N. PUBLIC CHARACTER PROFILE (/c/:handle): Fetch from GET /api/daeya-api/c/:handle. Large hero: avatar_url as circular 160px centered, name heading, handle in text-muted, tagline. Trait pills row (skin tone swatch + hair color + hair style + eye color). "Looks" section: masonry or even grid of look outfit_url images. Clicking image opens fullscreen modal with prev/next. "Collaborate" button (deferred, shows "Coming soon" toast). OTP LOGIN (/login): bg-base centered card. Email input → "Send Code" button (primary). Then code input → "Verify" button. On success store token and redirect to /studio. Uses POST /api/daeya-api/auth/otp/send and POST /api/daeya-api/auth/otp/verify. STUDIO DASHBOARD (/studio): DashboardShell from @project/layout. Header: "Your Characters" title + "New Character" primary button. Grid of CharacterCard (same card component as discovery but with edit controls: Publish toggle + Edit link). Published characters show a small accent dot. Empty state: centered + "Create your first character" button. CREATION WIZARD (/studio/characters/new): 4-step wizard, progress bar in accent color. STEP 1 DESCRIBE: Textarea placeholder "Describe your character — their vibe, energy, how they carry themselves." Handle field: @handle (lowercase, alphanumeric, used in public URL). Skin tone picker: 10 circular swatches (#FDDBB4 lightest to #3D1A00 darkest), selected=2px ring in accent. STEP 2 SHAPE: Hair color pills (Blonde,Brown,Black,Red,Auburn,White,Silver). Hair style pills (Short,Medium,Long,Curly,Wavy,Straight,Bob,Bun). Eye color pills (Brown,Blue,Green,Hazel,Gray,Amber). Body type pills (Slim,Athletic,Average,Curvy,Plus). Face shape pills (Oval,Round,Square,Heart,Diamond). Name field. Tagline field. All pills: default=secondary style, selected=accent pill style. STEP 3 SOUL (optional): Interest pills multi-select up to 3 (Travel,Fashion,Music,Food,Art,Fitness,Books,Tech,Nature). Live summary card updates as user selects — shows all chosen traits as accent pills. STEP 4 GENERATE: Summary card. "Generate Character" primary button. On submit POST /api/daeya-api/characters. Shows full-screen generating overlay: avatar generating animation (pulsing circle in accent color) + cycling messages "Bringing your character to life..." / "Crafting their appearance..." / "Almost there...". On SSE character_updated with status=ready redirect to /studio/characters/:id. CHARACTER DETAIL (/studio/characters/:id): Two-column layout. Left (1/3): circular avatar 200px (pending=pulsing bg-panel circle). Name + @handle + tagline. Trait pills. PUBLISH TOGGLE: labeled "Public" — toggle switch in accent when on. On toggle call PATCH /api/daeya-api/characters/:id {published: true/false}. Published characters get a small "Live on discovery" badge. "Add Look" button opens look panel. Looks list: each row shows look name, 48px outfit_url thumbnail, status badge. Clicking look opens fullscreen modal. Right (2/3): MUTATION EXPLORER panel (bg-panel, 16px radius, p-24). Header "Explore Variations" in white. Parameters: Skin Tone — row of 10 swatches, click to select (highlight in accent). Background — pill row: City Day, City Night, Nature, Studio, Beach, Mountain. Lighting — pill row: Natural, Golden Hour, Dramatic, Neon. Style — pill row: Realistic, Stylized, Anime, Painterly. "Generate Preview" primary button → POST /api/daeya-api/characters/:id/mutations. Preview area 1:1 aspect ratio: shows result_url when ready, else shows pulsing bg-panel placeholder + "Generating..." text. "Commit as New Baseline" button (accent, enabled only when preview ready) → POST commit endpoint → refresh avatar. "Discard" secondary button resets params. ADD LOOK PANEL (slide-in from right, 400px, bg-panel): Look name field. Upload area: dashed border border-default, "Drop photos here or click to upload" in text-muted, max 3. Thumbnail previews of uploaded photos. "Or describe the outfit" textarea below. "Generate Look" primary button. ALBUMS (/studio/albums): Grid of AlbumCard. "New Album" button → inline modal. REALTIME: useEventChannel from @project/realtime subscribed to channel:daeya. On character_updated: refresh avatar + status in dashboard and detail. On look_updated: refresh look thumbnail. On mutation_ready: show in mutation explorer preview. On action_ready: show in post.' auto_commit: true auto_push: true git_clone_url: "https://git.threesix.ai/threesix/{{ .outputs.create-project.project_id }}.git" outputs: - build_id: .data.task_id wait-studio-ui: depends_on: [implement-studio-ui] action: wait_build build_id: "{{ .outputs.implement-studio-ui.build_id }}" max_attempts: 120 poll_interval: 5 wait-deploy-final: description: Deploy final build depends_on: [wait-studio-ui] action: wait_pipeline project_id: "{{ .outputs.create-project.project_id }}" max_attempts: 120 poll_interval: 10 # --- Verification --- verify-health: description: Verify daeya-api is healthy depends_on: [wait-deploy-final] action: shell command: | HEALTH=$(curl -sf "https://{{ .outputs.create-project.domain }}/api/daeya-api/health" | jq -r '.data.status // empty') if [ "$HEALTH" = "healthy" ]; then echo "daeya-api healthy" exit 0 else echo "daeya-api not healthy: $HEALTH" exit 1 fi verify-site: description: Verify studio-ui frontend loads depends_on: [wait-deploy-final] action: wait_site domain: "{{ .outputs.create-project.domain }}" project_id: "{{ .outputs.create-project.project_id }}" max_attempts: 30 poll_interval: 5 verify-public-discovery: description: Verify public discovery feed responds without auth depends_on: [verify-health] on_error: continue action: shell command: | DOMAIN="{{ .outputs.create-project.domain }}" STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ "https://$DOMAIN/api/daeya-api/characters/public") echo "GET /characters/public (no auth) returned: $STATUS" if [ "$STATUS" = "200" ]; then echo "Public discovery endpoint confirmed"; exit 0; fi echo "Unexpected status — public endpoint may not exist" exit 1 verify-auth-endpoint: description: Verify authenticated character endpoint requires auth (401 confirms it) depends_on: [verify-health] on_error: continue action: shell command: | DOMAIN="{{ .outputs.create-project.domain }}" STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ "https://$DOMAIN/api/daeya-api/characters" \ -H "Content-Type: application/json") echo "GET /characters without auth returned: $STATUS" if [ "$STATUS" = "401" ]; then echo "Auth guard confirmed"; exit 0; fi echo "Unexpected status — endpoint may not exist" exit 1 verify-otp-endpoint: description: Verify OTP email endpoint exists and notify is configured (200/202/429 confirms it) depends_on: [verify-health] on_error: continue action: shell command: | DOMAIN="{{ .outputs.create-project.domain }}" STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ "https://$DOMAIN/api/daeya-api/auth/otp/send" \ -H "Content-Type: application/json" \ -d '{"email":"smoke-test@example.com"}') echo "POST /auth/otp/send returned: $STATUS" if [ "$STATUS" = "200" ] || [ "$STATUS" = "202" ] || [ "$STATUS" = "429" ]; then echo "OTP endpoint + notify confirmed working" exit 0 fi echo "Unexpected status — OTP endpoint may be missing or notify credentials not injected" exit 1 teardown: - description: Delete project and all provisioned resources action: api method: DELETE endpoint: "/project/{{ .outputs.create-project.project_id }}"