chore: import upstream snapshot with attribution
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
ci / test (push) Waiting to run
ci / lint-and-format (push) Waiting to run
ci / build (push) Waiting to run
ci / dev-startup (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
gitleaks / gitleaks (push) Waiting to run
Markdown Links / Relative Markdown Links (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Quality (Extended) / Homepage Build (PR smoke) (push) Waiting to run
Quality (Extended) / Comment-only diff guard (push) Waiting to run
Quality (Extended) / Format + Type Safety Ratchet (push) Waiting to run
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Waiting to run
Quality (Extended) / Develop Gate (lint) (push) Waiting to run
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Waiting to run
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Waiting to run
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:43:05 +08:00
commit 426e9eeabd
41828 changed files with 9656266 additions and 0 deletions
+125
View File
@@ -0,0 +1,125 @@
# Automation glossary & integration model
elizaOS has several overlapping scheduling / automation abstractions. They are
*not* interchangeable, and using the wrong word for one of them is how a new
engineer ends up looking for a "task" in the wrong table or adding a second
scheduler. This document is the single source of truth for the vocabulary and
for how the pieces fit together. Every term below is **the** word for its
concept in code identifiers, API paths, DB names, UI copy, and docs.
## Glossary — one word per concept
| Term | Meaning | Where it lives |
| --- | --- | --- |
| **workflow** | A stored, versioned definition of multi-step work — the plugin-workflow node graph. Never a run, never a schedule. | `plugins/plugin-workflow` · `workflow.embedded_workflows` |
| **run** | One execution of a workflow (or of a trigger). The concrete storage/API name is `execution` / `embedded_executions` / `/executions` and is kept as-is; UI copy and new code say "run". | `workflow.embedded_executions` |
| **trigger** | The attachable condition that starts a runnable: a **schedule** (`cron` \| `interval` \| `once`) or an **event**. One `TriggerConfig` references exactly one target — `kind: "workflow"` (a `workflowId`) or `kind: "prompt"` (a prompt automation). "Heartbeat" is **not** a synonym for trigger. | `packages/core/src/types/trigger.ts` (`TriggerConfig`) stored in a core `Task`'s `metadata.trigger` |
| **schedule** | The time spec *inside* a trigger (`cronExpression` / `intervalMs` / `scheduledAtIso`). Not an independent object. | fields on `TriggerConfig` |
| **task** (unqualified) | ONLY the core runtime infra unit (`packages/core/src/types/task.ts`): a persisted unit of deferred/recurring work run by `TaskService` via a registered `TaskWorker`. Infrastructure vocabulary — never user-facing. | `tasks` table (`plugins/plugin-sql/src/schema/tasks.ts`) |
| **scheduled item** | A LifeOps `ScheduledTask` record (reminder / check-in / follow-up / approval / recap / watcher / output). The code type stays `ScheduledTask` (frozen contract); prose and UI say "scheduled item". | `app_lifeops.life_scheduled_tasks` |
| **coding task** | An orchestrator work item (`OrchestratorTaskRecord`) — a coding delegation to a sub-agent. Code keeps its `Orchestrator*` prefix; UI/docs always qualify as "coding task", never bare "task". | `orchestrator_tasks` table |
| **automation** | The UI umbrella read-model only (`/api/automations` + merged scheduled items): "anything that runs without you asking in the moment". Never a storage concept or a backend type outside the read-model builders. | `plugins/plugin-workflow/src/routes/automations.ts` |
| **heartbeat** | A connector keep-alive repeat `Task` (tag `heartbeat`), and nothing else. Correct English for what those are; the misuse was applying it to user triggers. | core `Task` tagged `["queue","repeat","heartbeat"]` |
| **prompt automation** | A workbench task expressed as a **prompt trigger**: a prompt + a trigger, no node graph (`TriggerConfig.kind === "prompt"`). | core `Task` with `metadata.trigger.kind === "prompt"` |
### "task" disambiguation table
The word "task" is overloaded across the codebase. These are the distinct
concepts; only concept A is the unqualified word "task".
| # | "task" means | Store | Owner |
| --- | --- | --- | --- |
| A | Core recurring/deferred runtime Task (infra) — **the** unqualified "task" | `tasks` | `packages/core/src/types/task.ts`; `plugins/plugin-sql/src/schema/tasks.ts` |
| B | Workbench task — a core Task surfaced as a "prompt automation" (prompt + trigger) | reuses `tasks` (schedule in `metadata.trigger`) | `plugins/plugin-workflow` (automations builder) |
| C | LifeOps scheduled item (reminder / check-in / …) → say **scheduled item** | `app_lifeops.life_scheduled_tasks` | `plugins/plugin-scheduling` |
| D | Orchestrator coding task (goal + sub-agent sessions) → say **coding task** | `orchestrator_tasks` | `plugins/plugin-agent-orchestrator` |
| E | Todo — user to-do item | `todos.todos` | `plugins/plugin-todos` |
| F | iOS background task (BGTaskScheduler bridge) | none (OS) | `plugins/plugin-native-eliza-tasks` |
## One clock, two consumers
There is exactly **one** timer for user-facing scheduled work: the core
`TaskService`, which ticks every second (`packages/core/src/services/task.ts`),
polls `Task` rows tagged `queue`, runs each due one, and reschedules `repeat`
tasks via `metadata.updateInterval`. Recurrence rides **tags**
(`queue` = scheduler-owned, `repeat` = recurring), not columns.
Two subsystems are *consumers* of that one clock, wired in at exactly two points.
Nothing else schedules user work; do not add a second scheduler
(`plugins/plugin-personal-assistant/README.md` mandates one runner).
```
core TaskService (1s tick, packages/core/src/services/task.ts)
polls Task rows tagged "queue"
┌─────────────────────┴──────────────────────────┐
│ │
TRIGGER_DISPATCH task LIFEOPS_SCHEDULER task
tags [queue, repeat, trigger] tags [queue, repeat, lifeops] (60s)
metadata.trigger : TriggerConfig packages/plugin-personal-assistant
packages/agent/src/triggers/runtime.ts /lifeops/scheduler-task.ts
│ │
reads TriggerConfig.kind processDueScheduledTasks
├── "workflow" → WORKFLOW_DISPATCH service (due-fire / recurrence / timeout)
│ (plugin-workflow) │
└── "prompt" → prompt-runner path ScheduledTask runner (state machine)
(agent runtime) plugins/plugin-scheduling/src/
scheduled-task/runner.ts
```
- **Trigger consumer** — a `TRIGGER_DISPATCH` core Task carries a `TriggerConfig`
in `metadata.trigger`. When it fires, `executeTriggerTask`
(`packages/agent/src/triggers/runtime.ts`) reads `TriggerConfig.kind` and
dispatches: `"workflow"` → the `WORKFLOW_DISPATCH` service (plugin-workflow),
`"prompt"` → the prompt-runner path. Each fire appends a `TriggerRunRecord`.
- **LifeOps consumer** — a single `LIFEOPS_SCHEDULER` repeat Task (60 s) runs
`processDueScheduledTasks`, which drives the `ScheduledTask` runner state
machine (`plugins/plugin-scheduling/src/scheduled-task/runner.ts`). The runner
owns no timer of its own — the one core clock ticks it.
A scheduled workflow is therefore already "a workflow schedulable via the
task/cron layer": plugin-workflow's `armSchedules()` materializes each
schedule-trigger node as a `TRIGGER_DISPATCH` core Task
(`plugins/plugin-workflow/src/services/embedded-workflow-service.ts`).
## Who fires what — route map
| Concept | HTTP surface | Handler |
| --- | --- | --- |
| workflow (definition CRUD, run) | `/api/workflow/*` (rawPath) | `plugins/plugin-workflow/src/plugin-routes.ts``routes/workflow-routes.ts` |
| trigger (schedule/event start condition) | `/api/triggers` (+ `/health`, `/:id/runs`, `/:id/execute`, `/events/:eventKind`, `/:id`) | `plugins/plugin-workflow/src/trigger-routes.ts` |
| automation (umbrella read-model) | `GET /api/automations` | `plugins/plugin-workflow/src/routes/automations.ts` |
| scheduled item (LifeOps) | `/api/lifeops/scheduled-tasks/*` | `plugins/plugin-scheduling/src/routes/scheduled-tasks.ts` |
| coding task (orchestrator) | `/api/orchestrator/tasks*` | `plugins/plugin-agent-orchestrator/src/setup-routes.ts` |
There is no `/api/heartbeats` route — it was an alias for `/api/triggers` and has
been removed. Connector keep-alive heartbeats still surface read-only in the
trigger list (synthesized in `packages/agent/src/triggers/runtime.ts`).
## Two "workflow" models — distinctly named
Two unrelated things are called "workflow"; always qualify the LifeOps one.
- **workflow** (engine) — plugin-workflow's n8n-shaped node graph, executed
in-process by the Smithers engine.
- **LifeOps workflow** (`LifeWorkflow*` / `life_workflow_*`) — schedule +
action-plan records bound to the LifeOps runner's frozen contract. A different
shape entirely; merging it into the node-graph engine is out of scope
(see issue #12177 non-goals). Say "LifeOps workflow" when you mean this one.
## One runtime cron engine
`packages/core/src/services/triggerScheduling.ts` is the only server-side cron
implementation (`parseCronExpression`, `computeNextCronRunAtMs`). Both the
trigger layer and the LifeOps runner import it. The UI's `cron-parser` npm dep is
for **client-side validation/preview only**. Do not add a third parser.
## Schedule encodings (there are exactly two)
1. **`TriggerConfig`** (jsonb in a core Task's `metadata.trigger`) — engine
triggers and prompt automations.
2. **LifeOps `trigger` union** (typed row) — scheduled items.
The former `schedule:<cron>` **tag** encoding on workbench tasks has been
deleted; those schedules now live in `metadata.trigger` like every other
trigger.
+430
View File
@@ -0,0 +1,430 @@
# Notifications + Widgets System
North-star spec for the home surface, the notification pipeline, and in-chat
widgets. This is the document other lanes implement against. It is grounded in
what exists on `develop` today (post #14485 de-clutter, post #14349 breadth-mandate
retirement) and in the architectural direction set by the board-15 chat-widgets
sweep (#14412 synthesis) and the uiWidgets/uiGenerative provider split (#14524).
**Product frame:** elizaOS is an operating system for an agent that also ships
as an app. The agent does things autonomously; **home is where its activity
becomes glanceable**, notifications are where it becomes **interruptive**, and
chat is where it becomes **conversational**. The primary device is an iPhone
running the iOS PWA. Every budget in this document assumes a mid-range phone on
a cell connection.
---
## A. Principles
1. **The two-second rule.** Every home widget answers *"what changed while I
was gone?"* in under two seconds of looking. If a card requires reading a
paragraph, tapping to understand, or mental arithmetic, it is not a home
widget. (Prior art: iOS lock-screen widgets and watchOS complications are
legible at arm's length; that is the grammar.)
2. **No widget without a live data source.** A home widget must be backed by a
data source that exists in the runtime *today* or is one endpoint away. No
speculative cards, no "coming soon" placeholders, no widgets that render
sample data. (This is why #14485 removed finances/relationships/inbox
residents — the domains exist, but their *glanceable urgency* signal did
not.)
3. **One job per surface.**
- **Notification = interrupt.** It exists to break attention, exactly once,
and then live in the inbox as a record.
- **Home widget = glance.** It exists to be read passively, at rest, with
zero interaction.
- **Chat widget = artifact.** It exists *inside a conversation turn* as the
structured form of something the agent said or needs.
A piece of information belongs to exactly one of these by default. The same
fact appearing as an interrupt, a resident card, *and* a chat bubble is the
noise pattern #14485 cut.
4. **Hard cap on the home.** The home renders: the ambient base (clock +
weather), the pinned notification center, and **at most five** ranked
cards. Not twelve (`HOME_RENDER_CAP` is currently 12 — see §E). A phone
viewport shows the wallpaper, the base, the notification stack, one or two
cards, and the chat bar. That is the whole product surface, and that is
deliberate.
5. **Empty means invisible.** A widget with nothing worth saying renders
`null`. Never an empty-state card on home (`WidgetProps.slot === "home"`
contract, #9143). A quiet agent produces a quiet home: wallpaper, clock,
weather, chat bar. That resting state is a feature, not a bug.
6. **Event-driven or visibility-gated; never polling re-renders.** Home is
always-mounted. Anything that re-renders it on a timer, polls an endpoint
on an interval while hidden, or blurs a compositing layer per frame pays
its cost forever. Data arrives by WS push or store subscription; clocks
tick in leaf components; tickers pause when the document is hidden.
7. **Every widget earns its slot or dies.** A resident card must demonstrate a
recurring attention signal (`home-attention` publishes, or ranked signal
kinds actually firing). A card that has self-hidden for every user for a
release cycle is a kill candidate. Curation is ongoing, not a one-time
sweep — frontpage presence is opt-in and curated, never mandated (#14349).
8. **Chat carries artifacts, not apps.** The iMessage app-drawer is the
canonical failure mode: a tray of mini-apps nobody opens, competing with
the conversation. We never build a widget drawer, a widget picker, or
persistent chat chrome. Widgets appear *because the agent emitted one in a
turn*, scroll with the transcript like any other utterance, and collapse
when their job is done.
---
## B. Home surface spec
### Anatomy (top to bottom)
```
┌──────────────────────────────┐
│ wallpaper (full-bleed) │
│ │
│ clock + weather (ambient) │ Tier 1 — never ranked, never hidden*
│ notifications (pinned) │ self-hides when empty
│ ┌────────────────────────┐ │
│ │ ranked cards (≤ 5) │ │ Tier 2/3/4, priority-ranked,
│ │ … │ │ each self-hiding
│ └────────────────────────┘ │
│ │
│ chat bar (composer) │ the one persistent control
└──────────────────────────────┘
```
\* clock is user-hideable (#10706); weather is independent.
### The ideal ranked set
Five residents maximum. Each entry below states data source (exists today),
glance content, tap behavior, empty state, refresh model, and perf budget.
#### 1. Needs Response — `needs-attention.pending`
The single most important card on home: the agent is *blocked on the user*.
- **Data source:** core `ApprovalService` (#9449) via store; WS-pushed. Exists.
- **Glance:** count + the top pending item's one-line question. "2 waiting —
Approve sending the email to JJ?"
- **Tap:** opens the approval in chat (deep-link to the thread turn), not a
separate approvals app.
- **Empty:** `null`. No "all clear" card.
- **Refresh:** WS event → store → render. Zero polling.
- **Perf:** re-renders only on approval-set change. No timers.
- **Rank:** `approval`/`escalation`/`blocked` signals (weights 910) — always
wins the top slot when live. Correct as-is.
#### 2. Up Next — `calendar.upcoming`
- **Data source:** calendar store (core API-backed). Exists.
- **Glance:** the next event only: title, time-until ("in 40 min"), location
line if present. One event, not an agenda. Full-width row (`cols: 4` — keep).
- **Tap:** opens the calendar day view (routed), or the event's deep link.
- **Empty:** `null` when no event inside the lookahead window (next 18h).
An event next Tuesday is not glanceable urgency.
- **Refresh:** store push; the "in 40 min" countdown uses a leaf
`<RelativeTime>` (§C.4) so the card body never re-renders on the minute tick.
- **Perf:** one leaf text node updates per minute, visibility-gated.
- **Rank:** self-publishes `home-attention` as the event approaches
(weight ramps inside T-2h). Exists via `home-attention-store.ts`.
#### 3. Today — `todo.items` (absorbing goals-attention)
- **Data source:** todo/workbench store (`visibility: "fallback"`). Exists.
- **Glance:** up to 3 items due/overdue today, checkbox affordance, overdue in
the accent color. If the goals plugin reports an at-risk goal, it renders as
one flagged row *inside this card* rather than as a second resident card
(merge verdict, §E).
- **Tap on row:** toggles done (optimistic). **Tap on header:** opens todos.
- **Empty:** `null` when nothing due today and nothing overdue. A long-range
backlog is not a glance.
- **Refresh:** store subscription. No timers.
- **Rank:** `reminder`/`check-in`/`nudge` signals + self-published attention
when overdue count > 0.
#### 4. Setup progress — `model-download.status` / `agent-provisioning.status`
Transient by nature: they exist only during first-run/provisioning windows.
- **Data source:** local-inference hub events (LOCAL) / cloud handoff phase
event (CLOUD). Exists.
- **Glance:** progress bar + phase label. The one thing between a fresh agent
and its first reply, so full-width prominence while live is correct.
- **Tap:** none required (status only); provisioning card may expose a retry.
- **Empty:** self-hides permanently once complete.
- **Refresh:** event stream. No polling.
- **Rank:** setup signals; they own the top while active, vanish after.
#### 5. Welcome — `ftu-welcome` (Tier 4, sunset)
- **Data source:** none needed (core FTU surface).
- **Glance:** greeting + 23 prompt chips that teach the chat bar.
- **Tap:** chip prefills/sends into the composer.
- **Empty/lifecycle:** the **only** card class using the sunset lifecycle —
retires permanently `afterAction`/`dismissible`/`afterSeen` via
`home-dismissal-store`. Correct as-is.
- **Rank:** `welcome` weight 8 — above cold cards, below any real "act now"
signal. Correct as-is.
### Explicitly NOT residents (and why)
- **`wallet.balance`** — a balance is state, not *change*. It fails the
two-second rule's real question ("what changed?"). Demote to launcher/routed
view; a large delta becomes a **notification** (category `general`/`system`,
producer-side), which is the correct surface for "something changed with
your money." (§E, migration item.)
- **`health.sleep`** — same failure: yesterday's sleep score is a daily digest
fact, not resting urgency. The *alert* case (threshold crossed) is already a
notification category (`health`). Demote the resident card; keep the routed
view. (§E.)
- **Activity/app-run/workflow streams, inbox, finances, relationships** —
removed in #14485; this spec ratifies the removal. Continuous streams belong
in the chat sidebar and routed views. Their *urgent moments* travel as
notifications.
### Home perf budget (binding)
- **No `useNow` above a leaf.** Timers live in `<RelativeTime>`-class leaf
components only (§C.4).
- **All tickers visibility-gated:** paused when `document.hidden` (the PWA is
backgrounded far more than foregrounded).
- **No interval polling from any home widget.** WS/store push only. (The 5s
`listAppRuns` poll pattern is confined to the chat sidebar and should not be
ported home.)
- **Compositing:** at most one `backdrop-filter` surface on home (the
notification glass), and it must sit on a non-scrolling element. Prefer
`bg-card/70` translucency without blur on low-end devices; treat blur as a
progressive enhancement, never a requirement for legibility.
- **Render-storm locks stay:** `WidgetHost.render-storm.test.tsx` protects the
host; new residents must add equivalent locks.
---
## C. Notifications spec
### C.1 Triage model: interrupt / digest / silent
The runtime already has the right primitive — `NotificationPriority` on
`AgentNotification` (`packages/core/src/types/notification.ts`) — and the store
already routes on it. This spec names the three tiers and binds producers to
them:
| Tier | Priority | Behavior | Examples |
|---|---|---|---|
| **Interrupt** | `urgent`, `high` | OS notification (even focused, for `urgent`), toast, inbox, badge | approval needed, task failed, health threshold, agent blocked |
| **Digest** | `normal` | inbox + unread badge, **no** OS interrupt while focused | task completed, workflow finished, proactive message worth surfacing |
| **Silent** | `low` | inbox only, no badge weight, auto-expires | routine confirmations, background completions |
**Producer rule:** an autonomous agent that interrupts for non-blocking events
trains the user to ignore interrupts (Android's channel lesson: apps that
abuse priority get muted wholesale, then miss the real one). Anything the user
does not need to *act on* is digest or silent. `NotificationService.notify`
callers must pass an explicit priority; category defaults may map to tiers
(`approval`→interrupt, `task`/`workflow`→digest, `system` routine→silent) but a
producer can always downgrade.
**Silent tier default expiry:** `low` notifications get a producer-side
`expiresAt` default (24h) so the inbox self-cleans. The self-destroy mechanic
already exists (`expiresAt`, honored on hydrate/notify/read).
### C.2 Where notifications live
One inbox, pinned on home: `NotificationsHomeCenter` directly below the
ambient base. This spec ratifies the existing decision (it replaced the
pull-down sheet; notifications live *on the dashboard*, not behind a gesture)
and the existing non-negotiables:
- **Not a slot widget** — pinned by `HomeScreen`, a registry declaration would
double-render it. Keep.
- **Stable ordering** — priority bucket, then recency; read-state styles rows
but never reorders them (tap-marks-read must not reshuffle under the
finger). Keep.
- **Self-hides when empty.** Keep.
- Height-capped, internal scroll, `MAX_RENDERED_ROWS` cap. Keep.
### C.3 Grouping and coalescing
- **Supersede (exists):** `groupKey` — a newer notification with the same key
replaces the older one. Correct for repeated reminders.
- **Count-aware coalescing (add):** when a producer emits N same-`groupKey`
digest notifications in a window, the surviving record should carry the
count ("3 tasks completed" not the last one silently eating two). Producer
writes `data.count`; the row renders the count chip. One endpoint away —
`NotificationService.notify` can increment on supersede.
- **Category sections (do NOT add):** the inbox is one chronological/priority
stack, like the iOS lock screen — not a tabbed/categorized triage app.
Categories drive icons and filtering in a future full-history view, not
structure on home.
### C.4 The `useNow(60s)` re-render fix (binding pattern)
**Today:** `NotificationsHomeCenter` calls `useNow(60_000)` at the top of the
component, so every minute the *entire* inbox — up to 100 rows, each with
buttons, over a `backdrop-blur-xl` surface — re-renders to refresh "5m ago"
strings. `NotificationRow` is deliberately un-memoized because a stable-props
memo would pin "just now" forever. The diagnosis is right; the cure is wrong.
**The pattern (applies to every relative-timestamp surface in the app):**
1. Extract a leaf: `<RelativeTime ts={createdAt} />` which itself calls a
shared `useNow` and renders the formatted string. The minute tick then
re-renders **only the `<time>` text nodes**, not rows, not the list, not
the glass surface.
2. Memoize `NotificationRow` on `(notification.id, readAt, title, body)`
safe once time rendering is out of the row's render path.
3. Make the shared ticker **visibility-gated**: one module-level interval
(not one per leaf), subscribed via `useSyncExternalStore`, paused on
`visibilitychange` when hidden and resynced on show. A backgrounded PWA
burns zero timer wakeups.
4. Audit `backdrop-blur-xl`: the glass sits on the non-scrolling section
(good), but a full-strength blur over an animated wallpaper is a
per-frame compositing cost on iOS Safari. Reduce to the minimum radius
that reads as glass, and provide the `supports-[backdrop-filter]`
fallback as the primary path on low-end devices.
The same pattern fixes `DefaultHomeWidgets`' clock (its `useNow` re-renders
the weather tile alongside the time text every minute).
### C.5 Dismissal semantics
- **Tap row** = mark read + follow safe deep link. Read ≠ removed; the record
stays as history. Never reorders (C.2).
- **X** = remove from inbox. Permanent for that record.
- **Mark-all-read** / **clear-all** = existing header actions. Keep.
- **`expiresAt`** = producer-declared self-destroy. Silent tier defaults to
24h (C.1); interrupt tier never defaults an expiry (an unread approval
request must not evaporate).
- **Acted-upon auto-read (add):** when the user completes the action a
notification pointed at (approves the approval, opens the task), the
producer should mark the corresponding notification read via `groupKey`
the inbox should never nag about a done thing.
---
## D. In-chat widget spec
### D.1 The artifact grammar — when the agent emits a widget vs text
A chat widget is the structured form of a conversational move. The closed
vocabulary (post #14524) maps to exactly five moves:
| Conversational move | Widget | Marker |
|---|---|---|
| "Pick one" | Choice | `[CHOICE]` |
| "I need these fields" | Form (+ date/time pickers, #14486) | `[FORM]` |
| "Here's what you could do next" | Followups | `[FOLLOWUPS]` |
| "I'm doing a multi-step thing" | Task / Workflow / Checklist | `[TASK]` `[WORKFLOW]` `[CHECKLIST]` |
| "Set up this integration" | Config / Connector | `[CONFIG:pluginId]` |
Plus the non-registry segments (permission card, secret/OAuth request, code
block, GenUI ui-spec) which are producing-action-backed.
**Emission rules:**
- **Text when the answer is prose.** A widget that could have been a sentence
is chrome. Never emit a form for one field the user could just type; never
emit a choice widget for yes/no when the user can say "yes".
- **Widget when structure removes ambiguity or typing.** ≥3 options → CHOICE.
≥2 required fields or any secret/date → FORM (with secrets always routed
through the sensitive-request flow, never plain fields — #14326).
- **Widget when the thing is live.** Anything with progress or streaming
state (task pipelines, workflows) is a widget, because text cannot update.
- **Code-emitted markers beat prompt guidance.** The action appends the
marker; the model never needs the grammar in context. Preference order
(board-15 synthesis, verbatim): *code-emitted markers > gated guide entry >
new provider*. If guidance must be in-prompt it pays rent per token per
turn — the `uiWidgets` budget ratchet (60 lines / 1200 tokens, test-enforced)
is load-bearing and must never be relaxed without a fight.
- **Results render as widgets only when interactive or better-than-prose
structured** (tables/charts via the GenUI path). A one-number answer is text.
### D.2 Lifecycle: artifacts scroll, they don't pin
- **Widgets scroll with the transcript.** They are utterances. No pinned
widget area above/below chat, no drawer (Principle 8). The transcript *is*
the history of artifacts.
- **Latest instance is live.** When the same logical widget is re-emitted
(a checklist updated, a workflow advanced), the newest instance is the
interactive one; older instances render as inert history. Display-only
widgets (WORKFLOW, CHECKLIST) already work by re-emission — keep that model
rather than mutating old messages.
- **Collapse-on-complete.** A widget whose job is done (form submitted,
connector connected, choice made) auto-collapses to a one-line summary row
with a chevron to re-expand — the `ChatWidgetShell` contract from #14412
(start expanded while incomplete; collapse to summary on completion;
standardized chevron). This spec adopts #14412's shell as the universal
chat-widget chrome.
- **Re-invocation is conversational.** There is no "widget history" UI. To get
a picker back, the user asks; the agent re-emits. Followup chips can offer
the re-emit ("change my answer").
- **Density model (existing, keep):** glance card → expand-in-place →
full view page (`TaskWidget` is the reference implementation). No widget
adds a fourth density or its own navigation chrome.
### D.3 Alignment with the uiWidgets / uiGenerative split (#14521/#14524)
This spec **aligns with** lalalune's direction; it does not fork:
- **`uiWidgets`** (closed marker vocabulary, ~58 lines, budget-ratcheted) is
the canonical path and the only prompt-cost the common turn pays. The five
conversational moves in D.1 are its complete surface. New widget kinds must
clear a high bar: they extend the *closed* vocabulary, add a parser + both
surface renderers + connector projections, and update `WIDGET_MATRIX.md`.
- **`uiGenerative`** (JSONL patches + component catalog) stays the gated
escape hatch for data-visualization intent only — with the in-get
word-boundary gate + continuation signal, since `relevanceKeywords` is
advisory metadata nothing consumes (#14528 autopsy). Dashboards, tables,
charts route here; everything else uses the closed vocabulary.
- **One respectful addition, not a fork:** as home widgets converge on the
same glance/expand shell as chat widgets, the *shell* (collapse contract,
summary rows, containment) should be one shared component family, so the
#14412 shell work serves both surfaces. This is an implementation
convergence, not a change to the provider architecture.
- **Connector projection discipline (board-15 matrix):** in-app renders the
full widget; button-capable connectors get the neutral projection
(`toNeutralLayout`); button-less surfaces need the plain-text sibling
projection (#14525's `toPlainTextFallback`). Config/setup cards stay
in-app; connectors get a link-out + prose status. No projecting forms onto
keyboards.
### D.4 Chat-widget perf budget
- A widget's internal state change (expand/collapse, field edit, connection
status) must not repaint the transcript — the `memo` + WeakMap normalize
cache invariants in `chat-transcript.tsx` / `chat-message.tsx` are locked
by tests and stay binding.
- Collapsed and off-screen widgets carry `content-visibility: auto` /
`contain` so N connector cards in a long transcript don't re-layout per
frame (#14412 item 4, pairs with #14333).
- Time/status-bearing widget subtrees follow the `<RelativeTime>` leaf
pattern (C.4) — the NotificationRow lesson generalizes.
---
## E. Migration map (today → target)
Inventory as of `develop` (post #14485), ordered by execution priority.
| # | Today | Verdict | Change | Size |
|---|---|---|---|---|
| 1 | `NotificationsHomeCenter``useNow(60s)` full-list re-render, un-memoized rows, `backdrop-blur-xl` | **keep + fix** | C.4 pattern: `<RelativeTime>` leaf, memoized rows, shared visibility-gated ticker, blur audit. Also fixes `DefaultHomeWidgets` clock tick. | M |
| 2 | `HOME_RENDER_CAP = 12` | **tighten** | Cap ranked residents at **5** (`WidgetHost.tsx`); wallpaper + base + notifications + 5 + chat bar is the whole surface. | S |
| 3 | `wallet.balance` home declaration | **demote** | Remove from `home` slot (component + routed view stay). Producer-side notification on material balance change replaces the resident card. | S |
| 4 | `health.sleep` home declaration | **demote** | Remove from `home` slot; `health` category notifications carry the alert case; routed view keeps the dashboard. | S |
| 5 | `goals.attention` home declaration | **merge** | At-risk goal renders as one flagged row inside the Today (todo) card; goals loses its standalone resident. | M |
| 6 | Notification coalescing | **add** | Count-aware `groupKey` supersede (`data.count`), silent-tier default `expiresAt`, acted-upon auto-read. | M |
| 7 | `needs-attention`, `calendar.upcoming`, `todo.items`, `model-download`, `agent-provisioning`, `ftu-welcome` | **keep** | The target resident set (§B). Calendar gains the 18h lookahead gate + `<RelativeTime>` countdown. | S |
| 8 | Chat widget shell (collapse-on-complete) | **tracked** | Already scoped as #14412 — this spec adopts its contract as universal; no new issue. | — |
| 9 | `HOME_CONTENT_TAXONOMY.md` / `WIDGET_MATRIX.md` | **update** | Point both at this spec as the north star once items 15 land (coordinate with #14327). | S |
Items 12 are pure wins with no product debate. Items 35 change the resident
set and should land as one reviewable sweep (same shape as #14485). Item 6 is
runtime + store. Item 7 is mostly ratification.
---
*Spec owner: this document. Amend by PR with rationale; the principles in §A
change only with a product-level decision.*
— [sol-orch]
+58
View File
@@ -0,0 +1,58 @@
<!--
Identity-slot catalog for the LifeOps HITL connector matrix. The table is
checked by scripts/lifeops/connector-paths.test.mjs so operators can trust that
OWNER/AGENT slot docs match the registry that drives the dashboard and lane
preflight.
-->
# HITL Identity Slot Catalog
The LifeOps HITL matrix needs to know whether a credential represents the
OWNER, the AGENT, a runtime OAuth role, a separate real account, or a
slotless/shared credential. This table documents the current `CONNECTOR_PATHS`
contract for every auth path.
`n/a` means the path does not carry that slot through env vars. It does not mean
the connector never needs two real identities in a live lane; for OAuth-role and
separate-account rows the role is carried by the runtime login flow or by
separate account provisioning instead of by env key names.
| Path ID | Family | Kind | Slot model | OWNER env vars | AGENT env vars | Gate env vars | Notes |
|---|---|---|---|---|---|---|---|
| `model.openai-key` | model | api-key | single/slotless | n/a | n/a | OPENAI_API_KEY | n/a |
| `model.cerebras-key` | model | api-key | single/slotless | n/a | n/a | CEREBRAS_API_KEY | n/a |
| `model.anthropic-key` | model | api-key | single/slotless | n/a | n/a | ANTHROPIC_API_KEY | n/a |
| `elizacloud.siwe-session` | elizacloud | cloud-session | single/slotless | n/a | n/a | ELIZA_CLOUD_API_KEY<br>ELIZAOS_CLOUD_API_KEY | The session artifact is the API key, not the browser's steward_session_token JWT (that one only lives in dashboard localStorage). |
| `elizacloud.api-key` | elizacloud | api-key | single/slotless | n/a | n/a | ELIZA_CLOUD_API_KEY<br>ELIZAOS_CLOUD_API_KEY | n/a |
| `github.gh-cli` | github | cloud-session | single/slotless | n/a | n/a | GITHUB_TOKEN | n/a |
| `github.pat` | github | pat | env slots | GITHUB_USER_PAT<br>ELIZA_E2E_GITHUB_USER_PAT | GITHUB_AGENT_PAT<br>ELIZA_E2E_GITHUB_AGENT_PAT | GITHUB_USER_PAT<br>GITHUB_AGENT_PAT<br>GITHUB_TOKEN | OWNER label maps to plugin-github role 'user', AGENT to 'agent' (plugins/plugin-github/src/accounts.ts); GITHUB_ACCOUNTS JSON and character.settings.github.accounts are the multi-account forms; GITHUB_TOKEN stays the ownerless legacy single token. |
| `github.device-oauth` | github | user-oauth | single/slotless | n/a | n/a | GITHUB_OAUTH_CLIENT_ID | Device flow returns the consenting user's token into the legacy `GITHUB_TOKEN` slot; the OAuth app registration is owner-managed. |
| `github.user-oauth` | github | user-oauth | single/slotless | n/a | n/a | GITHUB_OAUTH_CLIENT_ID<br>GITHUB_OAUTH_CLIENT_SECRET<br>GITHUB_OAUTH_REDIRECT_URI | n/a |
| `google.oauth-owner` | google | user-oauth | OAuth requestedRole | n/a | n/a | GOOGLE_CLIENT_ID<br>GOOGLE_CLIENT_SECRET<br>GOOGLE_REDIRECT_URI | n/a |
| `google.oauth-agent` | google | user-oauth | OAuth requestedRole | n/a | n/a | GOOGLE_CLIENT_ID<br>GOOGLE_CLIENT_SECRET<br>GOOGLE_REDIRECT_URI | OWNER and AGENT are separate real Google accounts (owner-agent matrix doc §3); the role rides oauth start metadata (packages/core/src/connectors/oauth-role.ts), not env names. |
| `telegram.bot` | telegram | bot | single/slotless | n/a | n/a | TELEGRAM_BOT_TOKEN | n/a |
| `telegram.user-client` | telegram | user-client | single/slotless | n/a | n/a | TELEGRAM_API_ID<br>TELEGRAM_API_HASH<br>TELEGRAM_OWNER_SESSION<br>TELEGRAM_USER_SESSION | Documented ahead of a gramjs integration; TELEGRAM_OWNER_SESSION is the owner-scoped key required by the HITL issue, while TELEGRAM_USER_SESSION remains a temporary read alias. Telegram Desktop's tdata is proprietary/encrypted and is not a credential source. |
| `discord.bot` | discord | bot | single/slotless | n/a | n/a | DISCORD_API_TOKEN<br>DISCORD_BOT_TOKEN | n/a |
| `discord.user-token` | discord | user-client | single/slotless | n/a | n/a | DISCORD_USER_TOKEN | n/a |
| `discord.user-oauth` | discord | user-oauth | single/slotless | n/a | n/a | DISCORD_CLIENT_ID<br>DISCORD_CLIENT_SECRET | n/a |
| `slack.bot` | slack | bot | single/slotless | n/a | n/a | SLACK_BOT_TOKEN<br>SLACK_APP_TOKEN | n/a |
| `slack.user-token` | slack | user-client | single/slotless | n/a | n/a | SLACK_USER_TOKEN | n/a |
| `signal.desktop-bridge` | signal | local-bridge | single/slotless | n/a | n/a | n/a | n/a |
| `signal.cli` | signal | user-client | single/slotless | n/a | n/a | SIGNAL_ACCOUNT_NUMBER<br>SIGNAL_HTTP_URL<br>SIGNAL_CLI_PATH | Availability runs `--version` and the read-only `listAccounts`; the live probe also verifies the configured account is among the linked accounts. |
| `whatsapp.cloud-api` | whatsapp | api-key | single/slotless | n/a | n/a | ELIZA_WHATSAPP_ACCESS_TOKEN<br>ELIZA_WHATSAPP_PHONE_NUMBER_ID | ELIZA_WHATSAPP_* and bare WHATSAPP_* spellings are write-aliased by the dashboard; either satisfies the probe. |
| `imessage.macos` | imessage | local-bridge | single/slotless | n/a | n/a | n/a | n/a |
| `imessage.bluebubbles` | imessage | local-bridge | single/slotless | n/a | n/a | BLUEBUBBLES_SERVER_URL<br>BLUEBUBBLES_PASSWORD | An installed-but-stopped server is 'available' (row shows, probe reports connection refused with the start hint); the password lives in the server's config.db. |
| `x.oauth1-user` | x | user-oauth | single/slotless | n/a | n/a | TWITTER_API_KEY<br>TWITTER_API_SECRET_KEY<br>TWITTER_ACCESS_TOKEN<br>TWITTER_ACCESS_TOKEN_SECRET | n/a |
| `x.bearer-app` | x | api-key | single/slotless | n/a | n/a | TWITTER_BEARER_TOKEN | n/a |
| `x.agent-account` | x | user-oauth | separate real account | n/a | n/a | n/a | n/a |
| `twilio.api` | twilio | api-key | single/slotless | n/a | n/a | TWILIO_ACCOUNT_SID<br>TWILIO_AUTH_TOKEN | n/a |
| `health.strava` | health | api-key | single/slotless | n/a | n/a | STRAVA_ACCESS_TOKEN | n/a |
| `health.oura` | health | api-key | single/slotless | n/a | n/a | OURA_ACCESS_TOKEN | n/a |
| `health.fitbit` | health | api-key | single/slotless | n/a | n/a | FITBIT_ACCESS_TOKEN | n/a |
| `health.withings` | health | api-key | single/slotless | n/a | n/a | WITHINGS_ACCESS_TOKEN | n/a |
| `health.healthkit` | health | local-bridge | single/slotless | n/a | n/a | ELIZA_HEALTHKIT_CLI_PATH | n/a |
| `health.google-fit` | health | api-key | single/slotless | n/a | n/a | ELIZA_GOOGLE_FIT_ACCESS_TOKEN | n/a |
| `finance.plaid` | finance | api-key | single/slotless | n/a | n/a | PLAID_CLIENT_ID<br>PLAID_SECRET | n/a |
| `finance.paypal` | finance | api-key | single/slotless | n/a | n/a | PAYPAL_CLIENT_ID<br>PAYPAL_CLIENT_SECRET | LIFEOPS_FINANCE_CSV_FIXTURE remains the keyless finance alternative recognized by CONNECTOR_GROUPS; it is a fixture, not an auth path. |
| `crypto.evm` | crypto | api-key | single/slotless | n/a | n/a | EVM_PRIVATE_KEY | n/a |
| `crypto.solana` | crypto | api-key | single/slotless | n/a | n/a | SOLANA_PRIVATE_KEY | n/a |
+179
View File
@@ -0,0 +1,179 @@
# Human-in-the-Loop (HITL) test inventory
> Part of #14381 (HITL inventory / developer-facing runner), split from the
> device-review rollup #14317 / #14395. Companion: #14382 (resettable
> onboarding). Plugs into the existing evidence loop
> (`scripts/e2e-recordings/*` → contact sheets → viewer) and nubs'
> `launch-qa` board — this does **not** replace the device passes, it tells a
> developer *which* things a human/device still has to eyeball and pre-stages
> the frames so the review is a contact-sheet skim, not a manual drive.
## What "HITL" means here
A flow is HITL when an automated assertion **cannot** decide pass/fail — the
signal lives in a human's eye, thumb, ear, or a real device capability the
headless runner can't fake:
- **Visual polish** — clipping, overflow, contrast, spacing, safe-area,
leaked error strings. A DOM node can *exist* and still look broken.
- **Gesture / motion feel** — scroll momentum, sheet detents, drag, long-press,
frame glitches. "It renders" ≠ "it feels right."
- **Keyboard behavior** — soft-keyboard push/resize, focus retention, IME,
input obscuring on real mobile.
- **Onboarding** — first impression, copy, pacing; historically untestable
because you can't reset a real, memory-laden agent (→ #14382).
- **Login / auth** — real OAuth round-trip, tenant, token, redirect. Injected
state proves nothing (FLEET.md ban; qa-agent's staging-401 lesson).
- **Wallet** — real signing, balances, "not found" / error copy (#14426).
- **Notifications** — permission prompt, delivery, tap-through.
- **Camera / mic** — real permission grant + capture feel.
## Coverage legend
| Mark | Meaning |
|---|---|
| ✅ auto | Fully machine-decidable; e2e asserts it, no human needed |
| 🟡 frame | Headless can **stage + screenshot** the state; a human judges the frame |
| 🔴 device | Needs a **real device** capability (soft keyboard, wallet, camera, push) |
| ⛔ none | No current coverage; manual-only today |
## Inventory
### Onboarding
| Flow / decision point | Why HITL | Current coverage | What a harness can pre-stage |
|---|---|---|---|
| First-run welcome + name/style pick | first impression, copy, pacing | 🟡 frame — `packages/ui` first-run e2e + `test:ftu-home-e2e`; `capture-android-emu` / `capture-ios-sim` drive the real Capacitor onboarding | contact-sheet of each step (welcome → name → style → provider → complete) via the replay entry (#14382) so a fully-onboarded dev can re-walk it without a wipe |
| Provider / model selection | catalog correctness + visual density | 🟡 frame — first-run options e2e | screenshot of the provider grid at each viewport |
| Boot-trouble-speaks-in-chat (no banners) | tone / does the copy read right | 🟡 frame — `App.chat-overlay-first-run.test.tsx`, #14168 detent work | staged error card frame |
| Onboarding **persists** across restart | correctness (not visual) | ✅ auto — `first-run-persistence.restart.test.ts` (real `saveElizaConfig`/`loadElizaConfig`) | n/a (already machine-decided) |
| Onboarding **re-runnable** on a real agent | can't reset without nuking memories | 🟡 frame — dev-gated `?onboarding-replay=1`, wired on the app boot path (`packages/app/src/first-run-boot-patches.ts`; regression test `packages/app/test/first-run-boot-patches.test.ts`); steps below | dev-gated `?onboarding-replay=1` client overlay (no server wipe) |
### Onboarding replay / reset steps (#14382)
Two dev query params re-run onboarding. Both are wired on the main-window boot
path in `packages/app/src/first-run-boot-patches.ts` (called from
`packages/app/src/main.tsx`; the arm-before-patch order is load-bearing and
locked by `packages/app/test/first-run-boot-patches.test.ts`).
**Non-destructive replay — the default for QA on a real, memory-laden agent:**
1. Boot the dev app (`bun run dev` at the repo root, or
`bun run --cwd packages/app dev:shared` on a parallel lane).
2. Append `?onboarding-replay=1` to the URL and reload. Onboarding renders
again: a client overlay (`packages/ui/src/platform/onboarding-replay.ts`)
makes the client *report* fresh while the real agent, its config, the
persisted `elizaos:active-server`, and all server state (PGlite data dir,
conversations, knowledge, trajectories) stay untouched.
3. Walk the steps for QA/screenshots. Submitting the replayed onboarding
re-applies the chosen onboarding *config* to the same agent (that is the
full-path exercise); it never deletes data.
4. To leave without applying anything: drop the param and reload — the agent
is exactly as it was.
Headless proof/capture driver (screenshots + non-destruction assertions
against a running dev stack):
`node packages/app/scripts/onboarding-replay-evidence.mjs --out <dir>`.
**`?reset` — genuinely fresh client session (stateful, still no server wipe):**
clears the client's persisted session (active server, setup step,
first-run-complete) and sets the durable force-fresh flag so the next boot
lands on onboarding as a new client. Use it for "new device" QA; unlike the
replay it forgets which server/agent the client was attached to.
**Safeguards** (so the test path cannot be confused with a destructive
production account reset): `?onboarding-replay=1` is compiled inert outside
dev builds (`import.meta.env.DEV`); neither param can reach
`POST /api/agent/reset` — the only path that wipes agent memories — and the
module tests assert no delete/reset/clear method ever fires during a replay.
### First chat
| Flow / decision point | Why HITL | Current coverage | What a harness can pre-stage |
|---|---|---|---|
| First message send → streamed reply | live inference + feel | 🟡 frame — app `test:e2e` chat suite (stubbed); real inference needs credits (see #14424 credit path) | staged send + streamed-token frames; a human confirms cadence |
| Suggestions / FTU home widgets | visual + relevance | 🟡 frame — `test:suggestions-e2e`, `test:ftu-home-e2e` | rest + populated frames |
| Chat scroll / infinite scroll / momentum | gesture feel | 🟡 frame — `test:chat-scroll-web-e2e`, `test:chat-infinite-scroll-e2e`, `test:chat-perf-gate`; scroll **cert** superset #14380 | scroll-position + perf-gate frames |
| Chat sheet detents / frame glitch | motion feel | 🟡 frame — `test:chat-sheet-e2e`, `test:chat-sheet-frame-glitch-e2e`, `test:chatux-gesture-e2e` | detent-state frames |
### Login / auth
| Flow / decision point | Why HITL | Current coverage | What a harness can pre-stage |
|---|---|---|---|
| Cloud sign-in (OAuth round-trip) | real token/tenant/redirect | 🟡 frame — `cloud-e2e` mock login; **real** sign-in is device-only (#13609, #13611, #13610) | mock-login contact sheet; real login stays 🔴 device |
| Stale-token / no-credits resume (spam guard) | correctness + no visual spam | ✅ auto — `use-first-run-conductor.test.ts` (#14387 / PR #14423) | n/a |
| Staging tenant correctness | env-bake correctness | ✅ auto (process) — FLEET.md rule + qa-agent SW fix #14409 | n/a |
### Wallet
| Flow / decision point | Why HITL | Current coverage | What a harness can pre-stage |
|---|---|---|---|
| Wallet view render | leaked "Not found" red string (#14426, P1) | 🟡 frame | staged wallet-empty + wallet-populated frames so the leak is obvious on the sheet |
| Real signing / balances | real key material | 🔴 device — `platform/e2e-wallet.ts` stub only | n/a headless |
### Notifications / camera / mic
| Flow / decision point | Why HITL | Current coverage | What a harness can pre-stage |
|---|---|---|---|
| Permission priming prompt | copy + timing | 🟡 frame — `test:permission-priming-e2e` | priming-prompt frame |
| Mic permission (first-run) | real grant + capture | 🔴 device — `use-microphone-permission.ts` | n/a headless |
| Push delivery + tap-through | real device push | 🔴 device — Seeker pass (qa-agent) | n/a headless |
### Visual polish (cross-cutting launch-qa)
| Issue | Why HITL | Coverage | Pre-stage |
|---|---|---|---|
| #14427 launcher 'Relationships' label clips | pixel clipping | 🟡 frame | launcher tile frame at each width |
| #14426 wallet red 'Not found' leak (P1) | error-string leak | 🟡 frame | wallet frame |
| #14425 'when this Mac is asleep' on non-Mac | platform copy | 🟡 frame | CloudOverview frame on non-mac env |
| #14380 scroll + tap-target cert | gesture + hit-area | 🟡 frame — superset harness (sibling sol lane) | per-widget scroll/tap frames |
| #14379 mobile chat/search keyboard states | soft-keyboard | 🔴 device | n/a headless — real-device cert |
### LifeOps live validation (#11632) — `lifeops-live` group
The live, account/device-backed LifeOps operator lane. Deliberately **outside**
the golden-path default (`HITL_GOLDEN_GROUPS`) because it needs real
credentials and devices; select it explicitly:
`node scripts/hitl/run-hitl.mjs --groups=lifeops-live`. Runbook:
`plugins/plugin-personal-assistant/docs/LIFEOPS_LIVE_VALIDATION.md`.
| Flow / decision point | Why HITL | Current coverage | What a harness can pre-stage |
|---|---|---|---|
| Connector credential intake / readiness | a human confirms which connector creds are present (and fresh) before live lanes run | 🟡 frame — `bun run lifeops:hitl` (`scripts/lifeops/hitl-credential-dashboard.mjs` v2: per-auth-path rows from `connector-paths.mjs`, layered env sources, one-click gh/SIWE/signal-link, probes recorded to `docs/testing/hitl-ledger.json`) | readiness dashboard frame (values masked to last-4; freshness green ≤7d / yellow >7d / red >30d-or-never) |
| 9-state OWNER/AGENT permission matrix (credentialed) | correctness (not visual) | ✅ auto — `owner-agent-permission-matrix.integration.test.ts` under `LIFEOPS_PERMISSION_MATRIX=1` | n/a (machine-decided) |
| Live connector suites | correctness; creds present → live, absent → clean skip | ✅ auto — `node scripts/lifeops/run-11632-live-lanes.mjs` (each suite `describeIf`-gates on its creds) | n/a (machine-decided) |
| LifeOps split views populated with live data | populated/empty/error render quality with real data | 🟡 frame — `/lifeops-live-test` view + `bun run --cwd packages/app audit:app` + `bun run test:e2e:record` | populated-view frames per split view, desktop + mobile |
| iOS/macOS native flows (HealthKit, Family Controls, SelfControl) | real OS permission dialogs + native capabilities | 🔴 device | n/a headless |
| Android native flows (Health Connect, SMS default-role, Usage Access) | real OS permission dialogs + SIM-backed capabilities | 🔴 device | n/a headless |
## How the runner surfaces this (design)
The runner does **not** invent a new capture stack. It:
1. Selects the **HITL subset** of `UI_E2E_SUITES` relevant to a decision point
(onboarding / first-chat / login) via a tag manifest
(`scripts/hitl/hitl-manifest.mjs`).
2. Runs them through the existing `e2e-recordings` pipeline (record → extract
frames → `generate-contact-sheets``generate-viewer`).
3. Emits a **HITL contact sheet** where each staged frame is labelled with its
decision point + a `pass / fail / blocked` slot, so a developer reviews a
sheet *during* development instead of driving the app by hand or waiting for
a post-merge device pass.
Pass/fail/blocked is recorded by the human in the viewer; 🔴 device rows are
marked `blocked (device)` automatically and routed to the Seeker pass
(qa-agent) rather than pretending headless covered them. **Auth/wallet/real
inference use real flows only** (FLEET.md) — the harness stages the *mockable*
frames and explicitly defers the rest to device, never fabricates state as
evidence.
## Non-goals / honesty
- The `packages/ui` e2e env is known-flaky; this inventory marks what *can*
run headless (🟡) vs what genuinely needs a device (🔴). It does not claim
headless coverage where only a device suffices.
- This is the inventory + the onboarding-replay slice + a harness skeleton.
Wiring every 🟡 row into the tagged sheet and the full launch-qa board
labelling is follow-up (tracked on #14381).
— [sol-orch]
+450
View File
@@ -0,0 +1,450 @@
{
"version": 1,
"updatedAt": "2026-07-06T04:45:39.645Z",
"entries": {
"crypto.evm": {
"pathId": "crypto.evm",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.384Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 0,
"skipped": 1
}
},
"crypto.solana": {
"pathId": "crypto.solana",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.384Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 0,
"skipped": 1
}
},
"discord.bot": {
"pathId": "discord.bot",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.239Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"discord.user-oauth": {
"pathId": "discord.user-oauth",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.239Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 0,
"skipped": 1
}
},
"discord.user-token": {
"pathId": "discord.user-token",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.239Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"elizacloud.api-key": {
"pathId": "elizacloud.api-key",
"lastSuccessAt": "2026-07-06T04:45:36.169Z",
"lastRunAt": "2026-07-06T04:45:36.169Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 1,
"failed": 0,
"skipped": 0
}
},
"elizacloud.siwe-session": {
"pathId": "elizacloud.siwe-session",
"lastSuccessAt": "2026-07-06T04:45:36.168Z",
"lastRunAt": "2026-07-06T04:45:36.168Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 1,
"failed": 0,
"skipped": 0
}
},
"finance.paypal": {
"pathId": "finance.paypal",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.384Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"finance.plaid": {
"pathId": "finance.plaid",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.384Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"github.gh-cli": {
"pathId": "github.gh-cli",
"lastSuccessAt": "2026-07-06T04:45:36.236Z",
"lastRunAt": "2026-07-06T04:45:36.236Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 1,
"failed": 0,
"skipped": 0
}
},
"github.pat": {
"pathId": "github.pat",
"lastSuccessAt": "2026-07-06T04:45:36.237Z",
"lastRunAt": "2026-07-06T04:45:36.237Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 1,
"failed": 0,
"skipped": 0
}
},
"github.user-oauth": {
"pathId": "github.user-oauth",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.238Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 0,
"skipped": 1
}
},
"google.oauth-agent": {
"pathId": "google.oauth-agent",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.238Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"google.oauth-owner": {
"pathId": "google.oauth-owner",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.238Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"health.fitbit": {
"pathId": "health.fitbit",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.384Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"health.google-fit": {
"pathId": "health.google-fit",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.384Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"health.healthkit": {
"pathId": "health.healthkit",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.384Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 0,
"skipped": 1
}
},
"health.oura": {
"pathId": "health.oura",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.384Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"health.strava": {
"pathId": "health.strava",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.384Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"health.withings": {
"pathId": "health.withings",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.384Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"imessage.bluebubbles": {
"pathId": "imessage.bluebubbles",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.370Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"imessage.macos": {
"pathId": "imessage.macos",
"lastSuccessAt": "2026-07-06T04:45:36.355Z",
"lastRunAt": "2026-07-06T04:45:36.355Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 1,
"failed": 0,
"skipped": 0
}
},
"lifeops.permission-matrix": {
"pathId": "lifeops.permission-matrix",
"lastSuccessAt": "2026-07-06T04:38:20.049Z",
"lastRunAt": "2026-07-06T04:38:20.049Z",
"lane": "live-lane-1",
"commit": "d961ec6c32c",
"counts": {
"passed": 20,
"failed": 0,
"skipped": 0
}
},
"model.anthropic-key": {
"pathId": "model.anthropic-key",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.168Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"model.cerebras-key": {
"pathId": "model.cerebras-key",
"lastSuccessAt": "2026-07-06T04:45:36.160Z",
"lastRunAt": "2026-07-06T04:45:36.160Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 1,
"failed": 0,
"skipped": 0
}
},
"model.openai-key": {
"pathId": "model.openai-key",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.160Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"signal.cli": {
"pathId": "signal.cli",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.355Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 0,
"skipped": 1
}
},
"signal.desktop-bridge": {
"pathId": "signal.desktop-bridge",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.323Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 0,
"skipped": 1
}
},
"slack.bot": {
"pathId": "slack.bot",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.239Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"slack.user-token": {
"pathId": "slack.user-token",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.239Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"telegram.bot": {
"pathId": "telegram.bot",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.238Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"telegram.user-client": {
"pathId": "telegram.user-client",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.238Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 0,
"skipped": 1
}
},
"twilio.api": {
"pathId": "twilio.api",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.384Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"whatsapp.cloud-api": {
"pathId": "whatsapp.cloud-api",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.355Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"x.agent-account": {
"pathId": "x.agent-account",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.384Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 0,
"skipped": 1
}
},
"x.bearer-app": {
"pathId": "x.bearer-app",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.383Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
},
"x.oauth1-user": {
"pathId": "x.oauth1-user",
"lastSuccessAt": null,
"lastRunAt": "2026-07-06T04:45:36.383Z",
"lane": "dashboard-probe",
"commit": "f1a95d28503",
"counts": {
"passed": 0,
"failed": 1,
"skipped": 0
}
}
}
}
+66
View File
@@ -0,0 +1,66 @@
# HITL Credential Probe Catalog
This is the liveness contract for the LifeOps HITL credential intake lane
(#14799/#11632). Each row is one auth path from
`scripts/lifeops/connector-paths.mjs`; the dashboard and lane preflight use the
same path ids and write outcomes into `docs/testing/hitl-ledger.json`.
Probe outcomes use three states:
- `ok`: the credential authenticated against the cheapest read-only endpoint.
- `unauthorized`: the provider rejected the credential or scope.
- `unavailable`: the machine, bridge, app, account, or network needed for the
probe is absent. This is not a bad credential and should render as skipped.
Probe details must never print raw secret values. The dashboard masks known
secret-shaped values to their last four characters before rendering or writing
logs.
| Path id | Kind | Credential / auth path | Env slots | Probe state | Free or near-free liveness call | Success proves | Cost | Rate-limit notes | Failure interpretation |
|---|---|---|---|---|---|---|---|---|---|
| `model.openai-key` | api-key | OpenAI API key | `OPENAI_API_KEY`, `OPENAI_BASE_URL` | wired | `GET {OPENAI_BASE_URL\|https://api.openai.com/v1}/models` with Bearer auth | Key is accepted by the configured OpenAI-compatible endpoint and can list models. | $0 metered read | Low volume only; model-list quotas vary by compatible provider. | 401/403 = unauthorized or wrong base URL; timeout/DNS = unavailable. |
| `model.cerebras-key` | api-key | Cerebras API key | `CEREBRAS_API_KEY` | wired | `GET https://api.cerebras.ai/v1/models` with Bearer auth | Key is accepted by Cerebras model routing. | $0 metered read | Keep to dashboard/preflight cadence; no message generation. | 401/403 = unauthorized; 5xx/network = unavailable. |
| `model.anthropic-key` | api-key | Anthropic API key | `ANTHROPIC_API_KEY` | wired | `GET https://api.anthropic.com/v1/models` with `x-api-key` | Key is accepted by Anthropic and can list models. | $0 metered read | Respect Anthropic API read limits; no model call. | 401/403 = unauthorized; 429 can be temporary unavailable. |
| `elizacloud.siwe-session` | cloud-session | Eliza Cloud session via headless SIWE | `ELIZA_CLOUD_API_KEY`, `ELIZAOS_CLOUD_API_KEY`, `PRIVATE_KEY`, `SIWE_BASE` | wired | `GET {SIWE_BASE\|https://api.elizacloud.ai}/api/v1/credits/balance` with Bearer API key | Session API key maps to a cloud account and can read balance metadata. | $0 read | Run sparingly; this is an authenticated account read. | 401/403 = unauthorized/session expired; 5xx/network = unavailable. |
| `elizacloud.api-key` | api-key | Eliza Cloud API key paste | `ELIZA_CLOUD_API_KEY`, `ELIZAOS_CLOUD_API_KEY`, `SIWE_BASE` | wired | `GET {SIWE_BASE\|https://api.elizacloud.ai}/api/v1/credits/balance` with Bearer API key | API key authenticates to the configured cloud base. | $0 read | Same as SIWE session row. | 401/403 = unauthorized; network/base mismatch = unavailable. |
| `github.gh-cli` | cloud-session | Reuse `gh` CLI keyring token | `GITHUB_TOKEN` | wired | `gh auth token` then `GET https://api.github.com/user` | The local gh keyring is authenticated and yields a token that GitHub accepts. | $0 read | GitHub core REST rate limit applies; headers expose remaining quota. | Missing `gh`/not logged in = unavailable; 401/403 = unauthorized. |
| `github.pat` | pat | GitHub PATs (owner + agent slots) | `GITHUB_TOKEN`, `GITHUB_USER_PAT`, `GITHUB_AGENT_PAT`, `ELIZA_E2E_GITHUB_USER_PAT`, `ELIZA_E2E_GITHUB_AGENT_PAT` | wired | `GET https://api.github.com/user` for each configured PAT | Each configured owner/agent token authenticates and exposes scope headers. | $0 read | Core REST rate limit; one read per configured slot. | 401/403 = unauthorized or wrong scopes; network = unavailable. |
| `github.device-oauth` | user-oauth | GitHub OAuth device login | `GITHUB_OAUTH_CLIENT_ID`, `GITHUB_TOKEN` | wired | `POST https://github.com/login/device/code`, poll access-token grant, then `GET https://api.github.com/user` | The OAuth app has device flow enabled and the consenting user token is accepted by GitHub. | $0 control-plane/read | Honor GitHub's returned polling interval and `slow_down`; one user-driven flow at a time. | Missing client registration = unavailable owner-setup state; expired/denied flow = explicit login failure; 401/403 probe = unauthorized. |
| `github.user-oauth` | user-oauth | GitHub user OAuth app | `GITHUB_OAUTH_CLIENT_ID`, `GITHUB_OAUTH_CLIENT_SECRET`, `GITHUB_OAUTH_REDIRECT_URI` | wired | `GET https://api.github.com/user` with the acquired user token | OAuth app token authenticates as the consenting user. | $0 read | Core REST rate limit on the user token. | Missing OAuth app config = unavailable; 401/403 = unauthorized. |
| `google.oauth-owner` | user-oauth | Google OAuth OWNER account | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` | wired | Client config presence plus connector OAuth start with `metadata.requestedRole=OWNER` | OWNER account can start the real Google consent flow with the configured client. | $0 until consent; tokeninfo/read checks are free reads after consent. | Google OAuth and tokeninfo quotas apply. | Missing client config = unavailable; OAuth/token rejection = unauthorized. |
| `google.oauth-agent` | user-oauth | Google OAuth AGENT account | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URI` | wired | Client config presence plus connector OAuth start with `metadata.requestedRole=AGENT` | AGENT account can start the real Google consent flow with the configured client. | $0 until consent; tokeninfo/read checks are free reads after consent. | Same as OWNER row. | Missing client config = unavailable; OAuth/token rejection = unauthorized. |
| `telegram.bot` | bot | Telegram bot token | `TELEGRAM_BOT_TOKEN`, `TELEGRAM_TEST_CHAT_ID`, `TELEGRAM_ALLOWED_CHATS` | wired | `GET https://api.telegram.org/bot<token>/getMe` | Bot token is valid and resolves to a bot identity. | $0 read | Telegram Bot API limits are generous; no messages sent. | `ok:false`/401 = unauthorized; network = unavailable. |
| `telegram.user-client` | user-client | Telegram user client session | `TELEGRAM_API_ID`, `TELEGRAM_API_HASH`, `TELEGRAM_OWNER_SESSION`, `TELEGRAM_USER_SESSION` | documented-skip | GramJS `getMe` over the string session | Session would authenticate as the owner user once the GramJS lane lands. | $0 read | MTProto flood limits apply; not wired in this repo yet. | Missing API credentials or session/file = unavailable; bad session = unauthorized after adapter lands. |
| `discord.bot` | bot | Discord bot token | `DISCORD_API_TOKEN`, `DISCORD_BOT_TOKEN` | wired | `GET https://discord.com/api/v10/users/@me` with `Authorization: Bot <token>` | Bot token is valid and resolves to the bot user. | $0 read | Discord REST rate limits apply by route. | 401/403 = unauthorized; 429/network = unavailable. |
| `discord.user-token` | user-client | Discord user token paste | `DISCORD_USER_TOKEN` | wired | `GET https://discord.com/api/v10/users/@me` with raw user token | User-context token authenticates to Discord. | $0 read | User-token automation is policy-sensitive; keep probe read-only. | 401/403 = unauthorized; 429/network = unavailable. |
| `discord.user-oauth` | user-oauth | Discord user OAuth app | `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` | documented-skip | OAuth token exchange, then `GET /users/@me` | OAuth app can produce a user token accepted by Discord. | $0 read | Discord OAuth and REST limits apply. | Missing OAuth app = unavailable; invalid token/scope = unauthorized. |
| `slack.bot` | bot | Slack bot + app tokens | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN`, `SLACK_CHANNEL_IDS`, `SLACK_SIGNING_SECRET` | wired | `POST https://slack.com/api/auth.test` plus `apps.connections.open` | Bot token authenticates and app token can open Socket Mode. | $0 reads/control-plane call | Slack Web API method limits apply. | `ok:false`/invalid_auth = unauthorized; network = unavailable. |
| `slack.user-token` | user-client | Slack user token | `SLACK_USER_TOKEN` | wired | `POST https://slack.com/api/auth.test` with xoxp token | User-context token authenticates to Slack. | $0 read | Slack Web API method limits apply. | `invalid_auth`/missing_scope = unauthorized; network = unavailable. |
| `signal.desktop-bridge` | local-bridge | Signal Desktop status | none | documented-skip | Local install and profile presence | Signal Desktop is installed and has a local linked-device profile; the encrypted DB is not treated as a credential. | $0 local check | No network call. | Missing app and missing profile are separate unavailable states; both must be present. |
| `signal.cli` | user-client | signal-cli or signal-cli-rest-api | `SIGNAL_ACCOUNT_NUMBER`, `SIGNAL_HTTP_URL`, `SIGNAL_CLI_PATH` | wired | `GET {SIGNAL_HTTP_URL}/v1/about` + `/v1/accounts`, else `signal-cli --version` + `listAccounts` | The client is reachable and the configured account is actually linked. | $0 local/read | Read-only local bridge calls; no messages sent. | Missing binary, unrunnable binary, no linked account, and selected-account mismatch are distinct failures. |
| `whatsapp.cloud-api` | api-key | WhatsApp Cloud API token + phone id | `ELIZA_WHATSAPP_ACCESS_TOKEN`, `ELIZA_WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID` | wired | `GET https://graph.facebook.com/v19.0/{PHONE_NUMBER_ID}?fields=display_phone_number` | Token can read the configured phone-number resource. | $0 read | Meta Graph API rate limits apply. | 401/403 = unauthorized/wrong asset; network = unavailable. |
| `imessage.macos` | local-bridge | macOS Messages bridge | `ELIZA_IMESSAGE_BACKEND` | wired | Local readability check for `~/Library/Messages/chat.db` | macOS Messages DB is reachable with current Full Disk Access. | $0 local check | No network call. | Not macOS/no FDA/no DB = unavailable, not bad credential. |
| `imessage.bluebubbles` | local-bridge | BlueBubbles server | `BLUEBUBBLES_SERVER_URL`, `BLUEBUBBLES_PASSWORD` | wired | `GET {BLUEBUBBLES_SERVER_URL\|http://localhost:1234}/api/v1/ping?password=<password>` | BlueBubbles server is reachable and password is accepted. | $0 local/read | Local or LAN call only. | 401 = unauthorized; connection refused/stopped app = unavailable. |
| `x.oauth1-user` | user-oauth | X OAuth1 user context | `TWITTER_API_KEY`, `TWITTER_API_SECRET_KEY`, `TWITTER_ACCESS_TOKEN`, `TWITTER_ACCESS_TOKEN_SECRET`, `X_API_KEY` | wired | `GET https://api.x.com/2/users/me` signed with OAuth1 HMAC-SHA1 | Owner user token authenticates to X user context. | $0 read | X API rate limits are tight; one probe per preflight. | 401/403 = unauthorized or app access issue; 429/network = unavailable. |
| `x.bearer-app` | api-key | X app-only bearer token | `TWITTER_BEARER_TOKEN` | wired | `GET https://api.x.com/2/users/me` with Bearer token | Bearer token reaches X; app-only tokens may authenticate but lack user context. | $0 read | X API rate limits are tight. | 401 = unauthorized; 403 user-context denial may be expected app-only limitation. |
| `x.agent-account` | user-oauth | X AGENT separate real account | none | documented-skip | Same OAuth1 `GET /2/users/me` with the agent account's tokens | Separate AGENT account would authenticate when a dedicated account is connected. | $0 read | Same as owner X row. | No env slots by design = unavailable until manually connected. |
| `twilio.api` | api-key | Twilio account SID + auth token | `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_PHONE_NUMBER`, `TWILIO_WEBHOOK_URL` | wired | `GET https://api.twilio.com/2010-04-01/Accounts/{SID}.json` with Basic auth | SID/auth token pair can read the account resource. | $0 read | Twilio REST read limits apply. | 401/403 = unauthorized; 5xx/network = unavailable. |
| `health.strava` | api-key | Strava access token | `STRAVA_ACCESS_TOKEN` | wired | `GET https://www.strava.com/api/v3/athlete` | Strava token authenticates for athlete profile reads. | $0 read | Strava per-app/athlete read limits apply. | 401/403 = unauthorized/expired; 429/network = unavailable. |
| `health.oura` | api-key | Oura access token | `OURA_ACCESS_TOKEN` | wired | `GET https://api.ouraring.com/v2/usercollection/personal_info` | Oura token authenticates for user profile reads. | $0 read | Oura API limits apply. | 401/403 = unauthorized; network = unavailable. |
| `health.fitbit` | api-key | Fitbit access token | `FITBIT_ACCESS_TOKEN` | wired | `GET https://api.fitbit.com/1/user/-/profile.json` | Fitbit token authenticates for user profile reads. | $0 read | Fitbit API limits apply. | 401/403 = unauthorized/expired; 429/network = unavailable. |
| `health.withings` | api-key | Withings access token | `WITHINGS_ACCESS_TOKEN` | wired | `GET https://wbsapi.withings.net/v2/user?action=getdevice` | Withings token authenticates and can read device metadata. | $0 read | Withings can return API errors inside HTTP 200; inspect body status. | Nonzero body status/401 = unauthorized; network = unavailable. |
| `health.healthkit` | local-bridge | HealthKit export CLI | `ELIZA_HEALTHKIT_CLI_PATH` | documented-skip | Local device CLI responds on Apple platform | HealthKit export tool is installed for device-local proof. | $0 local check | Device/OS permission prompts are outside headless probe. | Not Apple/no CLI = unavailable; denied OS permission appears unavailable until device lane. |
| `health.google-fit` | api-key | Google Fit access token | `ELIZA_GOOGLE_FIT_ACCESS_TOKEN` | wired | `GET https://www.googleapis.com/fitness/v1/users/me/dataSources` | Google Fit token authenticates and can list data sources. | $0 read | Google Fitness API quotas apply. | 401/403 = unauthorized/scope issue; network = unavailable. |
| `finance.plaid` | api-key | Plaid sandbox credentials | `PLAID_CLIENT_ID`, `PLAID_SECRET` | wired | `POST https://sandbox.plaid.com/institutions/get` with count 1 | Plaid sandbox credentials authenticate and can read institution metadata. | $0 sandbox read | Sandbox endpoint; no live account data touched. | Plaid error code/401 = unauthorized; network = unavailable. |
| `finance.paypal` | api-key | PayPal sandbox credentials | `PAYPAL_CLIENT_ID`, `PAYPAL_CLIENT_SECRET`, `PAYPAL_API_BASE` | wired | `POST {PAYPAL_API_BASE\|https://api-m.sandbox.paypal.com}/v1/oauth2/token` with client credentials | PayPal client credentials can mint a sandbox access token. | $0 control-plane call | Sandbox OAuth limits apply. | 401/invalid_client = unauthorized; network = unavailable. |
| `crypto.evm` | api-key | EVM private key | `EVM_PRIVATE_KEY` | documented-skip | Local key parse to address, then `eth_getBalance` via public RPC | Private key parses to an address and public RPC read succeeds. | $0 public read | Public RPC rate limits apply. | Parse error = unauthorized/bad key; RPC/network = unavailable. |
| `crypto.solana` | api-key | Solana private key | `SOLANA_PRIVATE_KEY` | documented-skip | Local key parse to address, then `getBalance` via public RPC | Private key parses to an address and public RPC read succeeds. | $0 public read | Public RPC rate limits apply. | Parse error = unauthorized/bad key; RPC/network = unavailable. |
## Required follow-through
Rows marked `wired` are runnable today through
`scripts/lifeops/credential-probes.mjs` and the HITL credential dashboard. Rows
marked `documented-skip` are intentionally visible but unavailable until their
local bridge, protocol adapter, or owner-provided account path exists. They must
stay on the dashboard as gray skipped rows with the failure reason shown; hiding
them would make the MVP readiness report look healthier than reality.