---
path: /metis/ingest
surface: customer
domain: metis
auth: signed-in
source: apps/oshun/web/src/app/metis/ingest/page.tsx
status: walked
last_walked:
  '2026-06-29 real-infra Playwright chromium — signed-in customer shell, idle
  URL default, true radio source selector with Arrow/Home/End roving keyboard,
  PDF reference accepted through the live BFF, delayed pasted-text pending
  state, completed outline status card, real invalid-URL 422 validation,
  explicit offline abort branch, Queue another reset, mobile standalone shape,
  no horizontal overflow, and shared axe scan; 2026-06-29 telemetry addendum
  verifies the ingest form, submit button, accepted status card, Back-to-Metis
  link, Queue-another button, and descendants expose no `data-telemetry-event`,
  `data-analytics-event`, or `data-event` hooks. Evidence:
  WALKTHROUGH/results/v1-real-infra-run-2026-06-22.md#212-2026-06-29-metis-ingest-submitaccept-telemetry-coverage'
---

# Metis · ingest a source

## Purpose

Creator-facing surface to hand Metis one piece of raw material — a public URL, a
PDF upload, or a pasted passage — and queue an ingest job. On accept the BFF
returns a `202` with an `ingestJobId`: pasted text is processed immediately into
`state=completed` with a study outline, while URL/PDF references are honestly
recorded as `state=awaiting_source_content` until deploy-bound fetch/parsing can
provide the source body. See
[`V1/features.md#metis`](../../../V1/features.md#metis).

## Entry points

- **Metis schoolroom / BYOM** — verify whether [`metis.md`](./metis.md) or
  [`metis-byom.md`](./metis-byom.md) links to `/metis/ingest`
- **Direct URL / bookmark** — yes (auth required); `canonical` is
  `/metis/ingest`
- **Search result** — page metadata title "Metis · ingest"

## Layout regions

`page.tsx` is a server component:
`<LWebShell top={<LCustomerNav active="explore" />}>` wrapping a 640px-max
centered column with `<LMasthead>`, an italic intro, then the client
`<MetisIngestForm />`.

- **Header**: web shell top — `LCustomerNav` with `active="explore"`
- **Masthead**: `LMasthead` — left "Metis · ingest", right "creator", kicker
  "From source to syllabus", title "_Ingest_ a source."
- **Intro**: `LEyebrow` "How this opens" + serif-italic copy ("Hand Metis a
  single source. She returns a draft outline with section breaks, quotable
  passages, and a first-pass assessment rubric …")
- **Main**: the ingest form card (`L.paper`, `1px solid L.rule`)
- **Footer / sticky bottom**: _None._
- **Automation anchors**: root exposes `data-metis-ingest-page` and
  `data-route`; the form exposes `data-metis-ingest-form`, source-kind,
  pending/state, field-length/file-name, and error attributes; the accepted card
  exposes `data-metis-ingest-status`, job id, BFF state, outline presence,
  segment/prompt/minute counts, and pending reason.

## States

- [x] **Idle form** — `kind` defaults to `url`; the URL input is shown; submit
      reads "Queue for ingest"
- [x] **Kind = URL** — `input#ingest-url` (`type=url`, `required`) visible
- [x] **Kind = PDF** — `input#ingest-pdf` (`type=file`,
      `accept=application/pdf`, `required`) visible; selected file name echoed
      below in mono
- [x] **Kind = Text** — `textarea#ingest-text` (`required`, `minLength={60}`,
      `rows={6}`) visible
- [x] **Pending** — submit text "Queueing…", `opacity 0.6`, `cursor: progress`,
      `disabled`; proven with a delayed `route.fetch()` that still completes
      against the live BFF
- [x] **Accepted (text status card)** — `accepted` set;
      `role="status" aria-live="polite"` card: eyebrow "Outline ready", "Job
      `<ingestJobId>` processed.", section/minute summary, outline segments,
      "Study prompts", with "Back to Metis" + "Queue another"; on accept `kind`
      resets to `url` and `url`/`text`/`fileName` clear. Verified 2026-06-29 in
      `metis-byom-ingest.spec.ts` and `metis-create-forms.spec.ts`
- [x] **Accepted (PDF reference status card)** — BFF returns
      `state=awaiting_source_content`; card eyebrow "Received", "Job
      `<ingestJobId>` accepted.", and the deploy-bound fetch/parse or PDF parse
      reason. URL reference remains contract-covered; the browser proof covers
      PDF because it exercises the same `awaiting_source_content` card branch.
- [x] **Error (recoverable)** — `role="alert"` banner "Ingest could not start
      (`<status>`)." on non-ok; real invalid URL `422` preserves fields
- [x] **Offline** — fetch throws → "Metis is offline. Try again in a moment.";
      submit is a no-op (no queue)
- [x] **Loading** — N/A; client-rendered form with no initial fetch
- [x] **Empty** — N/A; create form
- [x] **Signed-in shell context** — walked through an authenticated customer
      shell with the Explore nav active
- [x] **Standalone PWA** — renders within `LWebShell` at 390px width with no
      horizontal overflow

## Interactions

### Source-kind selector (`role="radiogroup" aria-label="Source kind"`)

- [x] **URL** (`<button type="button" role="radio" aria-checked>`)
  - Function: `setKind('url')`; cell shows "URL" / "archive / paper"; selected
    cell gets `L.accent` border, `L.bg` background, italic label
  - Keyboard: true roving radio group; ArrowRight/ArrowDown move forward,
    ArrowLeft/ArrowUp move backward, Home selects URL, End selects Text
- [x] **PDF** (`<button type="button" role="radio" aria-checked>`)
  - Function: `setKind('pdf')`; cell "PDF" / "upload a file"
- [x] **Text** (`<button type="button" role="radio" aria-checked>`)
  - Function: `setKind('text')`; cell "Text" / "paste a passage"

### Conditional inputs

- [x] **Public URL** (`input#ingest-url`, shown when `kind==='url'`)
  - Function: controlled `url`; `type=url` + `required` (native validation)
  - Placeholder: "https://archive.org/details/…"
- [x] **PDF file** (`input#ingest-pdf`, shown when `kind==='pdf'`)
  - Function: `type=file accept=application/pdf required`; on change sets
    `fileName = files?.[0]?.name`; file name echoed in mono below
  - Note: only the file **name** is sent; the file bytes are not uploaded (see
    gaps)
- [x] **Passage** (`textarea#ingest-text`, shown when `kind==='text'`)
  - Function: controlled `text`; `required`, `minLength={60}`, `rows={6}`,
    `resize: vertical`
  - Placeholder: "Paste a passage — sixty characters minimum so Metis has enough
    to chew."

### Buttons

- [x] **Back** (`LBtn kind="ghost" size="lg"`, `href="/metis"`)
  - Function: navigates to `/metis` without submitting
- [x] **Queue for ingest** (`<button type="submit">`)
  - Function: `submit()` → POST `/v1/metis/ingest` with
    `{ kind, url, text, fileName }` (non-active fields sent as `null`); on ok
    sets `accepted`, resets `kind='url'`, and clears fields
  - Keyboard: Enter submits
  - Disabled when: `pending` → text "Queueing…"
  - Offline behavior: catch → error banner; not queued
  - Telemetry: none wired; E2E asserts the form, submit button, and form
    descendants expose no `data-telemetry-event`, `data-analytics-event`, or
    `data-event` hook before submit and while the live BFF request is pending.

### Accepted-state buttons

- [x] **Back to Metis** (`LBtn kind="ghost" size="md"`, `href="/metis"`)
- [x] **Queue another** (`LBtn kind="primary" size="md"`)
  - Function: `setAccepted(null)` → returns to the reset idle URL form
  - Telemetry: none wired; E2E asserts the accepted status card, Back-to-Metis
    link, Queue-another button, and descendants expose no
    `data-telemetry-event`, `data-analytics-event`, or `data-event` hook.

## Data & contracts

- **Reads**: _None_ — no initial fetch
- **Writes**: `POST /v1/metis/ingest` (via `buildOshunBffUrl`) with body
  `{ kind: 'url'|'pdf'|'text', url, text, fileName }`, `credentials: 'include'`,
  headers `content-type: application/json` + `csrfHeaders()` + bearer auth when
  an API token is present
  - BFF handler (`apps/oshun/bff/src/routes/domain-stubs.ts`) is guarded by
    `originGuard`, `csrfGuard`, and `collectionsAuthGuard`; missing auth returns
    `401 { reason: 'missing_auth_context' }`
  - For `kind='text'`, the handler validates at least 60 characters, persists an
    owner-scoped job in `metisIngestJobStore`, derives a real study outline from
    the submitted text, and returns
    `202 { ingestJobId: 'ingest_<uuid>', state: 'completed', outline, pendingReason: null }`
  - For `kind='url'`, the handler validates an `http(s)` URL, persists the
    owner-scoped reference, and returns `state: 'awaiting_source_content'` with
    a pending reason because fetch/extraction is deploy-bound
  - For `kind='pdf'`, the handler validates a file name, persists the
    owner-scoped reference, and returns `state: 'awaiting_source_content'`
    because the browser sends no file bytes
  - `GET /v1/metis/ingest/:jobId` reads a job scoped to the same owner and 403s
    on owner mismatch
  - Client `AcceptedResponse` shape:
    `{ ingestJobId, state: 'completed'|'awaiting_source_content', pendingReason, outline }`
  - Client success handling stores the accepted response, resets the next source
    kind to URL, and clears `url`, `text`, and `fileName`
- **Realtime**: _None._ — pasted text completes synchronously in the `202`
  response; URL/PDF reference extraction is pending/deploy-bound, not streamed
- **Caching**: write response is `no-store`
- **Auth/role check**: shell middleware enforces signed-in; BFF also enforces
  origin, CSRF, and collection auth on the write

No typed contract in `libs/contracts/` or `libs/openapi/`; shapes are inline.

## E2E coverage

- `apps/oshun/web/e2e/metis-byom-ingest.spec.ts` — 2026-06-29 live browser test
  opens `/metis/ingest`, selects Text, submits a pasted passage to the real BFF,
  waits for the `202`, and asserts the rendered outline status card
- `apps/oshun/web/e2e/metis-create-forms.spec.ts` — 2026-06-29 live browser
  route test verifies idle defaults, true radio source-kind keyboard behavior,
  PDF reference acceptance through the real BFF, pasted-text delayed pending and
  completed outline states, Queue another reset, real invalid-URL 422 handling,
  offline abort handling, mobile standalone layout, route-level axe scan, and no
  telemetry hook attributes on the ingest form, submit button, accepted status
  card, accepted-state action controls, and descendants
- `apps/oshun/web/e2e/metis-ingest-bff.spec.ts` — contract-level coverage for
  text completion, URL/PDF `awaiting_source_content`, validation, auth, and
  scoped job reads

## Cross-references

- Feature spec: [`V1/features.md#metis`](../../../V1/features.md#metis)
- Architecture: [`V1/ARCHITECTURE.md#metis`](../../../V1/ARCHITECTURE.md#metis)
- Sibling Metis routes: [`metis.md`](./metis.md),
  [`metis-byom.md`](./metis-byom.md),
  [`metis-courses-new.md`](./metis-courses-new.md),
  [`metis-lesson.md`](./metis-lesson.md)
- Journeys:
  [`../../journeys/metis-byom-ingest-to-course.md`](../../journeys/metis-byom-ingest-to-course.md)
- Component sources:
  - `apps/oshun/web/src/app/metis/ingest/page.tsx`
  - `apps/oshun/web/src/app/metis/ingest/MetisIngestForm.tsx`
  - BFF: `apps/oshun/bff/src/routes/domain-stubs.ts`

## Open questions / known gaps

- [ ] **PDF is not actually uploaded** — only `fileName` is posted as JSON;
      there is no multipart upload or presigned URL flow, so Metis has no file
      bytes to ingest. Confirm the intended upload path.
- [x] ~~The BFF `/v1/metis/ingest` stub ignores `kind`/`url`/`text`/`fileName`
      and returns a synthetic `ingestJobId`~~ — resolved: the handler now
      validates source kind, processes pasted text, stores owner-scoped jobs,
      and honestly records URL/PDF references as `awaiting_source_content`.
- [x] ~~`/v1/metis/ingest` lacks `originGuard`/`csrfGuard` while
      `/v1/metis/courses` has both~~ — resolved: the route now uses
      `originGuard`, `csrfGuard`, and `collectionsAuthGuard`.
- [x] ~~The radiogroup uses `<button aria-pressed>` rather than radio inputs;
      verify arrow-key roving and screen-reader announcement meet the radiogroup
      contract~~ — resolved: the selector now uses `role="radio"`,
      `aria-checked`, roving `tabIndex`, and Arrow/Home/End keyboard behavior.
- [x] No telemetry on submit/accept: `metis-create-forms.spec.ts` asserts the
      ingest form, submit button, accepted status card, Back-to-Metis link,
      Queue-another button, and descendants expose no `data-telemetry-event`,
      `data-analytics-event`, or `data-event` hooks. Product decision remains
      whether a future `metis_ingest_queued` event is expected.
