Pipeline Progress ?⚪ not started not yet begun✏️ draft initial version🔍 in review under review🚧 in progress actively worked on⛔ blocked waiting on dependency✅ done completed⏭️ skipped not applicable
Requirements: Scheduled LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. Engine
Overview
The scheduled loop engine is the core of Loopany: an in-process cron scheduler that turns a loop's schedule into pending agent runs and dispatches them to the loop's bound machine. A tick creates a pending run that is the durable inbox for the machine's poll; the machine unreachable case defers rather than fails, the next fire supersedes a still-waiting run, and boot-time catch-up recovers occurrences lost inside a deploy window. The engine also schedules the self-improvement (evolve) passes, owner-requested edit runs, one-shot run-now overrides, and enforces the single-scheduler invariant.
Stakeholders
| Stakeholder | Interest |
|---|---|
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. owner | Loops fire reliably on schedule, catch up after outages, and never double-fire. |
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. operator | A machine offline for a while loses no scheduled work; the next fire supersedes the missed one. |
| Server operator | One scheduler process per database; a deploy window does not silently drop scheduled occurrences. |
Functional Requirements
Order rows by priority: Must first, then Should, then May.
| ID | Priority | Requirement |
|---|---|---|
| FR-1MustThe scheduler shall fire a loop on its cron expression interpreted in the loop's timezone. | Must | The scheduler shall fire a loop on its cron expression interpreted in the loop's timezone. |
| FR-2MustA tick shall create a pending run row and dispatch it to the loop's bound machine via the Dispatcher seam. | Must | A tick shall create a pending run row and dispatch it to the loop's bound machine via the Dispatcher seam. |
| FR-3MustA tick for a loop with a running run shall be skipped so two agents never run the same loop at once. | Must | A tick for a loop with a running run shall be skipped so two agents never run the same loop at once. |
| FR-4MustA pending run on an unreachable machine shall be deferred, and the next exec fire shall supersede the still-waiting one as an outcome `skipped`. | Must | A pending run on an unreachable machine shall be deferred, and the next exec fire shall supersede the still-waiting one as an outcome skipped. |
| FR-5MustThe scheduler shall support a one-shot `nextRunAt` override that fires once and then resumes the cron schedule. | Must | The scheduler shall support a one-shot nextRunAt override that fires once and then resumes the cron schedule. |
| FR-6MustThe scheduler shall support run-now (`scheduler.runNow`) and evolve-now (`scheduler.evolveNow`) triggers. | Must | The scheduler shall support run-now (scheduler.runNow) and evolve-now (scheduler.evolveNow) triggers. |
| FR-7MustThe scheduler shall run a dedicated evolve pass when flagged, at most once per `EVOLVE_MIN_INTERVAL_MS` and no sooner than every `EVOLVE_EVERY` runs. | Must | The scheduler shall run a dedicated evolve pass when flagged, at most once per EVOLVE_MIN_INTERVAL_MS and no sooner than every EVOLVE_EVERY runs. |
| FR-8MustThe scheduler shall run a dedicated edit run when an owner `editRequest` is pending, taking precedence over a scheduled exec run. | Must | The scheduler shall run a dedicated edit run when an owner editRequest is pending, taking precedence over a scheduled exec run. |
| FR-9MustAt boot, the scheduler shall detect a missed cron occurrence inside a downtime window and fire one compensating catch-up tick. | Must | At boot, the scheduler shall detect a missed cron occurrence inside a downtime window and fire one compensating catch-up tick. |
| FR-10ShouldA disabled loop shall not fire and its timers shall be unscheduled; re-enabling reschedules it. | Should | A disabled loop shall not fire and its timers shall be unscheduled; re-enabling reschedules it. |
| FR-11ShouldThe scheduler shall clear the edit request and evolve marker after their runs end, preserving a future `nextRunAt` set by the run itself. | Should | The scheduler shall clear the edit request and evolve marker after their runs end, preserving a future nextRunAt set by the run itself. |
| FR-12ShouldThe scheduler shall expose a live set of loop ids with an open run for the UIUser interface's running indicator. | Should | The scheduler shall expose a live set of loop ids with an open run for the UIUser interface's running indicator. |
Non-Functional Requirements
Order rows by priority: Must first, then Should, then May.
| ID | Priority | Category | Requirement |
|---|---|---|---|
| NFR-1MustA failed dispatch (e.g. DB error) shall mark the run error and never escape as an unhandled rejection from a timer callback. | Must | Reliability | A failed dispatch (e.g. DB error) shall mark the run error and never escape as an unhandled rejection from a timer callback. |
| NFR-2MustBoot readiness shall never block on the misfire catch-up sweep, so a slow catch-up cannot widen the deploy downtime window. | Must | Availability | Boot readiness shall never block on the misfire catch-up sweep, so a slow catch-up cannot widen the deploy downtime window. |
| NFR-3MustExactly one process shall own the scheduler for a given database; a second process against the same DB must not double-fire. | Must | Availability | Exactly one process shall own the scheduler for a given database; a second process against the same DB must not double-fire. |
| NFR-4ShouldThe per-loop in-flight guard shall serialize concurrent triggers within a single scheduler process without DB round-trips. | Should | Performance | The per-loop in-flight guard shall serialize concurrent triggers within a single scheduler process without DB round-trips. |
| NFR-5ShouldScheduler behavior shall be tunable via environment variables (`LOOPANY_EVOLVE_EVERY`, `LOOPANY_EVOLVE_DELAY_MS`, `LOOPANY_EVOLVE_MIN_INTERVAL_MS`). | Should | Operability | Scheduler behavior shall be tunable via environment variables (LOOPANY_EVOLVE_EVERY, LOOPANY_EVOLVE_DELAY_MS, LOOPANY_EVOLVE_MIN_INTERVAL_MS). |
Constraints
- The server executes nothing; a tick only creates rows and dispatches, never runs workflow JS or an agent.
- Overlapping ticks are skipped rather than queued for a running run.
- The pending row is the durable inbox; the engine never decides a machine is offline.
- Timers are
unref'd so they do not hold the process open.
Acceptance Criteria
Every FR and NFR shall have at least one acceptance criterion.
Order criteria by FRs first (sorted by ID), then NFRs (sorted by ID).
- FR-1MustThe scheduler shall fire a loop on its cron expression interpreted in the loop's timezone.
- Given a loop with cron
0 7 * * *and timezoneAsia/Shanghai - When the scheduler runs
- Then a pending run is created at the next 07:00 in the loop's timezone
- Given a loop with cron
- FR-2MustA tick shall create a pending run row and dispatch it to the loop's bound machine via the Dispatcher seam.
- Given an enabled loop
- When its cron fires
- Then a pending run row exists and the Dispatcher received the loop and run
- FR-3MustA tick for a loop with a running run shall be skipped so two agents never run the same loop at once.
- Given a loop with a run in phase
running - When another tick fires for the same loop
- Then no new pending run is created
- Given a loop with a run in phase
- FR-4MustA pending run on an unreachable machine shall be deferred, and the next exec fire shall supersede the still-waiting one as an outcome `skipped`.
- Given a pending exec run the machine never claimed
- When the next exec fire occurs
- Then the old run retires as outcome
skippedand the new pending run replaces it
- FR-5MustThe scheduler shall support a one-shot `nextRunAt` override that fires once and then resumes the cron schedule.
- Given a loop with a future
nextRunAt - When the one-shot fires
- Then one run executes and
nextRunAtis cleared so the cron schedule resumes
- Given a loop with a future
- FR-6MustThe scheduler shall support run-now (`scheduler.runNow`) and evolve-now (`scheduler.evolveNow`) triggers.
- Given a loop
- When
runNoworevolveNowis called - Then a pending run is created via the one-shot timer path
- FR-7MustThe scheduler shall run a dedicated evolve pass when flagged, at most once per `EVOLVE_MIN_INTERVAL_MS` and no sooner than every `EVOLVE_EVERY` runs.
- Given a loop with enough runs since the last evolve
- When
maybeFlagEvolveruns - Then an evolve pass is scheduled, but never more than once per day in steady state
- FR-8MustThe scheduler shall run a dedicated edit run when an owner `editRequest` is pending, taking precedence over a scheduled exec run.
- Given a loop with a pending
editRequest - When the next tick fires
- Then the run role is
editand it takes precedence over an exec/evolve pass
- Given a loop with a pending
- FR-9MustAt boot, the scheduler shall detect a missed cron occurrence inside a downtime window and fire one compensating catch-up tick.
- Given a cron occurrence that fell inside a deploy window
- When the scheduler boots
- Then exactly one compensating catch-up tick fires and is coalesced
- NFR-1MustA failed dispatch (e.g. DB error) shall mark the run error and never escape as an unhandled rejection from a timer callback.
- Given a tick whose dispatch throws
- When the tick completes
- Then the run is marked
errorand no unhandled rejection escapes
- NFR-3MustExactly one process shall own the scheduler for a given database; a second process against the same DB must not double-fire.
- Given two scheduler processes against the same database
- When both attempt to schedule the same loop
- Then the boot guard prevents double ownership (single-scheduler invariant)
Conflicts
None identified yet.
Open Questions
- None: the scheduler behavior is fully determined by the code and tests.
Specification: Scheduled LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. Engine
Overview
The scheduler is a single in-process engine (croner) plus a per-loop one-shot timer. A tick loads the loop, guards on in-flight/open runs, decides the run role (exec / evolve / edit), creates a pending run row, and hands it to an injected Dispatcher (the MachineGateway in production). The pending row is the durable inbox; offline machines are handled by deferral and supersede, never by the engine failing the run.
Architecture
start() ─► load enabled loops ─► schedule(loop) per loop
│
croner Cron (loop tz) │ armNextRunAt (one-shot nextRunAt)
▼
runLoop(id)
│
┌─────────────┬──────────┴───────────┬──────────────┐
│ in-flight? │ running run? │ role select │
│ (skip) │ (skip tick) │ edit>evolve>exec
▼ ▼
supersede deferred pending (exec) ──► addRun(pending) ──► dispatcher.dispatch(loop, run)
The scheduler implements Dispatcher-triggered flows and a Dispatcher interface that is transport-agnostic. The MachineGateway implements dispatch in production.
Data Models
The engine reads and writes through store:
loops (relevant columns)
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | text | PK | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. id |
| cron | text | not null | Cron expression |
| timezone | text | nullable | IANA timezone; null = server local |
| enabled | boolean | default true | Master schedule switch |
| nextRunAt | text (ISO) | nullable | One-shot override; consumed on fire |
| evolveDue | boolean | nullable | Marker: next tick is an evolve pass |
| evolvedRunCount | integer | nullable | Runs count at last evolution |
| editRequest | text | nullable | Pending owner edit instruction |
| machineId | text | not null | Execution machine |
runs (created per tick)
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | text | PK | Run id |
| loopId | text | not null | Owning loop |
| machineId | text | not null | Bound machine |
| phase | enum | pending → running → done/error/canceled | Lifecycle |
| role | enum | exec / evolve / edit | Run kind |
| ts | text (ISO) | not null | Creation time |
API Contracts
The engine exposes no HTTP endpoints; it is consumed through store and the injected Dispatcher. It provides programmatic methods on Scheduler: start, addLoop, removeLoop, runNow, evolveNow, requestEdit, finishEdit, finishEvolution, maybeFlagEvolve, runningIds, and the static nextRun(expr) cron validator.
Sequences
Cron tick → pending run → dispatch
croner fire → runLoop(id)
├─ getLoop; unschedule if missing
├─ skip if !enabled or a running run exists
├─ role = editRequest ? "edit" : evolveDue ? "evolve" : "exec"
├─ if exec pending exists: supersedePendingRun(old, reason) → skipped
├─ consume spent nextRunAt (≤ now + 1.5s)
├─ if evolveDue and !canEvolve → finishEvolution, return
└─ addRun(pending, role) → dispatcher.dispatch(loop, run)
Boot misfire catch-up
start() → background catchUpMissedFires(all enabled loops)
per loop: previousRuns(1) in loop tz
├─ stand down if a past-due nextRunAt one-shot is present
├─ stand down if the occurrence predates loop creation
└─ fire ONE compensating tick if the newest run predates the occurrence
Technical Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Cron engine | croner with protect + catch |
Computes future fires only, timezone-aware, protect prevents overlap |
| Pending runA run row in phase `pending`; it is the durable inbox a machine's poll claims. as inbox | Rows in phase pending |
Durable across restarts; machine claim is stateless |
| Deferred handling | Hold pending, supersede on next fire | Never fails an unreachable machine; queue coalesces to depth 1 |
| Supersede atomicity | store.supersedePendingRun phase-guard |
A run claimed in the same instant is left alone |
| Evolve cadence | EVOLVE_EVERY runs AND EVOLVE_MIN_INTERVAL_MS |
Prevents a fast loop from evolving many times a day |
| In-flight guard | In-process running set per loop |
Serializes cron + one-shot landing together without DB round-trips |
| Misfire recovery | Reconstruct past occurrence at boot | croner only computes future fires; a deploy window must not lose a run |
Risks and Unknowns
- The in-process in-flight guard complements but never replaces the DB-level open-run check; a second scheduler process against the same DB would still double-fire (mitigated by the single-scheduler invariant).
unref'd timers can be delayed by a busy event loop; acceptable for schedule granularity.
Out of Scope
- Executing the run (the daemon's poll claims and runs it).
- Deciding machine offline (the gateway sweep and deferral own that).
- Multi-process scheduler support (explicitly forbidden by the single-scheduler invariant).
Test Plan: Scheduled LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. Engine
Scope
Testing the scheduler's tick behavior (role selection, deferral/supersede, one-shots, evolve/edit scheduling, misfire catch-up) and the run-lifecycle store operations it drives. Out of scope: the daemon's poll claim loop and agent execution (covered by the machine gateway and daemon features).
Unit Tests
| ID | Description | Input | Expected Output |
|---|---|---|---|
| TC-1 | Scheduler end-to-end tick creates a pending run and dispatches | Enabled loop, dispatcher spy | One pending run created, dispatcher called with loop + run |
| TC-2 | A running run blocks the tick | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. with a phase running run |
No second pending run created |
| TC-3 | A deferred pending exec run is superseded on the next exec fire | Pending exec run, next exec tick | Old run outcome skipped, one fresh pending run remains |
| TC-4 | A non-exec fire defers rather than supersedes a pending exec | Pending exec run, evolve tick | Old pending stays, no supersede |
| TC-5 | Run-now arms a one-shot timer | scheduler.runNow(id) |
A pending run is created through the one-shot path |
| TC-6 | Evolve-now flags evolution and fires | scheduler.evolveNow(id) |
evolveDue set, pending run created |
| TC-7 | Edit request produces an edit role run |
LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. with editRequest set |
Next tick creates a run with role edit |
| TC-8 | Misfire catch-upBoot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. fires one compensating tick | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. whose newest run predates a past cron occurrence | Exactly one compensating run, coalesced |
| TC-9 | Misfire catch-upBoot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. stands down for a past-due one-shot | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. with a past-due nextRunAt |
No double fire via catch-up |
| TC-10 | Evolve cadence respects both gates | Run counts below EVOLVE_EVERY or within EVOLVE_MIN_INTERVAL_MS |
No evolve flagged |
| TC-11 | Failed dispatch marks the run error | Dispatcher throws | Run phase error, no unhandled rejection |
| TC-12 | Disabled loop is not scheduled; re-enable reschedules | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. toggled enabled |
No ticks while disabled, ticks resume after re-enable |
Integration Tests
| ID | Description | Preconditions | Expected Outcome |
|---|---|---|---|
| TC-13 | Scheduler drives the real store against pgliteEmbedded WASM Postgres by ElectricSQL | pgliteEmbedded WASM Postgres by ElectricSQL pool, seeded loop | A cron fire creates a pending row readable back through the store |
Edge Cases and Failure Scenarios
| ID | Scenario | Expected Behavior |
|---|---|---|
| TC-14 | spentNextRunAt with a future value (the run self-rescheduled) |
The future nextRunAt is preserved, not cleared |
| TC-15 | Cron expression that never fires again | Scheduler.nextRun throws; the loop is not scheduled |
| TC-16 | Dispatch throws during an edit run | Edit marker is cleared so it does not re-fire forever |
Test Infrastructure
- vitest across both packages; server tests run against real pgliteEmbedded WASM Postgres by ElectricSQL where integration is needed.
- The dispatcher is injected as a spy/fake in unit tests;
storeis exercised against pgliteEmbedded WASM Postgres by ElectricSQL for integration tests. - Scheduler consts are read at module load, so tests re-import modules to vary
LOOPANY_EVOLVE_*.
Coverage Matrix
| Requirement | Test Cases |
|---|---|
| FR-1MustThe scheduler shall fire a loop on its cron expression interpreted in the loop's timezone. | TC-1 |
| FR-2MustA tick shall create a pending run row and dispatch it to the loop's bound machine via the Dispatcher seam. | TC-1, TC-12 |
| FR-3MustA tick for a loop with a running run shall be skipped so two agents never run the same loop at once. | TC-2 |
| FR-4MustA pending run on an unreachable machine shall be deferred, and the next exec fire shall supersede the still-waiting one as an outcome `skipped`. | TC-3, TC-4 |
| FR-5MustThe scheduler shall support a one-shot `nextRunAt` override that fires once and then resumes the cron schedule. | TC-5 |
| FR-6MustThe scheduler shall support run-now (`scheduler.runNow`) and evolve-now (`scheduler.evolveNow`) triggers. | TC-5, TC-6 |
| FR-7MustThe scheduler shall run a dedicated evolve pass when flagged, at most once per `EVOLVE_MIN_INTERVAL_MS` and no sooner than every `EVOLVE_EVERY` runs. | TC-10 |
| FR-8MustThe scheduler shall run a dedicated edit run when an owner `editRequest` is pending, taking precedence over a scheduled exec run. | TC-7 |
| FR-9MustAt boot, the scheduler shall detect a missed cron occurrence inside a downtime window and fire one compensating catch-up tick. | TC-8, TC-9 |
| FR-10ShouldA disabled loop shall not fire and its timers shall be unscheduled; re-enabling reschedules it. | TC-12 |
| FR-11ShouldThe scheduler shall clear the edit request and evolve marker after their runs end, preserving a future `nextRunAt` set by the run itself. | TC-14 |
| FR-12ShouldThe scheduler shall expose a live set of loop ids with an open run for the UIUser interface's running indicator. | covered by open-runs store query used by runningIds |
| NFR-1MustA failed dispatch (e.g. DB error) shall mark the run error and never escape as an unhandled rejection from a timer callback. | TC-11, TC-16 |
| NFR-2MustBoot readiness shall never block on the misfire catch-up sweep, so a slow catch-up cannot widen the deploy downtime window. | TC-8 (background sweep) |
| NFR-3MustExactly one process shall own the scheduler for a given database; a second process against the same DB must not double-fire. | boot guard covered by server/boot.test.ts |
| NFR-5ShouldScheduler behavior shall be tunable via environment variables (`LOOPANY_EVOLVE_EVERY`, `LOOPANY_EVOLVE_DELAY_MS`, `LOOPANY_EVOLVE_MIN_INTERVAL_MS`). | TC-10 |
requirements
- None: the scheduler behavior is fully determined by the code and tests.
Vocabulary
Domain Terms
| Term | Definition |
|---|---|
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. | A scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. |
| Loop folderThe on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. | The on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. |
| Open loopA loop with `goal = null` that runs indefinitely (monitor/digest); it never self-terminates. | A loop with goal = null that runs indefinitely (monitor/digest); it never self-terminates. |
| Closed loopA loop with a non-null `goal`; each exec run judges state against the goal and may call `loopany finish` when met, stamping `completedAt` and disabling the loop. | A loop with a non-null goal; each exec run judges state against the goal and may call loopany finish when met, stamping completedAt and disabling the loop. |
| Exec runA scheduled execution of a loop (role `exec`); only exec runs produce user-facing notifications. | A scheduled execution of a loop (role exec); only exec runs produce user-facing notifications. |
| Evolve runA self-improvement pass (role `evolve`) that reviews run history and rewrites the loop's brief, state schema, and dashboard. | A self-improvement pass (role evolve) that reviews run history and rewrites the loop's brief, state schema, and dashboard. |
| Edit runAn owner-requested change (role `edit`) that applies an instruction to the loop, then clears the request. | An owner-requested change (role edit) that applies an instruction to the loop, then clears the request. |
| Run token / run credentialThe per-run lease credential (`rk_…`) authorizing in-run `loopany` verbs and the final report. | The per-run lease credential (rk_…) authorizing in-run loopany verbs and the final report. |
| Device tokenThe `dk_`-prefixed credential that fully impersonates a machine for owner verbs and polling. | The dk_-prefixed credential that fully impersonates a machine for owner verbs and polling. |
| Connect keyA one-time claim token minted by the dashboard that binds a newly connecting machine to its owner and team. | A one-time claim token minted by the dashboard that binds a newly connecting machine to its owner and team. |
| Task fileThe loop's durable context+log document on the machine; its `## Spec` section is the standing brief. | The loop's durable context+log document on the machine; its ## Spec section is the standing brief. |
| WorkflowAn optional zero-LLM async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. | An optional zero-LLMLarge Language Model async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. |
| Generative dashboardLoop-authored `ui` markup (with `loop-embed`/`loop-calendar`/`loop-kanban` primitives) rendered by the web UI from synced front-matter artifacts. | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal.-authored ui markup (with loop-embed/loop-calendar/loop-kanban primitives) rendered by the web UIUser interface from synced front-matter artifacts. |
| Front matterAn optional fenced `---` block of flat `key: value` scalars at the top of a markdown product; the indexed subset `{type?, title?, date?}` is parsed once at byte ingress. | An optional fenced --- block of flat key: value scalars at the top of a markdown product; the indexed subset {type?, title?, date?} is parsed once at byte ingress. |
| TemplateA canned loop intent (meta.json `description` paste-prompt, optional `reference.md`, `thumb.svg`, `story.md`, flow spec) under `src/skill/templates/`. | A canned loop intent (meta.json description paste-prompt, optional reference.md, thumb.svg, story.md, flow spec) under src/skill/templates/. |
| BundleA curated category of templates under `src/skill/bundles/`; every template belongs to exactly one bundle. | A curated category of templates under src/skill/bundles/; every template belongs to exactly one bundle. |
| MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. | A teammate's daemon; the identity unit that owns loops and scopes the dashboard. |
| TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. | The ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. |
Technical Terms
| Term | Definition |
|---|---|
| Pending runA run row in phase `pending`; it is the durable inbox a machine's poll claims. | A run row in phase pending; it is the durable inbox a machine's poll claims. |
| Run leaseA durable row (`run_leases`) minted per delivery holding per-run caps; state machine `active` → `terminal-grace` → retired. | A durable row (run_leases) minted per delivery holding per-run caps; state machine active → terminal-grace → retired. |
| Terminal-graceA swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. | A swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. |
| Long-pollThe opt-in server-held poll (`wait:true`, ~20s) an idle daemon uses for near-zero dispatch latency. | The opt-in server-held poll (wait:true, ~20s) an idle daemon uses for near-zero dispatch latency. |
| Watch set / watchDigestThe per-machine cache of loop folders to watch, digest-echoed so an unchanged client omits the payload. | The per-machine cache of loop folders to watch, digest-echoed so an unchanged client omits the payload. |
| ManifestThe full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. | The full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. |
| BlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. | Content-addressed bytes keyed by sha256 hash, stored in R2Cloudflare R2 object storage or in-memory; referenced by blobs/artifact_files rows. |
| OversizeA file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. | A file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. |
| Run snapshotThe loop's full artifact manifest captured at each run's finalize, diffed against the prior run for the run page. | The loop's full artifact manifest captured at each run's finalize, diffed against the prior run for the run page. |
| SweepThe periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GC. | The periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GCGarbage collection. |
| Circuit breaker`notifyRunFailure` auto-pause of a loop after a configurable consecutive-exec-failure streak. | notifyRunFailure auto-pause of a loop after a configurable consecutive-exec-failure streak. |
| Misfire catch-upBoot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. | Boot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. |
| TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. | The axi-shaped text output rendered by gateway/toon.ts for every /api/machine/cli verb. |
| BYOABring-Your-Own-Agent | Bring-your-own-agent: execution runs with the user's own coding agent and credentials on their own machine. |
Acronyms and Abbreviations
| Abbreviation | Expansion |
|---|---|
| BYOABring-Your-Own-Agent | Bring-Your-Own-Agent |
| R2Cloudflare R2 object storage | Cloudflare R2Cloudflare R2 object storage object storage |
| TTLTime-to-live | Time-to-live |
| GCGarbage collection | Garbage collection |
| LLMLarge Language Model | Large Language Model |
| WSWebSocket | WebSocket |
| UIUser interface | User interface |
| PRPull Request | Pull Request |
| npm OIDCnpm OpenID Connect trusted publishing | npm OpenID Connect trusted publishing |
| Fly / Fly.ioFly.io app hosting | Fly.io app hosting |
| pgliteEmbedded WASM Postgres by ElectricSQL | Embedded WASM Postgres by ElectricSQL |
| MCPModel Context Protocol | Model Context Protocol |
Pipeline Progress ?⚪ not started not yet begun✏️ draft initial version🔍 in review under review🚧 in progress actively worked on⛔ blocked waiting on dependency✅ done completed⏭️ skipped not applicable
Requirements: MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. Gateway & BYOABring-Your-Own-Agent Execution
Overview
The machine gateway is the server-side run-lifecycle core and the wire boundary to every machine. It owns the stateless HTTP poll that claims pending runs, the report that finalizes them, the sweep that reclaims stuck or deferred runs, machine enrollment and presence, delivery/prompt construction, the unified CLI dispatch router (device vs run credentials), run leases, rate limiting, and machine-scoped owner verbs (createLoop, listLoops, editLoop, loopLog). Together it implements BYOABring-Your-Own-Agent: the server schedules and records, the machine's daemon executes, and the gateway keeps the two in lockstep without ever running code or an LLMLarge Language Model.
Stakeholders
| Stakeholder | Interest |
|---|---|
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. owner | Loops execute on their machine, results land back, and edits/logs work through both CLI and web. |
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. operator | A machine that sleeps or reconnects loses no work; the sweep reclaims only genuinely dead runs. |
| Server operator | Unauthenticated enrollment is closed; machine routes are rate-limited; a deploy never breaks an in-flight run. |
Functional Requirements
Order rows by priority: Must first, then Should, then May.
| ID | Priority | Requirement |
|---|---|---|
| FR-1MustA machine shall poll `/api/machine/poll` with its device token to claim pending runs, receive the watch set, and stamp freshness. | Must | A machine shall poll /api/machine/poll with its device token to claim pending runs, receive the watch set, and stamp freshness. |
| FR-2MustAn idle daemon shall be able to opt into a server-held long-poll (`wait:true`, ~20s) woken by the Dispatcher when a pending run is dispatched. | Must | An idle daemon shall be able to opt into a server-held long-poll (wait:true, ~20s) woken by the Dispatcher when a pending run is dispatched. |
| FR-3MustThe gateway shall accept a run's final `report()` and retire its run lease, persisting transcript, metrics, artifacts, and cost. | Must | The gateway shall accept a run's final report() and retire its run lease, persisting transcript, metrics, artifacts, and cost. |
| FR-4MustA run that a machine never claims or whose machine vanishes shall be reclaimed by the sweep, with a terminal-grace lease allowing one reconciling wake-report. | Must | A run that a machine never claims or whose machine vanishes shall be reclaimed by the sweep, with a terminal-grace lease allowing one reconciling wake-report. |
| FR-5MustThe gateway shall support the owner verbs `new` / `loops` / `edit` / `log` / `show` / `home` over the unified `/api/machine/cli` router, keyed on credential type (device vs run). | Must | The gateway shall support the owner verbs new / loops / edit / log / show / home over the unified /api/machine/cli router, keyed on credential type (device vs run). |
| FR-6MustA run credential shall only exercise the run's own verbs and loop; owner-only verbs on a run credential shall be rejected. | Must | A run credential shall only exercise the run's own verbs and loop; owner-only verbs on a run credential shall be rejected. |
| FR-7MustThe run credential shall be a durable run lease keyed by the sha256 of the wire token, surviving deploys and long machine sleeps. | Must | The run credential shall be a durable run lease keyed by the sha256 of the wire token, surviving deploys and long machine sleeps. |
| FR-8MustMachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. enrollment shall be gated: in gated mode only a token resolving to a live connect key may enroll, never an anonymous `shared` machine. | Must | MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. enrollment shall be gated: in gated mode only a token resolving to a live connect key may enroll, never an anonymous shared machine. |
| FR-9MustEvery `/api/machine/*` and `/agent-api/loop` route shall be rate-limited per IP and per token. | Must | Every /api/machine/* and /agent-api/loop route shall be rate-limited per IP and per token. |
| FR-10MustA pending run on an unreachable machine shall be held by the sweep (deferred), never failed as "machine offline"; an online-but-unclaimed run reclaims as an error. | Must | A pending run on an unreachable machine shall be held by the sweep (deferred), never failed as "machine offline"; an online-but-unclaimed run reclaims as an error. |
| FR-11ShouldThe gateway shall deliver per-run instructions and caps (the exec/evolve/edit first-user-turn CORE) through `delivery` and `prompt`. | Should | The gateway shall deliver per-run instructions and caps (the exec/evolve/edit first-user-turn CORE) through delivery and prompt. |
| FR-12ShouldThe gateway shall expose presence (online / asleep / offline) and a per-machine watch cache with digest echo. | Should | The gateway shall expose presence (online / asleep / offline) and a per-machine watch cache with digest echo. |
| FR-13ShouldThe gateway shall run periodic `maintainStorage` (snapshot pruning + blob GCGarbage collection) and prune expired run leases. | Should | The gateway shall run periodic maintainStorage (snapshot pruning + blob GCGarbage collection) and prune expired run leases. |
Non-Functional Requirements
Order rows by priority: Must first, then Should, then May.
| ID | Priority | Category | Requirement |
|---|---|---|---|
| NFR-1MustMachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard.-route request bodies shall be capped (2MB) before parsing; per-field caps bound row bloat. | Must | Security | MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard.-route request bodies shall be capped (2MB) before parsing; per-field caps bound row bloat. |
| NFR-2MustThe device token shall fully impersonate its machine but serialize owner-only (`tokenVisibleTo`); `loopLog` cross-scope is a flat 404. | Must | Security | The device token shall fully impersonate its machine but serialize owner-only (tokenVisibleTo); loopLog cross-scope is a flat 404. |
| NFR-3MustA DB leak of `run_leases` must not hand out live run credentials (hash-only storage). | Must | Security | A DB leak of run_leases must not hand out live run credentials (hash-only storage). |
| NFR-4MustA canceled run's late `report()` shall be ignored before any loop-level write. | Must | Reliability | A canceled run's late report() shall be ignored before any loop-level write. |
| NFR-5MustA deploy shall be invisible to an in-flight run; a long-sleep wake-report survives inside its grace window. | Must | Availability | A deploy shall be invisible to an in-flight run; a long-sleep wake-report survives inside its grace window. |
| NFR-6ShouldAn idle poll must be read-only (`lastSeen` re-stamped at most every `LAST_SEEN_REFRESH_MS`); claim scans are targeted, never the all-open sweep. | Should | Performance | An idle poll must be read-only (lastSeen re-stamped at most every LAST_SEEN_REFRESH_MS); claim scans are targeted, never the all-open sweep. |
Constraints
- The gateway never executes user code or an LLMLarge Language Model; it only stores/reads bytes and computes pure functions.
- Poll is stateless HTTP; long-poll is the only server-held mechanism.
- Run self-scheduling surfaces (
reschedule/set-cron) are floor-guarded on the run path only; ownereditis unlimited. - Boot constructs one shared blob store handed to the gateway, sync, and CLI gateway.
Acceptance Criteria
Every FR and NFR shall have at least one acceptance criterion.
- FR-1MustA machine shall poll `/api/machine/poll` with its device token to claim pending runs, receive the watch set, and stamp freshness.
- Given a registered machine with a pending run
- When it polls
- Then the poll claims the pending run and returns it in the payload
- FR-2MustAn idle daemon shall be able to opt into a server-held long-poll (`wait:true`, ~20s) woken by the Dispatcher when a pending run is dispatched.
- Given an idle daemon polling with
wait:true - When a new pending run is dispatched
- Then the parked poll returns the run near-instantly
- Given an idle daemon polling with
- FR-3MustThe gateway shall accept a run's final `report()` and retire its run lease, persisting transcript, metrics, artifacts, and cost.
- Given a running run that finalizes
- When
report()is called - Then the run becomes
done, the lease retires, and transcript/metrics/artifacts persist
- FR-4MustA run that a machine never claims or whose machine vanishes shall be reclaimed by the sweep, with a terminal-grace lease allowing one reconciling wake-report.
- Given a run whose machine fell asleep mid-run
- When the sweep reclaims it
- Then the lease enters
terminal-graceand exactly one late wake-report is honored (success flips it back todone)
- FR-5MustThe gateway shall support the owner verbs `new` / `loops` / `edit` / `log` / `show` / `home` over the unified `/api/machine/cli` router, keyed on credential type (device vs run).
- Given a device token
- When
loopany new/edit/log/showposts to/api/machine/cli - Then the owner verb executes and returns a TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb.
text+exitCode
- FR-6MustA run credential shall only exercise the run's own verbs and loop; owner-only verbs on a run credential shall be rejected.
- Given a run credential
- When it posts an owner-only verb or a loop id outside its lease
- Then the router returns 403, never a silent retarget
- FR-7MustThe run credential shall be a durable run lease keyed by the sha256 of the wire token, surviving deploys and long machine sleeps.
- Given an in-flight run across a deploy
- When the run reports after the restart
- Then the lease still resolves and the report finalizes normally
- FR-8MustMachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. enrollment shall be gated: in gated mode only a token resolving to a live connect key may enroll, never an anonymous `shared` machine.
- Given gated mode and an unknown/forged device token
- When it first contacts the poll route
- Then it is 401'd and no
sharedmachine is enrolled
- FR-9MustEvery `/api/machine/*` and `/agent-api/loop` route shall be rate-limited per IP and per token.
- Given a flood of machine-route requests
- When the token bucket is exhausted
- Then the route returns 429
- FR-10MustA pending run on an unreachable machine shall be held by the sweep (deferred), never failed as "machine offline"; an online-but-unclaimed run reclaims as an error.
- Given a pending run on a sleeping machine
- When the sweep runs
- Then the run is held (deferred), never failed; an online-but-unclaimed run past the window reclaims as an error
- NFR-1MustMachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard.-route request bodies shall be capped (2MB) before parsing; per-field caps bound row bloat.
- Given an oversized machine-route body
- When the route parses it
- Then it returns 413 without parsing
- NFR-2MustThe device token shall fully impersonate its machine but serialize owner-only (`tokenVisibleTo`); `loopLog` cross-scope is a flat 404.
- Given a teammate token with machine scope
- When the machine list renders
- Then
tokenis null and a cross-scopeloopany logis a flat 404
Conflicts
None identified yet.
Open Questions
- None: the gateway behavior is fully determined by the code and its tests.
Specification: MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. Gateway & BYOABring-Your-Own-Agent Execution
Overview
MachineGateway (gateway/index.ts) is the run-lifecycle core and the machine wire boundary. It is decomposed into three gateway classes sharing one boot-time blob store and the leaf module gateway/http.ts for wire plumbing: MachineGateway (poll/report/sweep/owner verbs/presence), CliGateway (unified /api/machine/cli router + legacy dispatch), and ArtifactSync (byte ingress, covered by the artifact feature). gateway/validate.ts is the single validator both write surfaces import.
Architecture
daemon ──POST /api/machine/poll──► MachineGateway.poll / pollWait (long-poll waiter)
daemon ──POST /machine/report────► MachineGateway.report (finalize, retire lease)
daemon ──POST /api/machine/sync──► ArtifactSync.sync (manifest reconcile)
daemon ──PUT /api/machine/blob──► ArtifactSync.putBlob
agent ──POST /api/machine/cli──► CliGateway.cli (credential router → finalizeCli)
agent ──POST /agent-api/loop───► CliGateway.agentApi (legacy dispatch)
All routes share machineRouteLimit (per-IP + per-token token buckets) except the byte-ingress routes (sync/blob), which require a valid device token and are handshake-bounded.
Data Models
machines
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | text | PK | m-sha256(deviceToken)[:16] |
| userId | text | not null | Owning user |
| teamId | text | nullable | Owning team |
| tokenHash | text | not null | Hash of the device token |
| token | text | nullable | Plaintext device token (owner re-show, documented exception) |
| lastSeen | text (ISO) | nullable | Freshness stamp |
| online | boolean | default false | Presence flag |
runLeases
| Field | Type | Constraints | Description |
|---|---|---|---|
| tokenHash | text | PK | sha256 hex of the wire token (rk_… or legacy bare UUID) |
| runId / loopId / machineId | text | not null | Run scope |
| role | enum | exec / evolve / edit | Run kind |
| allowControl / canSetUi / canSetSchema / canSetWorkflow / canFinish | boolean | default false | Per-run caps |
| state | enum | active / terminal-grace | Lease state machine |
| expiresAt | text | null while active | Terminal-graceA swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. bound |
API Contracts
POST /api/machine/poll
Request
| Field | Type | Required | Description |
|---|---|---|---|
| token | string | yes | dk_ device token |
| wait | boolean | no | Opt into the server-held long-poll |
| watchDigest | string | no | Echoed watch cache digest |
Response (200 OK)
| Field | Type | Description |
|---|---|---|
| runs | Run[] | Pending runs to claim (each carrying its run token + instructions) |
| watch | LoopFolder[] | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. folders to sync (omitted when the digest matches) |
| watchDigest | string | Current digest |
| machine | object | Presence + daemon-version config |
POST /api/machine/cli
Request
| Field | Type | Required | Description |
|---|---|---|---|
| argv | string[] | yes | CLI verbs + flags |
| token | string | yes | dk_ device token or rk_ run credential |
Response (200 OK)
| Field | Type | Description |
|---|---|---|
| text | string | axi TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. render (the daemon prints this verbatim) |
| exitCode | number | 0/1/2 per verb outcome |
| loops / runs | array | Retained data channels for client-side resolution / --json |
Error Responses
| Status | Code | Description |
|---|---|---|
| 400 | VALIDATION_ERROR | Invalid flags/status |
| 403 | — | Owner-only verb on a run credential; cross-loop retarget |
| 404 | — | Unknown machine / cross-scope log |
| 409 | — | Terminal-graceA swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. lease refusing mutations |
| 413 | — | Body over 2MB cap |
| 429 | — | Rate limited |
Sequences
Run lifecycle
tick → addRun(pending) → dispatcher.dispatch → wakeMachine (parked waiter resolves)
daemon poll (wait:true) → claims pending run, mints run lease (active)
daemon spawns agent → in-run verbs via CliGateway.dispatch (run credential)
agent report() → MachineGateway.report finalize → retireLease → push notify
SweepThe periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GC. reclaim
sweep(): stale running run (RUN_TIMEOUT_MS silence) → reclaimRun → terminalizeLease (terminal-grace, 24h)
late wake-report: phase=="error" && lease.state=="terminal-grace" → honor ONE reconciling report
success → flip back to done + retract via success push; real failure → replace generic reason
offline machine with pending → hold (deferred); DEFERRED_MAX_MS backstop → outcome skipped
Technical Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Run credential | Durable lease keyed by sha256(wire token) | Deploy/sleep-proof; a DB leak never exposes live credentials |
| Deferral vs failure | Hold pending; supersede on next fire | A sleeping laptop is the usual cause; catch-up on reconnect |
| Terminal-graceA swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. | One reconciling wake-report for swept runs | The sweep rarely means real failure; a late success must win |
| Stateless poll | HTTP short-poll + opt-in long-poll | No WSWebSocket infra; near-zero dispatch latency when idle |
| Rate limiting | Per-IP + per-token token buckets | Forged-token floods share one IP bucket; per-machine fairness |
| CLI router | Credential-type-first branching (dk_ prefix vs run-lease lookup) |
Owner vs run authority is decided before any verb |
| Watch cache | Per-machine, digest-echoed, WATCH_CACHE_TTL_MS |
Omission requires the echo; an absent watch means unchanged |
| Shared blob store | One instance across gateway/sync/cli | Two instances would let retention GCGarbage collection bytes sync never wrote |
Risks and Unknowns
- The device token fully impersonates its machine; plaintext storage is a documented trust-model exception for owner re-show.
- Global
fetchre-resolves DNS on connect for webhooks (no socket pinning); bounded by the host allowlist inwebhookGuard.ts. - Per-owner machine/loop quotas are a noted follow-up, not yet implemented.
Out of Scope
- Executing the agent (daemon-side).
- Artifact byte ingress (covered by the artifact sync feature).
- WebSocket connectivity (long-poll is the ceiling).
Test Plan: MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. Gateway & BYOABring-Your-Own-Agent Execution
Scope
Testing the machine wire boundary: poll/claim, long-poll wake, report finalize, sweep reclaim + terminal-grace reconcile, enrollment gating, rate limiting, run leases, delivery/prompt construction, unified CLI routing, and the TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. render spine. Out of scope: daemon-side spawning and artifact byte ingress.
Unit Tests
| ID | Description | Input | Expected Output |
|---|---|---|---|
| TC-1 | Poll claims a pending run for the machine | Registered machine + pending run | Claimed run returned with run token |
| TC-2 | Long-pollThe opt-in server-held poll (`wait:true`, ~20s) an idle daemon uses for near-zero dispatch latency. waiter is resolved by dispatch | Idle wait:true poll, new dispatch |
Parked poll returns the run near-instantly |
| TC-3 | Report finalizes the run and retires the lease | Final report on an active lease | Run done, lease deleted, notify fired |
| TC-4 | Terminal-graceA swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. honors one reconciling wake-report | Swept run, late success report | Run flips back to done, no second push |
| TC-5 | Second finalize is rejected | Already-retired lease | Report ignored before any loop-level write |
| TC-6 | Enrollment is gated in gated mode | Forged/unknown device token | 401, no shared machine enrolled |
| TC-7 | Cross-machine id-collision re-check | Token hash mismatch on an existing machine | Enroll rejected |
| TC-8 | Rate limiter 429s when the bucket is spent | Per-IP flood | 429 after the burst/per-sec allowance |
| TC-9 | BlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows.-PUT and sync-POST are rate-limit-exempt | Valid device token burst | No 429 from the limiter; still 401 on an unknown token |
| TC-10 | CLI router branches on credential type | dk_ vs run lease vs legacy bare UUID |
Device verbs vs run verbs resolve correctly |
| TC-11 | Run credential rejects owner-only verbs and cross-loop ids | Run token posting new or a foreign loop |
403, never a silent retarget |
| TC-12 | loopLog cross-scope is a flat 404 |
MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. B token asking for loop A's log | 404, existence never leaked |
| TC-13 | TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. serializer quotes and blocks per axi rules | Values with/without whitespace/commas | Bare vs quoted render per gateway/toon.ts |
| TC-14 | Presence is three-state (online/asleep/offline) | lastSeen deltas | online < 30s, asleep < 6h, else offline |
| TC-15 | Prompt construction builds the exec/evolve/edit CORE | Role + loop + state | First-user-turn instructions with the untrusted-data guard |
Integration Tests
| ID | Description | Preconditions | Expected Outcome |
|---|---|---|---|
| TC-16 | Full poll→run→report lifecycle against pgliteEmbedded WASM Postgres by ElectricSQL | pgliteEmbedded WASM Postgres by ElectricSQL store, seeded loop + machine | Run claims, executes (fake dispatcher), finalizes, lease retires |
| TC-17 | MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard.-route body cap (2MB) | Oversized POST body | 413 before parsing |
Edge Cases and Failure Scenarios
| ID | Scenario | Expected Behavior |
|---|---|---|
| TC-18 | Late report after a cancel | Ignored before any loop-level write (never advances cursor/taskFileContent) |
| TC-19 | A swept run whose wake-report is a real failure | The generic reclaim reason is replaced; no second push |
| TC-20 | Terminal-graceA swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. mutations | agentApi/runCli refuse with 409; only the final report reconciles |
| TC-21 | Deferred pending past DEFERRED_MAX_MS |
Retires as outcome skipped, phase canceled |
| TC-22 | Alarm policy mirrors presence | Asleep (<6h) fully silent; genuinely offline gets ONE calm deferred message |
Test Infrastructure
- vitest; the gateway takes an injectable notifier and blob store so tests observe pushes without network.
- Real pgliteEmbedded WASM Postgres by ElectricSQL for integration tests;
setWebhookFetchDepsinjects DNS + fetch for notify tests. - Rate limiting is off under vitest unless
LOOPANY_RATE_LIMIT=onso suites never trip it.
Coverage Matrix
| Requirement | Test Cases |
|---|---|
| FR-1MustA machine shall poll `/api/machine/poll` with its device token to claim pending runs, receive the watch set, and stamp freshness. | TC-1 |
| FR-2MustAn idle daemon shall be able to opt into a server-held long-poll (`wait:true`, ~20s) woken by the Dispatcher when a pending run is dispatched. | TC-2 |
| FR-3MustThe gateway shall accept a run's final `report()` and retire its run lease, persisting transcript, metrics, artifacts, and cost. | TC-3 |
| FR-4MustA run that a machine never claims or whose machine vanishes shall be reclaimed by the sweep, with a terminal-grace lease allowing one reconciling wake-report. | TC-4, TC-19, TC-21 |
| FR-5MustThe gateway shall support the owner verbs `new` / `loops` / `edit` / `log` / `show` / `home` over the unified `/api/machine/cli` router, keyed on credential type (device vs run). | TC-10 |
| FR-6MustA run credential shall only exercise the run's own verbs and loop; owner-only verbs on a run credential shall be rejected. | TC-11 |
| FR-7MustThe run credential shall be a durable run lease keyed by the sha256 of the wire token, surviving deploys and long machine sleeps. | durable lease + deploy survival covered by gateway/run-lease tests (TC-3, TC-16) |
| FR-8MustMachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. enrollment shall be gated: in gated mode only a token resolving to a live connect key may enroll, never an anonymous `shared` machine. | TC-6, TC-7 |
| FR-9MustEvery `/api/machine/*` and `/agent-api/loop` route shall be rate-limited per IP and per token. | TC-8 |
| FR-10MustA pending run on an unreachable machine shall be held by the sweep (deferred), never failed as "machine offline"; an online-but-unclaimed run reclaims as an error. | TC-21, TC-22 |
| FR-11ShouldThe gateway shall deliver per-run instructions and caps (the exec/evolve/edit first-user-turn CORE) through `delivery` and `prompt`. | TC-15 |
| FR-12ShouldThe gateway shall expose presence (online / asleep / offline) and a per-machine watch cache with digest echo. | TC-14 |
| FR-13ShouldThe gateway shall run periodic `maintainStorage` (snapshot pruning + blob GCGarbage collection) and prune expired run leases. | retention/GCGarbage collection covered by the artifact feature's retention tests |
| NFR-1MustMachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard.-route request bodies shall be capped (2MB) before parsing; per-field caps bound row bloat. | TC-17 |
| NFR-2MustThe device token shall fully impersonate its machine but serialize owner-only (`tokenVisibleTo`); `loopLog` cross-scope is a flat 404. | TC-12 |
| NFR-3MustA DB leak of `run_leases` must not hand out live run credentials (hash-only storage). | hash-only lease storage asserted by schema tests |
| NFR-4MustA canceled run's late `report()` shall be ignored before any loop-level write. | TC-5, TC-18 |
| NFR-6ShouldAn idle poll must be read-only (`lastSeen` re-stamped at most every `LAST_SEEN_REFRESH_MS`); claim scans are targeted, never the all-open sweep. | lastSeen re-stamp cadence covered by gateway poll tests |
requirements
- None: the gateway behavior is fully determined by the code and its tests.
Vocabulary
Domain Terms
| Term | Definition |
|---|---|
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. | A scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. |
| Loop folderThe on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. | The on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. |
| Open loopA loop with `goal = null` that runs indefinitely (monitor/digest); it never self-terminates. | A loop with goal = null that runs indefinitely (monitor/digest); it never self-terminates. |
| Closed loopA loop with a non-null `goal`; each exec run judges state against the goal and may call `loopany finish` when met, stamping `completedAt` and disabling the loop. | A loop with a non-null goal; each exec run judges state against the goal and may call loopany finish when met, stamping completedAt and disabling the loop. |
| Exec runA scheduled execution of a loop (role `exec`); only exec runs produce user-facing notifications. | A scheduled execution of a loop (role exec); only exec runs produce user-facing notifications. |
| Evolve runA self-improvement pass (role `evolve`) that reviews run history and rewrites the loop's brief, state schema, and dashboard. | A self-improvement pass (role evolve) that reviews run history and rewrites the loop's brief, state schema, and dashboard. |
| Edit runAn owner-requested change (role `edit`) that applies an instruction to the loop, then clears the request. | An owner-requested change (role edit) that applies an instruction to the loop, then clears the request. |
| Run token / run credentialThe per-run lease credential (`rk_…`) authorizing in-run `loopany` verbs and the final report. | The per-run lease credential (rk_…) authorizing in-run loopany verbs and the final report. |
| Device tokenThe `dk_`-prefixed credential that fully impersonates a machine for owner verbs and polling. | The dk_-prefixed credential that fully impersonates a machine for owner verbs and polling. |
| Connect keyA one-time claim token minted by the dashboard that binds a newly connecting machine to its owner and team. | A one-time claim token minted by the dashboard that binds a newly connecting machine to its owner and team. |
| Task fileThe loop's durable context+log document on the machine; its `## Spec` section is the standing brief. | The loop's durable context+log document on the machine; its ## Spec section is the standing brief. |
| WorkflowAn optional zero-LLM async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. | An optional zero-LLMLarge Language Model async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. |
| Generative dashboardLoop-authored `ui` markup (with `loop-embed`/`loop-calendar`/`loop-kanban` primitives) rendered by the web UI from synced front-matter artifacts. | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal.-authored ui markup (with loop-embed/loop-calendar/loop-kanban primitives) rendered by the web UIUser interface from synced front-matter artifacts. |
| Front matterAn optional fenced `---` block of flat `key: value` scalars at the top of a markdown product; the indexed subset `{type?, title?, date?}` is parsed once at byte ingress. | An optional fenced --- block of flat key: value scalars at the top of a markdown product; the indexed subset {type?, title?, date?} is parsed once at byte ingress. |
| TemplateA canned loop intent (meta.json `description` paste-prompt, optional `reference.md`, `thumb.svg`, `story.md`, flow spec) under `src/skill/templates/`. | A canned loop intent (meta.json description paste-prompt, optional reference.md, thumb.svg, story.md, flow spec) under src/skill/templates/. |
| BundleA curated category of templates under `src/skill/bundles/`; every template belongs to exactly one bundle. | A curated category of templates under src/skill/bundles/; every template belongs to exactly one bundle. |
| MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. | A teammate's daemon; the identity unit that owns loops and scopes the dashboard. |
| TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. | The ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. |
Technical Terms
| Term | Definition |
|---|---|
| Pending runA run row in phase `pending`; it is the durable inbox a machine's poll claims. | A run row in phase pending; it is the durable inbox a machine's poll claims. |
| Run leaseA durable row (`run_leases`) minted per delivery holding per-run caps; state machine `active` → `terminal-grace` → retired. | A durable row (run_leases) minted per delivery holding per-run caps; state machine active → terminal-grace → retired. |
| Terminal-graceA swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. | A swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. |
| Long-pollThe opt-in server-held poll (`wait:true`, ~20s) an idle daemon uses for near-zero dispatch latency. | The opt-in server-held poll (wait:true, ~20s) an idle daemon uses for near-zero dispatch latency. |
| Watch set / watchDigestThe per-machine cache of loop folders to watch, digest-echoed so an unchanged client omits the payload. | The per-machine cache of loop folders to watch, digest-echoed so an unchanged client omits the payload. |
| ManifestThe full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. | The full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. |
| BlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. | Content-addressed bytes keyed by sha256 hash, stored in R2Cloudflare R2 object storage or in-memory; referenced by blobs/artifact_files rows. |
| OversizeA file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. | A file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. |
| Run snapshotThe loop's full artifact manifest captured at each run's finalize, diffed against the prior run for the run page. | The loop's full artifact manifest captured at each run's finalize, diffed against the prior run for the run page. |
| SweepThe periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GC. | The periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GCGarbage collection. |
| Circuit breaker`notifyRunFailure` auto-pause of a loop after a configurable consecutive-exec-failure streak. | notifyRunFailure auto-pause of a loop after a configurable consecutive-exec-failure streak. |
| Misfire catch-upBoot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. | Boot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. |
| TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. | The axi-shaped text output rendered by gateway/toon.ts for every /api/machine/cli verb. |
| BYOABring-Your-Own-Agent | Bring-your-own-agent: execution runs with the user's own coding agent and credentials on their own machine. |
Acronyms and Abbreviations
| Abbreviation | Expansion |
|---|---|
| BYOABring-Your-Own-Agent | Bring-Your-Own-Agent |
| R2Cloudflare R2 object storage | Cloudflare R2Cloudflare R2 object storage object storage |
| TTLTime-to-live | Time-to-live |
| GCGarbage collection | Garbage collection |
| LLMLarge Language Model | Large Language Model |
| WSWebSocket | WebSocket |
| UIUser interface | User interface |
| PRPull Request | Pull Request |
| npm OIDCnpm OpenID Connect trusted publishing | npm OpenID Connect trusted publishing |
| Fly / Fly.ioFly.io app hosting | Fly.io app hosting |
| pgliteEmbedded WASM Postgres by ElectricSQL | Embedded WASM Postgres by ElectricSQL |
| MCPModel Context Protocol | Model Context Protocol |
Pipeline Progress ?⚪ not started not yet begun✏️ draft initial version🔍 in review under review🚧 in progress actively worked on⛔ blocked waiting on dependency✅ done completed⏭️ skipped not applicable
Requirements: Loopany Daemon & CLI
Overview
The daemon (@crewlet/loopany) is the machine-side half of Loopany: one binary with two roles. As a poll-loop daemon it connects to a server, polls for due runs, and executes them with the user's local coding agent (Claude Code, Codex, or Grok). As the in-run loopany callback it lets the agent report results, adjust the loop's schedule/dashboard, and finish closed loops. It also owns the user-facing CLI (up/status/down/log/new/edit/show/home/skill/setup/update), the loop-folder watcher, transient-failure resume, the cwd jail, the PATH shim, and the best-effort install of the public skill and SessionStart hooks.
Stakeholders
| Stakeholder | Interest |
|---|---|
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. operator | Low-friction install and connect, a content-first home, and honest status; credentials and files stay local. |
| Agent session | The in-run callback is reachable with the exact verbs the server advertises; help never has side effects. |
| Server operator | The daemon forwards whatever credential its env carries; old servers degrade gracefully (TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. text vs SERVER_TOO_OLD). |
Functional Requirements
Order rows by priority: Must first, then Should, then May.
| ID | Priority | Requirement |
|---|---|---|
| FR-1MustThe daemon shall classify every invocation in a pure router and map the route to its handler, with `--help`/`-h` short-circuiting to per-verb usage before any side effect. | Must | The daemon shall classify every invocation in a pure router and map the route to its handler, with --help/-h short-circuiting to per-verb usage before any side effect. |
| FR-2MustThe daemon shall run as a poll loop (detached via `up`, foreground via `up --foreground`), polling with an opt-in long-poll when idle. | Must | The daemon shall run as a poll loop (detached via up, foreground via up --foreground), polling with an opt-in long-poll when idle. |
| FR-3MustThe in-run callback (when `LOOPANY_RUN_TOKEN` is set) shall win over every other route, including bare `loopany` (which posts `home` on the run credential). | Must | The in-run callback (when LOOPANY_RUN_TOKEN is set) shall win over every other route, including bare loopany (which posts home on the run credential). |
| FR-4MustThe daemon shall spawn the loop's coding agent based on `loops.agent` (claude-code / codex / grok) with the correct CLI flags and env forwarding. | Must | The daemon shall spawn the loop's coding agent based on loops.agent (claude-code / codex / grok) with the correct CLI flags and env forwarding. |
| FR-5MustA transient coding-agent crash shall resume the session (`--resume`) with a short continuation prompt, up to `LOOPANY_TRANSIENT_RETRIES` attempts. | Must | A transient coding-agent crash shall resume the session (--resume) with a short continuation prompt, up to LOOPANY_TRANSIENT_RETRIES attempts. |
| FR-6MustThe daemon shall report runs through the run credential and print the server's TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. `text` + `exitCode` for every server verb. | Must | The daemon shall report runs through the run credential and print the server's TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. text + exitCode for every server verb. |
| FR-7MustThe daemon shall enforce a cwd jail (`LOOPANY_ROOTS`) and an allowlisted child environment. | Must | The daemon shall enforce a cwd jail (LOOPANY_ROOTS) and an allowlisted child environment. |
| FR-8MustThe daemon shall install the public skill for every known coding agent on `up`/`new`, best-effort and never blocking. | Must | The daemon shall install the public skill for every known coding agent on up/new, best-effort and never blocking. |
| FR-9ShouldThe daemon shall install SessionStart hooks for Claude Code, Codex, and Grok via shared JSON merge logic, gated on a durable on-PATH `loopany`. | Should | The daemon shall install SessionStart hooks for Claude Code, Codex, and Grok via shared JSON merge logic, gated on a durable on-PATH loopany. |
| FR-10ShouldThe daemon shall maintain a pidfile `<pid>:<startTime>` so a reused pid never reads as the live daemon. | Should | The daemon shall maintain a pidfile <pid>:<startTime> so a reused pid never reads as the live daemon. |
| FR-11ShouldThe daemon shall write a version-consistent PATH shim (or use a durable PATH global) and report it as `bin:`. | Should | The daemon shall write a version-consistent PATH shim (or use a durable PATH global) and report it as bin:. |
| FR-12ShouldThe daemon shall forward out-of-run `report`/`finish`/`complete` so the server's crafted run-only 403 reaches the agent. | Should | The daemon shall forward out-of-run report/finish/complete so the server's crafted run-only 403 reaches the agent. |
| FR-13ShouldThe daemon shall report loop-creation milestones via `loopany progress <step> --connect-key <key>` (best-effort). | Should | The daemon shall report loop-creation milestones via loopany progress <step> --connect-key <key> (best-effort). |
Non-Functional Requirements
Order rows by priority: Must first, then Should, then May.
| ID | Priority | Category | Requirement |
|---|---|---|---|
| NFR-1MustThe device token passes to the child via env, never argv (ps-visible). | Must | Security | The device token passes to the child via env, never argv (ps-visible). |
| NFR-2MustA run whose server has gone text-less (too old) surfaces a definitive `SERVER_TOO_OLD` error, never blank output. | Must | Reliability | A run whose server has gone text-less (too old) surfaces a definitive SERVER_TOO_OLD error, never blank output. |
| NFR-3Must`ensureBinShim`/`refreshHooks` must never clobber a foreign `loopany` or write the real home when running under tests. | Must | Security | ensureBinShim/refreshHooks must never clobber a foreign loopany or write the real home when running under tests. |
| NFR-4ShouldAll external touches (process/network/fs) are injectable seams so tests never need a real process or network. | Should | Operability | All external touches (process/network/fs) are injectable seams so tests never need a real process or network. |
| NFR-5ShouldAn idle poll should re-poll quickly after a server hold and sleep out the poll interval after a fast answer. | Should | Performance | An idle poll should re-poll quickly after a server hold and sleep out the poll interval after a fast answer. |
| NFR-6ShouldThe SessionStart home fetch must be bounded so a hung server degrades to a definitive home, never stalling session start. | Should | Reliability | The SessionStart home fetch must be bounded so a hung server degrades to a definitive home, never stalling session start. |
Constraints
- The daemon spawns a coding agent that runs with the user's credentials; it is the highest-permission surface and is continuously hardened.
- The workflow subprocess runs bare node; the MCPModel Context Protocol bridge is plain ESM on purpose.
report/finishreject an invalid--statuswith a 400VALIDATION_ERROR(fail loud).
Acceptance Criteria
Every FR and NFR shall have at least one acceptance criterion.
- FR-1MustThe daemon shall classify every invocation in a pure router and map the route to its handler, with `--help`/`-h` short-circuiting to per-verb usage before any side effect.
- Given
loopany up --foreground --help - When it is parsed
- Then the per-verb help prints and the poll loop never launches
- Given
- FR-2MustThe daemon shall run as a poll loop (detached via `up`, foreground via `up --foreground`), polling with an opt-in long-poll when idle.
- Given
loopany upon a fresh machine - When it runs
- Then a detached daemon polls the server and an idle poll opts into
wait:true
- Given
- FR-3MustThe in-run callback (when `LOOPANY_RUN_TOKEN` is set) shall win over every other route, including bare `loopany` (which posts `home` on the run credential).
- Given a run with
LOOPANY_RUN_TOKENset - When the agent types bare
loopany - Then the callback posts
homeon the run credential
- Given a run with
- FR-4MustThe daemon shall spawn the loop's coding agent based on `loops.agent` (claude-code / codex / grok) with the correct CLI flags and env forwarding.
- Given a loop bound to
codex - When the run starts
- Then the daemon spawns
codex execwith the correct flags and env
- Given a loop bound to
- FR-5MustA transient coding-agent crash shall resume the session (`--resume`) with a short continuation prompt, up to `LOOPANY_TRANSIENT_RETRIES` attempts.
- Given a claude crash classified transient
- When the run retries
- Then it resumes the prior session and spend is summed across attempts
- FR-6MustThe daemon shall report runs through the run credential and print the server's TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. `text` + `exitCode` for every server verb.
- Given any server verb
- When the server returns
- Then the daemon prints
body.textand exits withbody.exitCode
- FR-7MustThe daemon shall enforce a cwd jail (`LOOPANY_ROOTS`) and an allowlisted child environment.
- Given a server-sent root outside
LOOPANY_ROOTS - When the daemon resolves it
- Then the cwd jail narrows, never widens
- Given a server-sent root outside
- FR-8MustThe daemon shall install the public skill for every known coding agent on `up`/`new`, best-effort and never blocking.
- Given
loopany up - When the skill install runs
- Then the skill lands at user scope for every
SKILL_TARGET_AGENTS, best-effort
- Given
- NFR-1MustThe device token passes to the child via env, never argv (ps-visible).
- Given a spawned agent process
- When the command is built
- Then the device token appears only in env, never in argv
- NFR-2MustA run whose server has gone text-less (too old) surfaces a definitive `SERVER_TOO_OLD` error, never blank output.
- Given a pre-0.12 server returning no
text - When a device verb is invoked
- Then
SERVER_TOO_OLDis printed to stdout, exit 1
- Given a pre-0.12 server returning no
Conflicts
None identified yet.
Open Questions
- None: the daemon behavior is fully determined by the code and its tests.
Specification: Loopany Daemon & CLI
Overview
The daemon is one Node ESM binary (dist/cli.js) with two roles: the poll-loop daemon and the in-run loopany callback. Routing is a pure, side-effect-free classifier in route.ts; cli.ts maps a Route to a lazily-imported handler. All server verbs converge on cli-client.ts postCli, which picks the credential by env (run token wins), inlines file flags, and POSTs to the unified /api/machine/cli with a legacy fallback for old servers.
Architecture
loopany <args>
└─ route.ts classify(argv, env) → Route
├─ LOOPANY_RUN_TOKEN set → callback (in-run; bare → "home")
├─ help/version fast-paths
├─ <verb> --help → per-verb usage (before handler)
├─ up / up --foreground / --server-url re-exec → daemon
├─ new/skill/setup/update/status/down/log/show/progress → handlers
├─ loops/edit → interactive
├─ report/finish/complete out-of-run → forward (device-cred 403)
└─ bare → home (device cred, content-first)
The daemon poll loop (daemon.ts) builds the poll body with wait:true only while no run is in flight; the runner (runner.ts) spawns the coding agent via buildAgentSpawn and classifies crashes to decide transient resume.
Data Models
The daemon persists local-only state, none of which is shared schema:
~/.loopany/daemon.pid—<pid>:<startTime>for down/status/up idempotency.~/.loopany/device token file — read for device verbs.~/.loopany/skill/— generated public skill bundle (gitignored, never committed).- LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. folders on disk — each loop's worktree/task file, watched by
watcher.ts.
API Contracts
The daemon consumes the server's machine API:
POST /api/machine/poll— claim runs, fetch watch set + config.POST /api/machine/sync+PUT /api/machine/blob/:hash— artifact sync (device token).POST /api/machine/cli— all owner + run verbs, body{argv, token}; response{text, exitCode, loops?, runs?}.POST /agent-api/loop— legacy run transport fallback on a 404 from the unified CLI.POST /api/claim/progress— creation milestone reporting (progressverb).
Sequences
Detached up (idempotent)
loopany up → runEnsure({force?})
├─ consult pidfile (reused pid never reads as live)
├─ ensureBinShim (durable PATH shim, never clobbers foreign)
├─ refreshHooks (best-effort, durable command only)
├─ install skill for SKILL_TARGET_AGENTS (best-effort)
└─ spawn detached daemon with token via ENV, re-exec --server-url/--api-key
In-run callback
agent: loopany report --status resolved …
→ classify → callback → postCli(argv, run token)
→ POST /api/machine/cli → print body.text + exitCode
→ 404 (old server) → legacyRun → POST /agent-api/loop
Transient-failure resume
claude exits non-zero → classifyFailure
├─ auth/quota > poisoned > transient > task precedence
└─ transient (API error/ECONNRESET/5xx/rate limit) →
claude --resume <sessionId> + buildResumeTask continuation prompt
(max LOOPANY_TRANSIENT_RETRIES, backoff LOOPANY_TRANSIENT_RETRY_BASE_MS ×4 + jitter)
Technical Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Routing | Pure classify(argv, env) in route.ts |
Unit-testable without hanging a subprocess; <verb> --help inherits no-side-effect |
| Credential selection | Run token from env wins over device token | One client behind both CLI worlds (cli-client.ts) |
| Server-verb output | Print body.text + exitCode; text-less server → SERVER_TOO_OLD |
Daemon is a pure text sink; batch 7 retired the structured fallback |
| Agent spawn | Branch on loops.agent (claude/codex/grok) with per-agent flags + env |
BYOABring-Your-Own-Agent and vendor-neutral execution |
| Resume | --resume forks the session id; spend summed across attempts |
Recovers from provider blips without redoing work |
| Jail | LOOPANY_ROOTS resolve-normalized prefix check |
Server-sent roots can only narrow the local jail |
| Installers | Best-effort skill install + shared JSON SessionStart hook merge | Never blocks up; each agent has a concrete installer |
| PATH shim | Version-consistent re-exec wrapper; SHIM_MARKER detection |
Never clobbers a foreign loopany; ephemeral npx entries skipped |
Risks and Unknowns
- The daemon executes the user's coding agent with their credentials; it is the highest-permission surface and the security hardening focus.
- Non-Claude telemetry is degraded: grok and codex streams are not Claude stream-json, so live progress/cost/transcript parse awaits per-agent stream adapters.
- The daemon npm release and server deploy must be coordinated when wire-format changes ship (batch gating).
Out of Scope
- Server-side scheduling and storage (server features cover those).
- Multi-agent stream adapters for grok/codex live progress (a noted follow-up).
Test Plan: Loopany Daemon & CLI
Scope
Testing the daemon's CLI routing, poll loop, agent spawning, transient resume, callback transport, cwd jail, pidfile, bin shim, skill/hook installers, watcher, and workflow execution. All external touches are injectable seams; no test needs a real process or network.
Unit Tests
| ID | Description | Input | Expected Output |
|---|---|---|---|
| TC-1 | CLI routing classifies every verb | argv matrix incl. bare, up --foreground, run-token callback |
Correct Route kind per case |
| TC-2 | <verb> --help short-circuits before the handler |
up --foreground --help |
Per-verb help, no daemon launch |
| TC-3 | In-run bare loopany posts home |
LOOPANY_RUN_TOKEN set, no args |
Route callback with home |
| TC-4 | Out-of-run report/finish forward on the device credential | loopany report … outside a run |
Route forward; server's run-only 403 reaches the agent |
| TC-5 | Unknown verb errors exit 2 | loopany frobnicate |
Exit 2, never a backgrounded daemon |
| TC-6 | Poll loop builds the poll body | In-flight run vs idle | wait:true only while idle; heartbeat cadence otherwise |
| TC-7 | Poll delay after a server hold vs a fast answer | Consumed vs instant interval | 250ms breather vs sleep out POLL_MS |
| TC-8 | Agent spawn branches on the coding agent | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. with agent claude-code / codex / grok |
Correct binary, flags, and env per agent |
| TC-9 | Transient-failure classification and resume | Crashes of each class | Only transient retries, --resume + continuation prompt, attempt cap |
| TC-10 | Spend is summed across resume attempts | Multi-attempt run | Report carries summed cost; attempts only when > 1 |
| TC-11 | Cwd jail narrows server-sent roots | Root outside LOOPANY_ROOTS |
Resolve-normalized prefix check rejects |
| TC-12 | Child env is allowlisted | Spawned process | Only allowlisted keys pass through |
| TC-13 | Pidfile records pid+startTime and survives a reused pid | Fresh pidfile / stale pid | down/status/up idempotency correct |
| TC-14 | Bin shim writes only our own shim | Foreign loopany present |
Never clobbers; {path,onPath,written} accurate |
| TC-15 | Skill install covers every target agent | SKILL_TARGET_AGENTS set |
npx skills add … -a claude-code -a codex -g invoked per agent |
| TC-16 | SessionStart hook install merges per agent | Claude/Codex/Grok installers | Shared JSON merge routine; codex surfaces the trust step |
| TC-17 | Server verbs print text + exitCode | Stub server returning TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. body | Non-empty stdout via callback boundary |
| TC-18 | Text-less old server surfaces SERVER_TOO_OLD |
Stub returning no text |
Definitive error, exit 1 (home prints tooOldHome exit 0) |
| TC-19 | progress posts creation milestones |
progress <step> --connect-key <key> |
Best-effort POST, always exit 0 |
| TC-20 | WorkflowAn optional zero-LLM async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. subprocess runs the async function body | Valid body / export body |
Executes; a /SyntaxError/ body is a user-fix case |
Edge Cases and Failure Scenarios
| ID | Scenario | Expected Behavior |
|---|---|---|
| TC-21 | Timeout crash (wall-clock guard) | Never retries; our guard, not a provider blip |
| TC-22 | No captured session to resume | Run stops immediately, no retry |
| TC-23 | Abort during a run | Stops immediately |
| TC-24 | WorkflowAn optional zero-LLM async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. failure | Falls back to the agent with the original task + failure context; cursor never advances |
| TC-25 | Report after lease already retired | report() logs a clear 401 line, never silently drops |
| TC-26 | ensureBinShim/refreshHooks under test |
Inject seams; never writes the real ~/.claude/settings.json or ~/.local/bin |
Test Infrastructure
- vitest with injected fs/env/process/network seams (no real processes or network).
- Stub servers for the callback/CLI transport boundaries.
ensure.test.tsseams()no-ops bin-shim/hook writers; every setup/bin-shim test injects seams.
Coverage Matrix
| Requirement | Test Cases |
|---|---|
| FR-1MustThe daemon shall classify every invocation in a pure router and map the route to its handler, with `--help`/`-h` short-circuiting to per-verb usage before any side effect. | TC-1, TC-2 |
| FR-2MustThe daemon shall run as a poll loop (detached via `up`, foreground via `up --foreground`), polling with an opt-in long-poll when idle. | TC-6, TC-7 |
| FR-3MustThe in-run callback (when `LOOPANY_RUN_TOKEN` is set) shall win over every other route, including bare `loopany` (which posts `home` on the run credential). | TC-3 |
| FR-4MustThe daemon shall spawn the loop's coding agent based on `loops.agent` (claude-code / codex / grok) with the correct CLI flags and env forwarding. | TC-8 |
| FR-5MustA transient coding-agent crash shall resume the session (`--resume`) with a short continuation prompt, up to `LOOPANY_TRANSIENT_RETRIES` attempts. | TC-9, TC-10 |
| FR-6MustThe daemon shall report runs through the run credential and print the server's TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. `text` + `exitCode` for every server verb. | TC-17, TC-18 |
| FR-7MustThe daemon shall enforce a cwd jail (`LOOPANY_ROOTS`) and an allowlisted child environment. | TC-11, TC-12 |
| FR-8MustThe daemon shall install the public skill for every known coding agent on `up`/`new`, best-effort and never blocking. | TC-15 |
| FR-9ShouldThe daemon shall install SessionStart hooks for Claude Code, Codex, and Grok via shared JSON merge logic, gated on a durable on-PATH `loopany`. | TC-16 |
| FR-10ShouldThe daemon shall maintain a pidfile `<pid>:<startTime>` so a reused pid never reads as the live daemon. | TC-13 |
| FR-11ShouldThe daemon shall write a version-consistent PATH shim (or use a durable PATH global) and report it as `bin:`. | TC-14 |
| FR-12ShouldThe daemon shall forward out-of-run `report`/`finish`/`complete` so the server's crafted run-only 403 reaches the agent. | TC-4 |
| FR-13ShouldThe daemon shall report loop-creation milestones via `loopany progress <step> --connect-key <key>` (best-effort). | TC-19 |
| NFR-1MustThe device token passes to the child via env, never argv (ps-visible). | TC-12 (env-only credential) |
| NFR-2MustA run whose server has gone text-less (too old) surfaces a definitive `SERVER_TOO_OLD` error, never blank output. | TC-18 |
| NFR-3Must`ensureBinShim`/`refreshHooks` must never clobber a foreign `loopany` or write the real home when running under tests. | TC-26 |
| NFR-4ShouldAll external touches (process/network/fs) are injectable seams so tests never need a real process or network. | all seam-injected tests |
| NFR-5ShouldAn idle poll should re-poll quickly after a server hold and sleep out the poll interval after a fast answer. | TC-7 |
| NFR-6ShouldThe SessionStart home fetch must be bounded so a hung server degrades to a definitive home, never stalling session start. | bounded home fetch covered by home.test.ts |
requirements
- None: the daemon behavior is fully determined by the code and its tests.
Vocabulary
Domain Terms
| Term | Definition |
|---|---|
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. | A scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. |
| Loop folderThe on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. | The on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. |
| Open loopA loop with `goal = null` that runs indefinitely (monitor/digest); it never self-terminates. | A loop with goal = null that runs indefinitely (monitor/digest); it never self-terminates. |
| Closed loopA loop with a non-null `goal`; each exec run judges state against the goal and may call `loopany finish` when met, stamping `completedAt` and disabling the loop. | A loop with a non-null goal; each exec run judges state against the goal and may call loopany finish when met, stamping completedAt and disabling the loop. |
| Exec runA scheduled execution of a loop (role `exec`); only exec runs produce user-facing notifications. | A scheduled execution of a loop (role exec); only exec runs produce user-facing notifications. |
| Evolve runA self-improvement pass (role `evolve`) that reviews run history and rewrites the loop's brief, state schema, and dashboard. | A self-improvement pass (role evolve) that reviews run history and rewrites the loop's brief, state schema, and dashboard. |
| Edit runAn owner-requested change (role `edit`) that applies an instruction to the loop, then clears the request. | An owner-requested change (role edit) that applies an instruction to the loop, then clears the request. |
| Run token / run credentialThe per-run lease credential (`rk_…`) authorizing in-run `loopany` verbs and the final report. | The per-run lease credential (rk_…) authorizing in-run loopany verbs and the final report. |
| Device tokenThe `dk_`-prefixed credential that fully impersonates a machine for owner verbs and polling. | The dk_-prefixed credential that fully impersonates a machine for owner verbs and polling. |
| Connect keyA one-time claim token minted by the dashboard that binds a newly connecting machine to its owner and team. | A one-time claim token minted by the dashboard that binds a newly connecting machine to its owner and team. |
| Task fileThe loop's durable context+log document on the machine; its `## Spec` section is the standing brief. | The loop's durable context+log document on the machine; its ## Spec section is the standing brief. |
| WorkflowAn optional zero-LLM async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. | An optional zero-LLMLarge Language Model async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. |
| Generative dashboardLoop-authored `ui` markup (with `loop-embed`/`loop-calendar`/`loop-kanban` primitives) rendered by the web UI from synced front-matter artifacts. | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal.-authored ui markup (with loop-embed/loop-calendar/loop-kanban primitives) rendered by the web UIUser interface from synced front-matter artifacts. |
| Front matterAn optional fenced `---` block of flat `key: value` scalars at the top of a markdown product; the indexed subset `{type?, title?, date?}` is parsed once at byte ingress. | An optional fenced --- block of flat key: value scalars at the top of a markdown product; the indexed subset {type?, title?, date?} is parsed once at byte ingress. |
| TemplateA canned loop intent (meta.json `description` paste-prompt, optional `reference.md`, `thumb.svg`, `story.md`, flow spec) under `src/skill/templates/`. | A canned loop intent (meta.json description paste-prompt, optional reference.md, thumb.svg, story.md, flow spec) under src/skill/templates/. |
| BundleA curated category of templates under `src/skill/bundles/`; every template belongs to exactly one bundle. | A curated category of templates under src/skill/bundles/; every template belongs to exactly one bundle. |
| MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. | A teammate's daemon; the identity unit that owns loops and scopes the dashboard. |
| TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. | The ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. |
Technical Terms
| Term | Definition |
|---|---|
| Pending runA run row in phase `pending`; it is the durable inbox a machine's poll claims. | A run row in phase pending; it is the durable inbox a machine's poll claims. |
| Run leaseA durable row (`run_leases`) minted per delivery holding per-run caps; state machine `active` → `terminal-grace` → retired. | A durable row (run_leases) minted per delivery holding per-run caps; state machine active → terminal-grace → retired. |
| Terminal-graceA swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. | A swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. |
| Long-pollThe opt-in server-held poll (`wait:true`, ~20s) an idle daemon uses for near-zero dispatch latency. | The opt-in server-held poll (wait:true, ~20s) an idle daemon uses for near-zero dispatch latency. |
| Watch set / watchDigestThe per-machine cache of loop folders to watch, digest-echoed so an unchanged client omits the payload. | The per-machine cache of loop folders to watch, digest-echoed so an unchanged client omits the payload. |
| ManifestThe full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. | The full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. |
| BlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. | Content-addressed bytes keyed by sha256 hash, stored in R2Cloudflare R2 object storage or in-memory; referenced by blobs/artifact_files rows. |
| OversizeA file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. | A file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. |
| Run snapshotThe loop's full artifact manifest captured at each run's finalize, diffed against the prior run for the run page. | The loop's full artifact manifest captured at each run's finalize, diffed against the prior run for the run page. |
| SweepThe periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GC. | The periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GCGarbage collection. |
| Circuit breaker`notifyRunFailure` auto-pause of a loop after a configurable consecutive-exec-failure streak. | notifyRunFailure auto-pause of a loop after a configurable consecutive-exec-failure streak. |
| Misfire catch-upBoot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. | Boot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. |
| TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. | The axi-shaped text output rendered by gateway/toon.ts for every /api/machine/cli verb. |
| BYOABring-Your-Own-Agent | Bring-your-own-agent: execution runs with the user's own coding agent and credentials on their own machine. |
Acronyms and Abbreviations
| Abbreviation | Expansion |
|---|---|
| BYOABring-Your-Own-Agent | Bring-Your-Own-Agent |
| R2Cloudflare R2 object storage | Cloudflare R2Cloudflare R2 object storage object storage |
| TTLTime-to-live | Time-to-live |
| GCGarbage collection | Garbage collection |
| LLMLarge Language Model | Large Language Model |
| WSWebSocket | WebSocket |
| UIUser interface | User interface |
| PRPull Request | Pull Request |
| npm OIDCnpm OpenID Connect trusted publishing | npm OpenID Connect trusted publishing |
| Fly / Fly.ioFly.io app hosting | Fly.io app hosting |
| pgliteEmbedded WASM Postgres by ElectricSQL | Embedded WASM Postgres by ElectricSQL |
| MCPModel Context Protocol | Model Context Protocol |
Pipeline Progress ?⚪ not started not yet begun✏️ draft initial version🔍 in review under review🚧 in progress actively worked on⛔ blocked waiting on dependency✅ done completed⏭️ skipped not applicable
Requirements: Artifact Sync & Generative Dashboard
Overview
This feature turns a loop's folder into durable server-side content and renders it as a generative dashboard. The daemon watcher builds a full sha256 manifest of each loop folder and syncs changes to the server; bytes live in a content-addressed blob store (R2Cloudflare R2 object storage or in-memory) with metadata in blobs/artifact_files. Front-matter-typed markdown products render as generative-UIUser interface panels (loop-embed, loop-calendar, loop-kanban, charts), a run's snapshot enables a per-run diff, and retention/GCGarbage collection keeps storage bounded. The server only stores and reads bytes; it never interprets or executes them.
Stakeholders
| Stakeholder | Interest |
|---|---|
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. owner | A dashboard that renders the loop's real products (reports, kanban cards, calendars, charts) without manual setup. |
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. operator | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. folders sync live; heavy work stays out of the loop folder so sync stays bounded. |
| Server operator | Retention/GCGarbage collection keeps storage within the per-loop cap; inline images are served sandboxed. |
Functional Requirements
Order rows by priority: Must first, then Should, then May.
| ID | Priority | Requirement |
|---|---|---|
| FR-1MustThe daemon shall watch each loop folder and build a full sha256 manifest with incremental hashing (stat-cache, racy-write guard). | Must | The daemon shall watch each loop folder and build a full sha256 manifest with incremental hashing (stat-cache, racy-write guard). |
| FR-2MustThe daemon shall POST the manifest to `/api/machine/sync`, upload only the hashes the server requests via `PUT /api/machine/blob/:hash`, and verify the hash server-side. | Must | The daemon shall POST the manifest to /api/machine/sync, upload only the hashes the server requests via PUT /api/machine/blob/:hash, and verify the hash server-side. |
| FR-3MustThe server shall store blob bytes in a content-addressed store (R2Cloudflare R2 object storage when configured, in-memory otherwise) keyed by sha256, with metadata rows in `blobs` and current file state in `artifact_files`. | Must | The server shall store blob bytes in a content-addressed store (R2Cloudflare R2 object storage when configured, in-memory otherwise) keyed by sha256, with metadata rows in blobs and current file state in artifact_files. |
| FR-4MustThe server shall enforce per-file (10MB) and per-loop (500MB) byte caps, marking oversized files metadata-only, and honor the never-syncable dir list. | Must | The server shall enforce per-file (10MB) and per-loop (500MB) byte caps, marking oversized files metadata-only, and honor the never-syncable dir list. |
| FR-5MustThe daemon shall bound every sync to per-loop file-count and byte ceilings, keeping the top-level content home and dropping overflow with one loud warning. | Must | The daemon shall bound every sync to per-loop file-count and byte ceilings, keeping the top-level content home and dropping overflow with one loud warning. |
| FR-6MustMarkdown products with front-matter `type`/`title`/`date` shall be indexed once at byte ingress and rendered as generative dashboard panels. | Must | Markdown products with front-matter type/title/date shall be indexed once at byte ingress and rendered as generative dashboard panels. |
| FR-7MustThe dashboard shall render custom panels (`loop-embed`/`loop-calendar`/`loop-kanban`) plus charts from numeric run state, sanitized against stored XSS. | Must | The dashboard shall render custom panels (loop-embed/loop-calendar/loop-kanban) plus charts from numeric run state, sanitized against stored XSS. |
| FR-8MustRun snapshots shall capture the manifest at report, and the run page shall diff run N against the prior snapshot. | Must | Run snapshots shall capture the manifest at report, and the run page shall diff run N against the prior snapshot. |
| FR-9MustRetention/GCGarbage collection shall prune snapshots (keep 20), unpin old blobs, honor a grace window, re-check referencedness, and delete bytes before metadata. | Must | Retention/GCGarbage collection shall prune snapshots (keep 20), unpin old blobs, honor a grace window, re-check referencedness, and delete bytes before metadata. |
| FR-10ShouldHTML artifacts shall render in a strict sandboxed iframe (never same-origin); images (incl. SVG) render via a hardened inline route. | Should | HTML artifacts shall render in a strict sandboxed iframe (never same-origin); images (incl. SVG) render via a hardened inline route. |
| FR-11ShouldThe task file shall appear exactly once in the files panel and render from the loop record's content, not a blob fetch. | Should | The task file shall appear exactly once in the files panel and render from the loop record's content, not a blob fetch. |
| FR-12ShouldThe dashboard grid shall cap at two columns, with only custom panels tiling and content blocks spanning full width. | Should | The dashboard grid shall cap at two columns, with only custom panels tiling and content blocks spanning full width. |
Non-Functional Requirements
Order rows by priority: Must first, then Should, then May.
| ID | Priority | Category | Requirement |
|---|---|---|---|
| NFR-1MustThe never-syncable dir list and caps must be enforced identically on daemon and server to keep the two in sync. | Must | Security | The never-syncable dir list and caps must be enforced identically on daemon and server to keep the two in sync. |
| NFR-2MustInline image serving must set `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox`. | Must | Security | Inline image serving must set X-Content-Type-Options: nosniff and Content-Security-Policy: sandbox. |
| NFR-3MustUnchanged files must never be re-read (incremental hashing); a digest match skips the network entirely. | Must | Performance | Unchanged files must never be re-read (incremental hashing); a digest match skips the network entirely. |
| NFR-4MustA sync burst must never 413 the server's body cap (inline blob budget + overflow to the PUT path). | Must | Availability | A sync burst must never 413 the server's body cap (inline blob budget + overflow to the PUT path). |
| NFR-5ShouldBlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. PUTs shall run bounded-concurrent (4); inline blobs budgeted 1MB aggregate per POST. | Should | Performance | BlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. PUTs shall run bounded-concurrent (4); inline blobs budgeted 1MB aggregate per POST. |
| NFR-6ShouldBlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. GCGarbage collection must bias to keep: a leaked blob is a cost bug, a wrong delete is data loss. | Should | Reliability | BlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. GCGarbage collection must bias to keep: a leaked blob is a cost bug, a wrong delete is data loss. |
Constraints
- The server never executes or interprets artifact bytes beyond parsing front-matter (pure, bounded, never throws).
- SVG is scriptable and must never be inlined into the app DOM.
- The dev (vite) server 404s asset-extension routes; image rendering verifies against a nitro prod build.
Acceptance Criteria
Every FR and NFR shall have at least one acceptance criterion.
- FR-1MustThe daemon shall watch each loop folder and build a full sha256 manifest with incremental hashing (stat-cache, racy-write guard).
- Given a loop folder with an unchanged file
- When the watcher rebuilds the manifest
- Then the file is not re-read (stat-cache hit)
- FR-2MustThe daemon shall POST the manifest to `/api/machine/sync`, upload only the hashes the server requests via `PUT /api/machine/blob/:hash`, and verify the hash server-side.
- Given a sync with a changed file
- When the server responds
needHashes - Then only those blobs are PUT and each PUT's hash is verified server-side
- FR-3MustThe server shall store blob bytes in a content-addressed store (R2Cloudflare R2 object storage when configured, in-memory otherwise) keyed by sha256, with metadata rows in `blobs` and current file state in `artifact_files`.
- Given a blob PUT
- When stored
- Then bytes land in the blob store keyed by sha256 and metadata rows record it
- FR-4MustThe server shall enforce per-file (10MB) and per-loop (500MB) byte caps, marking oversized files metadata-only, and honor the never-syncable dir list.
- Given a file over 10MB or a loop over 500MB
- When synced
- Then it is metadata-only (oversize) and the byte cap is enforced authoritatively at
putBlob
- FR-5MustThe daemon shall bound every sync to per-loop file-count and byte ceilings, keeping the top-level content home and dropping overflow with one loud warning.
- Given a loop folder over the manifest ceilings
- When the watcher caps it
- Then the top-level content home survives and overflow drops with one loud warning
- FR-6MustMarkdown products with front-matter `type`/`title`/`date` shall be indexed once at byte ingress and rendered as generative dashboard panels.
- Given a markdown product with
type: report,title,datefront matter - When its bytes arrive
- Then
{type?, title?, date?}is parsed once and stored on the blob row
- Given a markdown product with
- FR-7MustThe dashboard shall render custom panels (`loop-embed`/`loop-calendar`/`loop-kanban`) plus charts from numeric run state, sanitized against stored XSS.
- Given dashboard markup with
loop-kanbanand columns - When rendered
- Then artifacts group into columns (unmatched types collect in "Other"; task file excluded)
- Given dashboard markup with
- FR-8MustRun snapshots shall capture the manifest at report, and the run page shall diff run N against the prior snapshot.
- Given a run finalizing
- When report persists
- Then a snapshot is captured and the run page can diff it against the prior snapshot
- FR-9MustRetention/GCGarbage collection shall prune snapshots (keep 20), unpin old blobs, honor a grace window, re-check referencedness, and delete bytes before metadata.
- Given unreferenced blobs past the grace window
- When GCGarbage collection runs
- Then bytes delete before metadata, with a live keep-set re-check per candidate
- NFR-1MustThe never-syncable dir list and caps must be enforced identically on daemon and server to keep the two in sync.
- Given a
.envfile ornode_modulesdir in a loop folder - When synced
- Then it is excluded on both daemon and server
- Given a
- NFR-2MustInline image serving must set `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox`.
- Given an inline image request
- When served
- Then
nosniff+CSP: sandboxare set
- NFR-3MustUnchanged files must never be re-read (incremental hashing); a digest match skips the network entirely.
- Given an unchanged manifest digest
- When the daemon syncs
- Then the network round-trip is skipped entirely
- NFR-4MustA sync burst must never 413 the server's body cap (inline blob budget + overflow to the PUT path).
- Given a burst of inline blobs over the aggregate budget
- When synced
- Then overflow takes the PUT path and the POST never 413s
Conflicts
None identified yet.
Open Questions
- None: behavior is fully determined by the code and its tests.
Specification: Artifact Sync & Generative Dashboard
Overview
ArtifactSync (gateway/sync.ts) is the byte-ingress cluster: sync() reconciles a full manifest against the server's artifact_files state, and putBlob() stores content-addressed bytes in the shared BlobStore (R2Cloudflare R2 object storage or in-memory). The daemon's watcher.ts builds manifests incrementally with a stat cache. Dashboard rendering parses front-matter at ingress (server/frontmatter.ts) and renders typed panels (components/LoopView.tsx registry) with DOMPurify sanitization.
Architecture
daemon watcher (chokidar)
└─ buildManifest (stat cache, incremental sha256, capManifest ceilings)
└─ POST /api/machine/sync → server replies needHashes
└─ PUT /api/machine/blob/:hash (4-concurrent, hash verified)
└─ BlobStore (R2Cloudflare R2 object storage | in-memory) ← blobs row (+ parsed front-matter meta)
└─ artifact_files (loopId, path, hash, deleted, oversize)
web: /api/artifact/:loopId/* (session-authed, loopInScope)
└─ image inline route (allowlist imageMime, nosniff + CSP sandbox)
└─ LoopView renders ui markup → DOMPurify → loop-embed / loop-calendar / loop-kanban
└─ runSnapshots at report → getRunDiff (jsdiff) for the run page
retention: maintainStorage → prune snapshots (keep 20) → blob GCGarbage collection (grace, re-check, bytes-before-metadata)
Data Models
blobs
| Field | Type | Constraints | Description |
|---|---|---|---|
| hash | text | PK | sha256 hex; the R2Cloudflare R2 object storage object key |
| size | integer | not null | Byte length |
| binary | boolean | default false | NUL heuristic (download-only) |
| meta | jsonb | nullable | Parsed front-matter {type?, title?, date?} |
| createdAt | text | not null | Ingress time |
artifact_files
| Field | Type | Constraints | Description |
|---|---|---|---|
| id | text | PK | Row id |
| loopId | text | not null | Owning loop |
| path | text | not null | Normalized, loop-relative |
| hash | text | → blobs.hash; null if deleted/oversize | Current bytes |
| size | integer | nullable | Byte length |
| oversize / deleted | boolean | default false | Cap / tombstone flags |
| lastRunId | text | nullable | Run in flight when the change synced |
run_snapshots
| Field | Type | Constraints | Description |
|---|---|---|---|
| runId | text | PK | Run boundary |
| loopId | text | not null | Owning loop |
| manifest | jsonb | SnapshotManifest |
Full path → |
API Contracts
POST /api/machine/sync
Request
| Field | Type | Required | Description |
|---|---|---|---|
| token | string | yes | dk_ device token |
| manifest | object | yes | Full sha256 manifest per loop |
| inline | blob[] | no | Small inline blobs (≤64KB, ≤1MB aggregate) |
Response (200 OK)
| Field | Type | Description |
|---|---|---|
| needHashes | string[] | Hashes the server wants uploaded |
PUT /api/machine/blob/:hash
Response (200 OK)
Stores the body (verified hash) and returns the accepted blob metadata. Only hashes the sync handshake asked for are accepted.
Sequences
Sync reconcile
daemon flush → POST sync (full manifest, incremental inline)
server: compare manifest vs artifact_files → reply needHashes
daemon: PUT missing blobs (4-concurrent) → server verifies sha256 + caps
server: upsert artifact_files rows (deletions = absence), parse front-matter once
Run diff
report() → capture manifest → run_snapshots row (no diff computed on write)
run page → getRunDiff(runN) → diff vs prior snapshot manifest (jsdiff) → "Changes" tab
Technical Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Content addressing | sha256 keyed bytes in R2Cloudflare R2 object storage | Dedup across loops/runs; business DB holds only metadata |
| Sync handshake | Server asks for needHashes only |
A device token is never an uncapped write channel |
| Incremental hashing | Stat cache (size+mtime+ctime, racy-write guard) | Unchanged files never re-read; digest-match skips the network |
| ManifestThe full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. ceilings | capManifest (5000 files / 256MB) |
A burst can't 413/timeout into a retry storm; content home survives |
| Front matterAn optional fenced `---` block of flat `key: value` scalars at the top of a markdown product; the indexed subset `{type?, title?, date?}` is parsed once at byte ingress. | Parsed once at ingress, pure and bounded | Authoritative product date; soft convention, never a storage gate |
| XSS containment | HTML in a sandbox="allow-scripts" opaque-origin iframe; SVG never inlined |
Stored XSS containment is load-bearing |
| GCGarbage collection bias | Keep in doubt; bytes before metadata; re-check per candidate | A wrong delete is data loss; a leaked blob is a cost bug |
| Grid | auto-fit minmax(min(100%, max(28rem, (100% - gap)/2)), 1fr) |
Hard two-column cap; content blocks span full width |
Risks and Unknowns
- The shared blob store is load-bearing: boot constructs ONE instance for gateway + sync, or retention could delete bytes sync never wrote.
- The never-syncable dir list and caps must be kept in sync between
watcher.tsandgateway/artifacts.ts. - In-memory blob store is the test/dev default and loses bytes on restart (documented).
Out of Scope
- Executing or interpreting artifact content (beyond the front-matter parse).
- Full-text search or preview beyond the files panel and dashboard panels.
Test Plan: Artifact Sync & Generative Dashboard
Scope
Testing the watcher manifest/hash logic, the sync reconcile handshake, blob storage and caps, front-matter indexing, retention/GCGarbage collection, artifact byte serving, and the generative dashboard panels. Out of scope: daemon poll/run lifecycle (covered by the machine gateway and daemon features).
Unit Tests
| ID | Description | Input | Expected Output |
|---|---|---|---|
| TC-1 | ManifestThe full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. build is incremental (stat cache) | Unchanged file across rebuilds | File not re-read; digest matches, network skipped |
| TC-2 | ManifestThe full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. drops never-syncable dirs and key files | .git, node_modules, .env in a loop folder |
Excluded from the manifest on both daemon and server |
| TC-3 | capManifest keeps the shallowest-then-smallest |
Loop folderThe on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. over the file-count/byte ceilings | Content home survives; overflow dropped with one warning |
| TC-4 | Sync reconcile replies with needHashes |
ManifestThe full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. with one new file | Only the missing hash is requested |
| TC-5 | BlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. PUT verifies the hash | Mismatched body | Put rejected; only handshake-asked hashes accepted |
| TC-6 | Per-file and per-loop caps enforced | File > 10MB, loop > 500MB | OversizeA file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. metadata-only; authoritative cap at putBlob |
| TC-7 | Front-matter parsed once at ingress | Markdown product with type/title/date |
Indexed subset stored on the blob row; dedup reuses it |
| TC-8 | Front-matter parse is bounded and never throws | Junk front matter / huge file | Parse skipped, meta null, no throw |
| TC-9 | Snapshot captured at report; run diff computed lazily | Run N vs prior snapshot | "Changes" diff via jsdiff; runs without snapshots degrade gracefully |
| TC-10 | Snapshot pruning keeps 20 | 25 snapshots | 20 retained, old blobs unpinned |
| TC-11 | BlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. GCGarbage collection honors grace and re-checks referencedness | Candidate referenced / recent / old-unreferenced | Bytes deleted before metadata only for safe candidates |
| TC-12 | Inline image serving is hardened | Known image mime via ?view=inline |
nosniff + CSP: sandbox, real content-type |
| TC-13 | Unknown/oversize artifact renders metadata-only | OversizeA file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. file request | Note, no bytes served |
| TC-14 | Kanban groups typed artifacts into columns | type:-tagged markdown cards |
Columns per columns attr; unmatched → "Other"; task file excluded |
| TC-15 | Dashboard grid caps at two columns | Wide container + custom panels | Panels tile 2-up; content blocks span full width |
| TC-16 | File entries dedup the task file | Task fileThe loop's durable context+log document on the machine; its `## Spec` section is the standing brief. + synced copy | Appears exactly once; task row renders from the loop record |
Integration Tests
| ID | Description | Preconditions | Expected Outcome |
|---|---|---|---|
| TC-17 | Full watcher→sync→blob lifecycle against pgliteEmbedded WASM Postgres by ElectricSQL + in-memory store | Seeded loop folder with changes | Files sync, bytes stored, artifact_files consistent |
| TC-18 | BlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. PUT body cap (32MB SYNC_BODY_CAP) |
Oversized POST | 413, no partial write |
Edge Cases and Failure Scenarios
| ID | Scenario | Expected Behavior |
|---|---|---|
| TC-19 | Inline burst over the 1MB aggregate budget | Overflow takes the PUT path; no 413 |
| TC-20 | First flush after watcher start | Inlines nothing (server already has almost everything) |
| TC-21 | SVG artifact | Never inlined into the app DOM; served via hardened inline route |
| TC-22 | HTML artifact with a script | Runs in a strict sandboxed opaque-origin iframe; can't reach parent/cookies |
Test Infrastructure
- vitest; server tests use the shared in-memory blob store; integration tests use real pgliteEmbedded WASM Postgres by ElectricSQL.
gatewayWithStoremirrors the production shared-store wiring for retention tests.- jsdom + DOMPurify for dashboard panel rendering; Recharts mounted under
actwith a ResizeObserver stub.
Coverage Matrix
| Requirement | Test Cases |
|---|---|
| FR-1MustThe daemon shall watch each loop folder and build a full sha256 manifest with incremental hashing (stat-cache, racy-write guard). | TC-1 |
| FR-2MustThe daemon shall POST the manifest to `/api/machine/sync`, upload only the hashes the server requests via `PUT /api/machine/blob/:hash`, and verify the hash server-side. | TC-4, TC-5 |
| FR-3MustThe server shall store blob bytes in a content-addressed store (R2Cloudflare R2 object storage when configured, in-memory otherwise) keyed by sha256, with metadata rows in `blobs` and current file state in `artifact_files`. | TC-17 |
| FR-4MustThe server shall enforce per-file (10MB) and per-loop (500MB) byte caps, marking oversized files metadata-only, and honor the never-syncable dir list. | TC-2, TC-6 |
| FR-5MustThe daemon shall bound every sync to per-loop file-count and byte ceilings, keeping the top-level content home and dropping overflow with one loud warning. | TC-3 |
| FR-6MustMarkdown products with front-matter `type`/`title`/`date` shall be indexed once at byte ingress and rendered as generative dashboard panels. | TC-7, TC-8 |
| FR-7MustThe dashboard shall render custom panels (`loop-embed`/`loop-calendar`/`loop-kanban`) plus charts from numeric run state, sanitized against stored XSS. | TC-14 |
| FR-8MustRun snapshots shall capture the manifest at report, and the run page shall diff run N against the prior snapshot. | TC-9 |
| FR-9MustRetention/GCGarbage collection shall prune snapshots (keep 20), unpin old blobs, honor a grace window, re-check referencedness, and delete bytes before metadata. | TC-10, TC-11 |
| FR-10ShouldHTML artifacts shall render in a strict sandboxed iframe (never same-origin); images (incl. SVG) render via a hardened inline route. | TC-12, TC-21, TC-22 |
| FR-11ShouldThe task file shall appear exactly once in the files panel and render from the loop record's content, not a blob fetch. | TC-16 |
| FR-12ShouldThe dashboard grid shall cap at two columns, with only custom panels tiling and content blocks spanning full width. | TC-15 |
| NFR-1MustThe never-syncable dir list and caps must be enforced identically on daemon and server to keep the two in sync. | TC-2 |
| NFR-2MustInline image serving must set `X-Content-Type-Options: nosniff` and `Content-Security-Policy: sandbox`. | TC-12 |
| NFR-3MustUnchanged files must never be re-read (incremental hashing); a digest match skips the network entirely. | TC-1 |
| NFR-4MustA sync burst must never 413 the server's body cap (inline blob budget + overflow to the PUT path). | TC-19 |
| NFR-5ShouldBlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. PUTs shall run bounded-concurrent (4); inline blobs budgeted 1MB aggregate per POST. | TC-19 |
| NFR-6ShouldBlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. GCGarbage collection must bias to keep: a leaked blob is a cost bug, a wrong delete is data loss. | TC-11 |
requirements
- None: behavior is fully determined by the code and its tests.
Vocabulary
Domain Terms
| Term | Definition |
|---|---|
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. | A scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. |
| Loop folderThe on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. | The on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. |
| Open loopA loop with `goal = null` that runs indefinitely (monitor/digest); it never self-terminates. | A loop with goal = null that runs indefinitely (monitor/digest); it never self-terminates. |
| Closed loopA loop with a non-null `goal`; each exec run judges state against the goal and may call `loopany finish` when met, stamping `completedAt` and disabling the loop. | A loop with a non-null goal; each exec run judges state against the goal and may call loopany finish when met, stamping completedAt and disabling the loop. |
| Exec runA scheduled execution of a loop (role `exec`); only exec runs produce user-facing notifications. | A scheduled execution of a loop (role exec); only exec runs produce user-facing notifications. |
| Evolve runA self-improvement pass (role `evolve`) that reviews run history and rewrites the loop's brief, state schema, and dashboard. | A self-improvement pass (role evolve) that reviews run history and rewrites the loop's brief, state schema, and dashboard. |
| Edit runAn owner-requested change (role `edit`) that applies an instruction to the loop, then clears the request. | An owner-requested change (role edit) that applies an instruction to the loop, then clears the request. |
| Run token / run credentialThe per-run lease credential (`rk_…`) authorizing in-run `loopany` verbs and the final report. | The per-run lease credential (rk_…) authorizing in-run loopany verbs and the final report. |
| Device tokenThe `dk_`-prefixed credential that fully impersonates a machine for owner verbs and polling. | The dk_-prefixed credential that fully impersonates a machine for owner verbs and polling. |
| Connect keyA one-time claim token minted by the dashboard that binds a newly connecting machine to its owner and team. | A one-time claim token minted by the dashboard that binds a newly connecting machine to its owner and team. |
| Task fileThe loop's durable context+log document on the machine; its `## Spec` section is the standing brief. | The loop's durable context+log document on the machine; its ## Spec section is the standing brief. |
| WorkflowAn optional zero-LLM async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. | An optional zero-LLMLarge Language Model async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. |
| Generative dashboardLoop-authored `ui` markup (with `loop-embed`/`loop-calendar`/`loop-kanban` primitives) rendered by the web UI from synced front-matter artifacts. | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal.-authored ui markup (with loop-embed/loop-calendar/loop-kanban primitives) rendered by the web UIUser interface from synced front-matter artifacts. |
| Front matterAn optional fenced `---` block of flat `key: value` scalars at the top of a markdown product; the indexed subset `{type?, title?, date?}` is parsed once at byte ingress. | An optional fenced --- block of flat key: value scalars at the top of a markdown product; the indexed subset {type?, title?, date?} is parsed once at byte ingress. |
| TemplateA canned loop intent (meta.json `description` paste-prompt, optional `reference.md`, `thumb.svg`, `story.md`, flow spec) under `src/skill/templates/`. | A canned loop intent (meta.json description paste-prompt, optional reference.md, thumb.svg, story.md, flow spec) under src/skill/templates/. |
| BundleA curated category of templates under `src/skill/bundles/`; every template belongs to exactly one bundle. | A curated category of templates under src/skill/bundles/; every template belongs to exactly one bundle. |
| MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. | A teammate's daemon; the identity unit that owns loops and scopes the dashboard. |
| TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. | The ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. |
Technical Terms
| Term | Definition |
|---|---|
| Pending runA run row in phase `pending`; it is the durable inbox a machine's poll claims. | A run row in phase pending; it is the durable inbox a machine's poll claims. |
| Run leaseA durable row (`run_leases`) minted per delivery holding per-run caps; state machine `active` → `terminal-grace` → retired. | A durable row (run_leases) minted per delivery holding per-run caps; state machine active → terminal-grace → retired. |
| Terminal-graceA swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. | A swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. |
| Long-pollThe opt-in server-held poll (`wait:true`, ~20s) an idle daemon uses for near-zero dispatch latency. | The opt-in server-held poll (wait:true, ~20s) an idle daemon uses for near-zero dispatch latency. |
| Watch set / watchDigestThe per-machine cache of loop folders to watch, digest-echoed so an unchanged client omits the payload. | The per-machine cache of loop folders to watch, digest-echoed so an unchanged client omits the payload. |
| ManifestThe full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. | The full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. |
| BlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. | Content-addressed bytes keyed by sha256 hash, stored in R2Cloudflare R2 object storage or in-memory; referenced by blobs/artifact_files rows. |
| OversizeA file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. | A file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. |
| Run snapshotThe loop's full artifact manifest captured at each run's finalize, diffed against the prior run for the run page. | The loop's full artifact manifest captured at each run's finalize, diffed against the prior run for the run page. |
| SweepThe periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GC. | The periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GCGarbage collection. |
| Circuit breaker`notifyRunFailure` auto-pause of a loop after a configurable consecutive-exec-failure streak. | notifyRunFailure auto-pause of a loop after a configurable consecutive-exec-failure streak. |
| Misfire catch-upBoot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. | Boot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. |
| TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. | The axi-shaped text output rendered by gateway/toon.ts for every /api/machine/cli verb. |
| BYOABring-Your-Own-Agent | Bring-your-own-agent: execution runs with the user's own coding agent and credentials on their own machine. |
Acronyms and Abbreviations
| Abbreviation | Expansion |
|---|---|
| BYOABring-Your-Own-Agent | Bring-Your-Own-Agent |
| R2Cloudflare R2 object storage | Cloudflare R2Cloudflare R2 object storage object storage |
| TTLTime-to-live | Time-to-live |
| GCGarbage collection | Garbage collection |
| LLMLarge Language Model | Large Language Model |
| WSWebSocket | WebSocket |
| UIUser interface | User interface |
| PRPull Request | Pull Request |
| npm OIDCnpm OpenID Connect trusted publishing | npm OpenID Connect trusted publishing |
| Fly / Fly.ioFly.io app hosting | Fly.io app hosting |
| pgliteEmbedded WASM Postgres by ElectricSQL | Embedded WASM Postgres by ElectricSQL |
| MCPModel Context Protocol | Model Context Protocol |
Pipeline Progress ?⚪ not started not yet begun✏️ draft initial version🔍 in review under review🚧 in progress actively worked on⛔ blocked waiting on dependency✅ done completed⏭️ skipped not applicable
Requirements: Web Dashboard, Teams & Run UIUser interface
Overview
The web dashboard is the team surface for Loopany: a TanStack Start application where users see their loops, machines, runs, and timeline, manage teams, and drill into loop and run detail. The team lives in the URL (/t/$teamId) with membership-validated scoping, so tabs on different teams can be open at once. LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. detail supports editing (dispatch a run or copy an agent-neutral prompt), run detail shows transcripts, costs, artifacts, and a live activity card, and the cross-loop timeline projects future fires. This feature also covers the shared compose modal and team/machine management surfaces.
Stakeholders
| Stakeholder | Interest |
|---|---|
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. owner | A dashboard where loops, runs, artifacts, and notifications are visible and actionable. |
| TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. members | See a shared team surface (loops, timeline, machines, channels) without operating machines. |
| TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. owner | Manage membership, roles, invites, and team deletion with safe guards (delete blocked while the team owns loops). |
Functional Requirements
Order rows by priority: Must first, then Should, then May.
| ID | Priority | Requirement |
|---|---|---|
| FR-1MustThe dashboard's team shall live in the URL (`/t/$teamId`), with list server fns taking an explicit validated `teamId` (route param wins over the cookie). | Must | The dashboard's team shall live in the URL (/t/$teamId), with list server fns taking an explicit validated teamId (route param wins over the cookie). |
| FR-2MustBare `/` in gated mode shall redirect to the last-used or personal team; a non-member `/t/<x>` is a generic not-found (enumeration-safe). | Must | Bare / in gated mode shall redirect to the last-used or personal team; a non-member /t/<x> is a generic not-found (enumeration-safe). |
| FR-3MustThe dashboard shall list jobs, machines, and teams with a fetch-then-set poll refresh that keeps stale data on a transient blip. | Must | The dashboard shall list jobs, machines, and teams with a fetch-then-set poll refresh that keeps stale data on a transient blip. |
| FR-4MustLoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. detail shall render the loop's generative dashboard, runs list, files panel, and controls (enable/pause, run now, evolve, edit, delete). | Must | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. detail shall render the loop's generative dashboard, runs list, files panel, and controls (enable/pause, run now, evolve, edit, delete). |
| FR-5MustRun detail shall show transcript, metrics, cost, artifacts, diff, status, and a live activity card while running. | Must | Run detail shall show transcript, metrics, cost, artifacts, diff, status, and a live activity card while running. |
| FR-6MustLoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. editing shall offer two paths: Dispatch (one agent pass on the owner's machine) and Copy prompt (agent-neutral, built by a pure helper). | Must | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. editing shall offer two paths: Dispatch (one agent pass on the owner's machine) and Copy prompt (agent-neutral, built by a pure helper). |
| FR-7MustThe cross-loop timeline shall be one form at every zoom (row = loop, x = time), with future fires projected from the loop's cron. | Must | The cross-loop timeline shall be one form at every zoom (row = loop, x = time), with future fires projected from the loop's cron. |
| FR-8MustTeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. management shall be owner-only for membership changes and support rename, add-by-email, single-use invite links, role changes, leave, and delete. | Must | TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. management shall be owner-only for membership changes and support rename, add-by-email, single-use invite links, role changes, leave, and delete. |
| FR-9MustDelete shall be blocked while a team owns loops, and the last-owner guard shall be enforced transactionally. | Must | Delete shall be blocked while a team owns loops, and the last-owner guard shall be enforced transactionally. |
| FR-10ShouldThe compose modal shall handle all three shapes (blank, template, bundle) sharing one connect-key machinery. | Should | The compose modal shall handle all three shapes (blank, template, bundle) sharing one connect-key machinery. |
| FR-11ShouldNo page shall have horizontal scroll; wide content scrolls inside its own pane. | Should | No page shall have horizontal scroll; wide content scrolls inside its own pane. |
| FR-12ShouldThe files panel shall show the task file exactly once with type/title chips from front matter. | Should | The files panel shall show the task file exactly once with type/title chips from front matter. |
Non-Functional Requirements
Order rows by priority: Must first, then Should, then May.
| ID | Priority | Category | Requirement |
|---|---|---|---|
| NFR-1MustEvery team fn shall take an explicit teamId and authorize by membership + role, never the active-team cookie. | Must | Security | Every team fn shall take an explicit teamId and authorize by membership + role, never the active-team cookie. |
| NFR-2MustThe last-owner guard shall be transactional so concurrent self-removals cannot strand a memberless team. | Must | Security | The last-owner guard shall be transactional so concurrent self-removals cannot strand a memberless team. |
| NFR-3MustPrefers-reduced-motion disables decorative animation; Recharts animation is off including tooltips. | Must | Accessibility | Prefers-reduced-motion disables decorative animation; Recharts animation is off including tooltips. |
| NFR-4ShouldRecharts stays out of the base client bundle (loop detail lazy-loads the chunk). | Should | Performance | Recharts stays out of the base client bundle (loop detail lazy-loads the chunk). |
| NFR-5ShouldSource-reading test guards must keep the path in a variable (vite rewrites the literal `new URL` form). | Should | Reliability | Source-reading test guards must keep the path in a variable (vite rewrites the literal new URL form). |
Constraints
- LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. detail and run detail are pages, not modals; Base UIUser interface Dialog parts require a
Dialog.Rootancestor. - The dashboard's team comes from the URL param; the cookie is only the bare-
/redirect hint, never an auth key. - Generic operation copy is agent-neutral, never "Claude Code", because Loopany runs multiple agents.
Acceptance Criteria
Every FR and NFR shall have at least one acceptance criterion.
- FR-1MustThe dashboard's team shall live in the URL (`/t/$teamId`), with list server fns taking an explicit validated `teamId` (route param wins over the cookie).
- Given a signed-in member on
/t/Aand/t/B - When tabs load
- Then each shows its own team's loops and a team switch re-seeds the poll state
- Given a signed-in member on
- FR-2MustBare `/` in gated mode shall redirect to the last-used or personal team; a non-member `/t/<x>` is a generic not-found (enumeration-safe).
- Given a signed-out visitor on a non-member
/t/<x> - When the route loads
- Then it throws the same generic not-found as a missing loop
- Given a signed-out visitor on a non-member
- FR-3MustThe dashboard shall list jobs, machines, and teams with a fetch-then-set poll refresh that keeps stale data on a transient blip.
- Given a transient poll blip
- When the dashboard refreshes
- Then stale data is kept, never an invalidate-throw
- FR-4MustLoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. detail shall render the loop's generative dashboard, runs list, files panel, and controls (enable/pause, run now, evolve, edit, delete).
- Given a loop with a dashboard, files, and runs
- When its detail page loads
- Then all surfaces render and controls are available
- FR-5MustRun detail shall show transcript, metrics, cost, artifacts, diff, status, and a live activity card while running.
- Given a running run
- When its detail page is open
- Then the live activity card shows the pulsing step line and a ticking elapsed clock
- FR-6MustLoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. editing shall offer two paths: Dispatch (one agent pass on the owner's machine) and Copy prompt (agent-neutral, built by a pure helper).
- Given the loop edit composer
- When "Copy prompt" is chosen
- Then a pure, agent-neutral prompt is copied with the loop's on-disk dir named when derivable
- FR-7MustThe cross-loop timeline shall be one form at every zoom (row = loop, x = time), with future fires projected from the loop's cron.
- Given a loop with a daily cron
- When the timeline renders
- Then future fires project as dashed ghosts past the now-line
- FR-8MustTeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. management shall be owner-only for membership changes and support rename, add-by-email, single-use invite links, role changes, leave, and delete.
- Given an owner managing a team
- When they mint an invite link
- Then a single-use, 7-day link is redeemable by any signed-in user
- FR-9MustDelete shall be blocked while a team owns loops, and the last-owner guard shall be enforced transactionally.
- Given a team that owns loops
- When delete is attempted
- Then it is blocked with a disabled state, never cascaded
- NFR-1MustEvery team fn shall take an explicit teamId and authorize by membership + role, never the active-team cookie.
- Given a member browsing team A
- When they manage team B
- Then authorization checks the explicit teamId, never the cookie
- NFR-2MustThe last-owner guard shall be transactional so concurrent self-removals cannot strand a memberless team.
- Given two concurrent self-removals of the last two owners
- When both commit
- Then exactly one wins; the team never ends up memberless
Conflicts
None identified yet.
Open Questions
- None: behavior is fully determined by the code and its tests.
Specification: Web Dashboard, Teams & Run UIUser interface
Overview
The web app is a TanStack Start application (React 19, Tailwind v4, Base UIUser interface, Recharts, CodeMirror). Server functions (src/server/loopApi.ts) drive the dashboard; team logic lives in framework-free src/server/teamAdmin.ts with a thin teamFns.ts RPC wrapper. Routes render the dashboard under /t/$teamId (plus open-mode /), loop detail under /loops/$loopId, run detail under /loops/$loopId/runs/$runId, and the cross-loop timeline under /t/$teamId/timeline.
Architecture
/t/$teamId → DashboardView (mounts with key=teamId → fetch-then-set poll)
├─ listJobs / listMachines / listMyTeams (explicit teamId, requestScope)
├─ BundleCarousel → ComposeModal (blank | template | bundle)
└─ TeamsModal / MachinesModal / NotificationsModal
/loops/$loopId → LoopDetailView (LoopView dashboard chunk lazy-loaded)
/loops/$loopId/runs/$runId → RunView (LiveActivity while run.running)
/t/$teamId/timeline → LoopTimeline (projectFires + run query, row=loop, x=time)
TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. authorization: every fn takes an explicit teamId and checks membership + role via assertOwner (single chokepoint); non-members get the enumeration-safe generic not-found.
Data Models
The UIUser interface consumes Drizzle rows through adapters.ts (LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal./Run → JobSummary/JobDetail):
teams+team_members(rolesowner/member) +team_invites(single-use, 7-day, role-baked).machines(presence vialib/machinePresence.ts: online <30s, asleep <6h, else offline).loops(vialoopApiadapters incl. the derivednextFire/classification/runs).runs(via run detail adapters: transcript, usage, cost, artifacts, progress).
API Contracts
The UIUser interface consumes server functions, not REST: listJobs(teamId), listMachines(teamId), listMyTeams(), listTimeline(teamId, range), mintClaim, claimStatus, createChannel, testChannel, requestEdit, copyEditPrompt (pure client helper), getJobDetail, getArtifact, and firstRunStatus. TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. management RPCs (createTeam, renameTeam, addMemberByEmail, createInvite, redeemTeamInvite, setMemberRole, removeMember, leaveTeam, deleteTeam) route through teamFns.ts → teamAdmin.ts.
Sequences
TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. invite redeem
owner: TeamsModal → createInvite → POST (token, role baked) → share /invite/<token>
recipient (signed-in) → GET /invite/<token> → redeemTeamInvite
├─ invalid / already-used (redeemedAt stamped) / expired → error
├─ already-member → success (link burned, no double-add)
└─ fresh join at the invite's role
signed-out visitor → gated SignIn with callbackURL back to the invite
LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. edit dispatch
LoopDetailView editVia:
(1) Dispatch → requestEdit({id, instruction}) → scheduler.requestEdit → next tick runs an edit run
(2) Copy prompt → buildEditPrompt (pure) → clipboards a self-contained prompt
Technical Decisions
| Decision | Choice | Rationale |
|---|---|---|
| TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. in the URL | /t/$teamId param, cookie is last-used hint only |
Different teams in different tabs; route param wins over cookie |
| Poll refresh | Fetch-then-set, never router.invalidate |
The loader re-run throws on a transient blip; keep stale data |
| TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. logic layer | teamAdmin.ts framework-free, teamFns.ts thin RPC |
Every rule testable against real pgliteEmbedded WASM Postgres by ElectricSQL without mocking the Start runtime |
| Delete safety | Blocked while the team owns loops; last-owner transactional | Never strand a memberless team or orphan loops |
| Timeline | Row = loop, x = time, zoom changes only the window; future fires projected | A run is a point event, so Gantt bars have no length; lanes line up vertically on contention |
| Agent-neutral copy | "your coding agent", not "Claude Code" | Loopany runs claude-code, codex, and grok |
| No page-level h-scroll | min-w-0 on grid/flex children; panes scroll internally |
Pinned by *.regression.test.ts guards |
Risks and Unknowns
- Dashboard header still overflows below ~690px (pre-existing, not the content grid).
- The timeline projection is capped per loop and the run query is capped, surfaced as
truncatedwhen exceeded.
Out of Scope
- The onboarding wizard and first-run flow (separate feature).
- The template market pages (separate feature).
- Notification channel binding UIUser interface internals (notifications feature owns the shared
ChannelAddForm).
Test Plan: Web Dashboard, Teams & Run UIUser interface
Scope
Testing the dashboard/team/run UIUser interface surfaces: team scoping and CRUD, dashboard poll refresh, loop and run detail pages, live activity card, timeline, compose modal, and the no-horizontal-scroll regression guards. Out of scope: onboarding wizard, template market, and notification binding internals (separate features).
Unit Tests
| ID | Description | Input | Expected Output |
|---|---|---|---|
| TC-1 | requestScope prefers the explicit teamId over the cookie |
Route param vs cookie value | Route param wins |
| TC-2 | Non-member team scope is a generic not-found | Non-member /t/<x> |
Same not-found as a missing loop (enumeration-safe) |
| TC-3 | TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. CRUD authorizes by membership + role | Member vs owner action | Owner-only controls; server re-authorizes regardless |
| TC-4 | Last-owner guard is transactional | Concurrent self-removals | One wins; team never memberless |
| TC-5 | Delete is blocked while the team owns loops | TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. with loops, delete attempt | countLoopsForTeam blocks; delete never cascades |
| TC-6 | Invite redeem outcomes | invalid / used / expired / already-member / fresh | Correct per-branch result; single-use stamping |
| TC-7 | Dashboard poll keeps stale data on a blip | Transient listJobs failure | Stale data kept, no loader throw |
| TC-8 | Run detail live activity renders only while running | run.running true vs false |
LiveActivity mounted only for running; terminal pages byte-identical |
| TC-9 | Copy-prompt is agent-neutral and pure | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. with a derivable on-disk dir | Prompt names the loop dir; generic copy names no vendor agent |
| TC-10 | Timeline is one form at every zoom | day / week / month | Only the window changes; lane = loop, marks = runs |
| TC-11 | Timeline projects future fires | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. with a daily cron | Dashed ghosts past the now-line, capped per loop |
| TC-12 | Compose modal handles all three shapes | blank / template / bundle | One modal; template/bundle snippets share connect-key machinery |
| TC-13 | MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. presence is three-state | lastSeen deltas | online <30s / asleep <6h / offline |
| TC-14 | Files panel dedups the task file and shows front-matter chips | Synced task file + products | Task fileThe loop's durable context+log document on the machine; its `## Spec` section is the standing brief. once with TASK treatment; type/title chips for products |
Integration Tests
| ID | Description | Preconditions | Expected Outcome |
|---|---|---|---|
| TC-15 | TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. CRUD end-to-end against pgliteEmbedded WASM Postgres by ElectricSQL | Real pgliteEmbedded WASM Postgres by ElectricSQL store, seeded users/teams | 15 scenarios pass (create, invite, role change, leave, delete guards) |
| TC-16 | TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. URL scope integration | Teams A/B with distinct data | /t/A and /t/B show different teams; switch re-seeds state |
| TC-17 | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. form model persistence | LoopForm state | Model persists across edits |
Edge Cases and Failure Scenarios
| ID | Scenario | Expected Behavior |
|---|---|---|
| TC-18 | Page-level horizontal scroll regression | *.regression.test.ts guards stay green (grid/flex min-w-0) |
| TC-19 | Recharts animation flash | All animation off including <Tooltip isAnimationActive={false}> |
| TC-20 | Cross-team loop detail access | Teammate can't see another team's loop internals beyond scope |
| TC-21 | Timeline beyond the run-query cap | truncated surfaced, never silently clipped |
Test Infrastructure
- vitest with jsdom; client renders under
act; Recharts mounts via effects with a jsdom ResizeObserver stub. - Integration tests run against real pgliteEmbedded WASM Postgres by ElectricSQL (
teamCrud.integration.test.ts,teamUrlScope.integration.test.ts). - Source-reading guards keep paths in a variable (vite rewrites the literal
new URLform).
Coverage Matrix
| Requirement | Test Cases |
|---|---|
| FR-1MustThe dashboard's team shall live in the URL (`/t/$teamId`), with list server fns taking an explicit validated `teamId` (route param wins over the cookie). | TC-1, TC-16 |
| FR-2MustBare `/` in gated mode shall redirect to the last-used or personal team; a non-member `/t/<x>` is a generic not-found (enumeration-safe). | TC-2 |
| FR-3MustThe dashboard shall list jobs, machines, and teams with a fetch-then-set poll refresh that keeps stale data on a transient blip. | TC-7 |
| FR-4MustLoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. detail shall render the loop's generative dashboard, runs list, files panel, and controls (enable/pause, run now, evolve, edit, delete). | TC-12 |
| FR-5MustRun detail shall show transcript, metrics, cost, artifacts, diff, status, and a live activity card while running. | TC-8 |
| FR-6MustLoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. editing shall offer two paths: Dispatch (one agent pass on the owner's machine) and Copy prompt (agent-neutral, built by a pure helper). | TC-9 |
| FR-7MustThe cross-loop timeline shall be one form at every zoom (row = loop, x = time), with future fires projected from the loop's cron. | TC-10, TC-11 |
| FR-8MustTeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. management shall be owner-only for membership changes and support rename, add-by-email, single-use invite links, role changes, leave, and delete. | TC-3, TC-6 |
| FR-9MustDelete shall be blocked while a team owns loops, and the last-owner guard shall be enforced transactionally. | TC-4, TC-5 |
| FR-10ShouldThe compose modal shall handle all three shapes (blank, template, bundle) sharing one connect-key machinery. | TC-12 |
| FR-11ShouldNo page shall have horizontal scroll; wide content scrolls inside its own pane. | TC-18 |
| FR-12ShouldThe files panel shall show the task file exactly once with type/title chips from front matter. | TC-14 |
| NFR-1MustEvery team fn shall take an explicit teamId and authorize by membership + role, never the active-team cookie. | TC-3 |
| NFR-2MustThe last-owner guard shall be transactional so concurrent self-removals cannot strand a memberless team. | TC-4 |
| NFR-3MustPrefers-reduced-motion disables decorative animation; Recharts animation is off including tooltips. | TC-19 |
| NFR-4ShouldRecharts stays out of the base client bundle (loop detail lazy-loads the chunk). | lazy-loaded LoopView chunk |
| NFR-5ShouldSource-reading test guards must keep the path in a variable (vite rewrites the literal `new URL` form). | source-reading guard tests |
requirements
- None: behavior is fully determined by the code and its tests.
Vocabulary
Domain Terms
| Term | Definition |
|---|---|
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. | A scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. |
| Loop folderThe on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. | The on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. |
| Open loopA loop with `goal = null` that runs indefinitely (monitor/digest); it never self-terminates. | A loop with goal = null that runs indefinitely (monitor/digest); it never self-terminates. |
| Closed loopA loop with a non-null `goal`; each exec run judges state against the goal and may call `loopany finish` when met, stamping `completedAt` and disabling the loop. | A loop with a non-null goal; each exec run judges state against the goal and may call loopany finish when met, stamping completedAt and disabling the loop. |
| Exec runA scheduled execution of a loop (role `exec`); only exec runs produce user-facing notifications. | A scheduled execution of a loop (role exec); only exec runs produce user-facing notifications. |
| Evolve runA self-improvement pass (role `evolve`) that reviews run history and rewrites the loop's brief, state schema, and dashboard. | A self-improvement pass (role evolve) that reviews run history and rewrites the loop's brief, state schema, and dashboard. |
| Edit runAn owner-requested change (role `edit`) that applies an instruction to the loop, then clears the request. | An owner-requested change (role edit) that applies an instruction to the loop, then clears the request. |
| Run token / run credentialThe per-run lease credential (`rk_…`) authorizing in-run `loopany` verbs and the final report. | The per-run lease credential (rk_…) authorizing in-run loopany verbs and the final report. |
| Device tokenThe `dk_`-prefixed credential that fully impersonates a machine for owner verbs and polling. | The dk_-prefixed credential that fully impersonates a machine for owner verbs and polling. |
| Connect keyA one-time claim token minted by the dashboard that binds a newly connecting machine to its owner and team. | A one-time claim token minted by the dashboard that binds a newly connecting machine to its owner and team. |
| Task fileThe loop's durable context+log document on the machine; its `## Spec` section is the standing brief. | The loop's durable context+log document on the machine; its ## Spec section is the standing brief. |
| WorkflowAn optional zero-LLM async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. | An optional zero-LLMLarge Language Model async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. |
| Generative dashboardLoop-authored `ui` markup (with `loop-embed`/`loop-calendar`/`loop-kanban` primitives) rendered by the web UI from synced front-matter artifacts. | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal.-authored ui markup (with loop-embed/loop-calendar/loop-kanban primitives) rendered by the web UIUser interface from synced front-matter artifacts. |
| Front matterAn optional fenced `---` block of flat `key: value` scalars at the top of a markdown product; the indexed subset `{type?, title?, date?}` is parsed once at byte ingress. | An optional fenced --- block of flat key: value scalars at the top of a markdown product; the indexed subset {type?, title?, date?} is parsed once at byte ingress. |
| TemplateA canned loop intent (meta.json `description` paste-prompt, optional `reference.md`, `thumb.svg`, `story.md`, flow spec) under `src/skill/templates/`. | A canned loop intent (meta.json description paste-prompt, optional reference.md, thumb.svg, story.md, flow spec) under src/skill/templates/. |
| BundleA curated category of templates under `src/skill/bundles/`; every template belongs to exactly one bundle. | A curated category of templates under src/skill/bundles/; every template belongs to exactly one bundle. |
| MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. | A teammate's daemon; the identity unit that owns loops and scopes the dashboard. |
| TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. | The ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. |
Technical Terms
| Term | Definition |
|---|---|
| Pending runA run row in phase `pending`; it is the durable inbox a machine's poll claims. | A run row in phase pending; it is the durable inbox a machine's poll claims. |
| Run leaseA durable row (`run_leases`) minted per delivery holding per-run caps; state machine `active` → `terminal-grace` → retired. | A durable row (run_leases) minted per delivery holding per-run caps; state machine active → terminal-grace → retired. |
| Terminal-graceA swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. | A swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. |
| Long-pollThe opt-in server-held poll (`wait:true`, ~20s) an idle daemon uses for near-zero dispatch latency. | The opt-in server-held poll (wait:true, ~20s) an idle daemon uses for near-zero dispatch latency. |
| Watch set / watchDigestThe per-machine cache of loop folders to watch, digest-echoed so an unchanged client omits the payload. | The per-machine cache of loop folders to watch, digest-echoed so an unchanged client omits the payload. |
| ManifestThe full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. | The full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. |
| BlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. | Content-addressed bytes keyed by sha256 hash, stored in R2Cloudflare R2 object storage or in-memory; referenced by blobs/artifact_files rows. |
| OversizeA file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. | A file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. |
| Run snapshotThe loop's full artifact manifest captured at each run's finalize, diffed against the prior run for the run page. | The loop's full artifact manifest captured at each run's finalize, diffed against the prior run for the run page. |
| SweepThe periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GC. | The periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GCGarbage collection. |
| Circuit breaker`notifyRunFailure` auto-pause of a loop after a configurable consecutive-exec-failure streak. | notifyRunFailure auto-pause of a loop after a configurable consecutive-exec-failure streak. |
| Misfire catch-upBoot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. | Boot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. |
| TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. | The axi-shaped text output rendered by gateway/toon.ts for every /api/machine/cli verb. |
| BYOABring-Your-Own-Agent | Bring-your-own-agent: execution runs with the user's own coding agent and credentials on their own machine. |
Acronyms and Abbreviations
| Abbreviation | Expansion |
|---|---|
| BYOABring-Your-Own-Agent | Bring-Your-Own-Agent |
| R2Cloudflare R2 object storage | Cloudflare R2Cloudflare R2 object storage object storage |
| TTLTime-to-live | Time-to-live |
| GCGarbage collection | Garbage collection |
| LLMLarge Language Model | Large Language Model |
| WSWebSocket | WebSocket |
| UIUser interface | User interface |
| PRPull Request | Pull Request |
| npm OIDCnpm OpenID Connect trusted publishing | npm OpenID Connect trusted publishing |
| Fly / Fly.ioFly.io app hosting | Fly.io app hosting |
| pgliteEmbedded WASM Postgres by ElectricSQL | Embedded WASM Postgres by ElectricSQL |
| MCPModel Context Protocol | Model Context Protocol |
Pipeline Progress ?⚪ not started not yet begun✏️ draft initial version🔍 in review under review🚧 in progress actively worked on⛔ blocked waiting on dependency✅ done completed⏭️ skipped not applicable
Requirements: Onboarding & First-Run Experience
Overview
The onboarding experience guides a brand-new user from an empty workspace to their first loop's first run. It is its own route (/onboarding), never a homepage overlay, and advances only on detected reality: the machine step reuses the real createMachine/machineStatus, the create step reuses the real mintClaim/claimStatus with the Housekeeper template, and a final live step folds the celebration, first-run wait, and notification binding. The live creation checklist shows agent-reported milestones lighting up, and a dev-only simulation lets the whole flow be clicked locally without a second machine.
Stakeholders
| Stakeholder | Interest |
|---|---|
| New user | A guided, skippable walk from sign-in to a real running loop, with honest states (never a spinner-trap). |
| Product | One onboarding surface that mirrors how a loop is really born, reusing production machinery so it never drifts. |
| Developer | A dev-only simulation gate so the flow is clickable locally without a second machine or GitHub OAuth. |
Functional Requirements
Order rows by priority: Must first, then Should, then May.
| ID | Priority | Requirement |
|---|---|---|
| FR-1MustOnboarding shall be its own route, entered once for a fully empty workspace (no loops and no machines) and re-openable as a quiet banner while the user has no loops. | Must | Onboarding shall be its own route, entered once for a fully empty workspace (no loops and no machines) and re-openable as a quiet banner while the user has no loops. |
| FR-2MustThe route shall carry the dashboard's team as `?team=<id>` and validate it with the same `canViewTeam` gate as `/t/$teamId`. | Must | The route shall carry the dashboard's team as ?team=<id> and validate it with the same canViewTeam gate as /t/$teamId. |
| FR-3MustThe machine step shall reuse `createMachine`/`machineStatus` and gate Continue on `.online`. | Must | The machine step shall reuse createMachine/machineStatus and gate Continue on .online. |
| FR-4MustThe create step shall reuse `mintClaim`/`claimStatus` with the Housekeeper template `description`, auto-advancing on `.done`. | Must | The create step shall reuse mintClaim/claimStatus with the Housekeeper template description, auto-advancing on .done. |
| FR-5MustThe wizard shall persist step + minted tokens per team via a pure state module so a mid-flow reload resumes. | Must | The wizard shall persist step + minted tokens per team via a pure state module so a mid-flow reload resumes. |
| FR-6MustThe "Meet Housekeeper" step shall play a four-act spring-physics storyboard driven by one elapsed-ms clock, with hover pause and a reduced-motion stills path. | Must | The "Meet Housekeeper" step shall play a four-act spring-physics storyboard driven by one elapsed-ms clock, with hover pause and a reduced-motion stills path. |
| FR-7MustThe creation checklist shall show agent-reported milestones lighting up, derived from a fixed enum tolerant of skipped/repeated/out-of-order/absent/junk reports. | Must | The creation checklist shall show agent-reported milestones lighting up, derived from a fixed enum tolerant of skipped/repeated/out-of-order/absent/junk reports. |
| FR-8MustThe `live` step shall poll `firstRunStatus` (done → payoff, error → honest failed hand-off, running → live, pending+offline/canceled → scheduled hand-off) and settle only after consecutive scheduled reads. | Must | The live step shall poll firstRunStatus (done → payoff, error → honest failed hand-off, running → live, pending+offline/canceled → scheduled hand-off) and settle only after consecutive scheduled reads. |
| FR-9MustThe `live` step shall offer notification binding via the shared `ChannelAddForm`, fully optional with dashboard/see-result always available. | Must | The live step shall offer notification binding via the shared ChannelAddForm, fully optional with dashboard/see-result always available. |
| FR-10ShouldA dev-only sim (production build never exposes it) shall simulate machine connect and loop creation through the same store rows a real daemon would write. | Should | A dev-only sim (production build never exposes it) shall simulate machine connect and loop creation through the same store rows a real daemon would write. |
Non-Functional Requirements
Order rows by priority: Must first, then Should, then May.
| ID | Priority | Category | Requirement |
|---|---|---|---|
| NFR-1MustThe checklist's claim-progress endpoint shall be enum-only with a body cap and IP flood guard before the body read. | Must | Reliability | The checklist's claim-progress endpoint shall be enum-only with a body cap and IP flood guard before the body read. |
| NFR-2Must`prefers-reduced-motion` renders a separate 4-frame static-stills path; the storyboard is decorative and never gates Continue. | Must | Accessibility | prefers-reduced-motion renders a separate 4-frame static-stills path; the storyboard is decorative and never gates Continue. |
| NFR-3ShouldThe milestone progress map shall be bounded/TTLTime-to-live'd in memory, keyed by the claim token. | Should | Performance | The milestone progress map shall be bounded/TTLTime-to-live'd in memory, keyed by the claim token. |
| NFR-4ShouldThe first-run poll shall never trigger a run; it only reads run rows. | Should | Reliability | The first-run poll shall never trigger a run; it only reads run rows. |
Constraints
- The wizard must not claim a Next without detected reality.
- The checklist is a single shared surface used by both the wizard and the dashboard's compose modal.
- Dev-sim is gated by ONE flag (
LOOPANY_ONBOARDING_SIMtruthy AND not a production build) enforced on both affordance and effect.
Acceptance Criteria
Every FR and NFR shall have at least one acceptance criterion.
- FR-1MustOnboarding shall be its own route, entered once for a fully empty workspace (no loops and no machines) and re-openable as a quiet banner while the user has no loops.
- Given an empty workspace
- When the dashboard loads
- Then the wizard auto-starts once and a back navigation cannot re-trigger it
- FR-2MustThe route shall carry the dashboard's team as `?team=<id>` and validate it with the same `canViewTeam` gate as `/t/$teamId`.
- Given a bookmarked
/t/<B>deep link - When onboarding starts
- Then the machine+claim mint into team B, never the last-used cookie team
- Given a bookmarked
- FR-3MustThe machine step shall reuse `createMachine`/`machineStatus` and gate Continue on `.online`.
- Given the machine step with the daemon not yet connected
- When Continue is attempted
- Then it stays gated until
.onlineis detected
- FR-4MustThe create step shall reuse `mintClaim`/`claimStatus` with the Housekeeper template `description`, auto-advancing on `.done`.
- Given a pasted connect snippet
- When the claim resolves
.done - Then the wizard auto-advances to the live step
- FR-5MustThe wizard shall persist step + minted tokens per team via a pure state module so a mid-flow reload resumes.
- Given a mid-flow reload
- When the wizard reopens
- Then it resumes at the persisted step and tokens
- FR-6MustThe "Meet Housekeeper" step shall play a four-act spring-physics storyboard driven by one elapsed-ms clock, with hover pause and a reduced-motion stills path.
- Given the cinematic playing
- When hovered or reduced-motion is set
- Then it pauses or renders the static stills;
data-act/hk-score/hk-dayhooks reflect the state
- FR-7MustThe creation checklist shall show agent-reported milestones lighting up, derived from a fixed enum tolerant of skipped/repeated/out-of-order/absent/junk reports.
- Given agent milestone reports
- When the checklist derives state
- Then milestones light up from the highest reported index, tolerant of junk
- FR-8MustThe `live` step shall poll `firstRunStatus` (done → payoff, error → honest failed hand-off, running → live, pending+offline/canceled → scheduled hand-off) and settle only after consecutive scheduled reads.
- Given a pending+offline first run
- When the live step polls
- Then it settles into the queued hand-off only after consecutive scheduled reads
- FR-9MustThe `live` step shall offer notification binding via the shared `ChannelAddForm`, fully optional with dashboard/see-result always available.
- Given the live step
- When a channel is bound
- Then it reuses the shared
ChannelAddFormwith a livetestChannelping; binding is optional
- NFR-1MustThe checklist's claim-progress endpoint shall be enum-only with a body cap and IP flood guard before the body read.
- Given an oversized or forged claim-progress request
- When it hits the endpoint
- Then the IP flood guard + body cap run before the body is read
- NFR-3ShouldThe milestone progress map shall be bounded/TTLTime-to-live'd in memory, keyed by the claim token.
- Given a production build
- When the sim endpoints are hit
- Then both the affordance and the effect refuse
Conflicts
None identified yet.
Open Questions
- None: behavior is fully determined by the code and its tests.
Specification: Onboarding & First-Run Experience
Overview
The onboarding wizard (routes/onboarding.tsx → components/OnboardingWizard.tsx) mirrors how a loop is really born, reusing the production machinery: createMachine/machineStatus for the machine step, mintClaim/claimStatus + the Housekeeper template for the create step, and a pure branch-table first-run poll for the live step. Milestone reporting flows through POST /api/claim/progress into a bounded TTLTime-to-live'd in-memory map, and a dev-only sim writes the same store rows a real daemon would.
Architecture
/t?team=<id> → DashboardView → OnboardingEntry
└─ empty workspace (no loops + no machines) → auto-start wizard once
└─ /onboarding?team=<id> → OnboardingWizard
Step: Meet Housekeeper (cinematic) → MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. (createMachine/.online)
→ Create (mintClaim/claimStatus + Housekeeper description + CreationChecklist)
→ Live (firstRunStatus + ChannelAddForm binding)
State persists per team via lib/onboardingState.ts (pure, unit-tested). Milestones report via loopany progress <step> --connect-key <key> → POST /api/claim/progress → tokens.ts recordClaimProgress (TTLTime-to-live'd map keyed by claim token).
Data Models
The wizard writes the SAME store rows a real daemon would:
machines(viasimulateMachineConnect/createMachine→updateMachineonline).loops(viagateway.createLoopwith the claim,createLoopfiresscheduler.runNow).runs(the first run's row;firstRunStatusreads it, never triggers it).- Claim progress: in-memory TTLTime-to-live'd map keyed by the claim token (
recordClaimProgress/readClaimProgress).
API Contracts
POST /api/claim/progress
Request
| Field | Type | Required | Description |
|---|---|---|---|
| claim | string | yes | dk_-shaped claim token |
| step | string | yes | Enum from CREATION_STEPS (re-validated at storage) |
Response (200 OK)
Records the milestone. The per-IP machineRouteLimit runs BEFORE the capped body read; the per-claim bucket rides the token tier only.
Sim endpoints (dev only)
server/onboardingSim.ts simulateMachineConnect / simulateLoopCreated / simulateFirstRun / simulateNotifyBind — refuse when onboardingSimEnabled() is false (production build or LOOPANY_ONBOARDING_SIM unset).
Sequences
First-run status branch table (lib/firstRun.ts firstRunStateFrom)
poll firstRunStatus(loopId)
done → payoff (CTA to /loops/$loopId/runs/$runId)
error → failed hand-off (honest non-success copy)
running → live (keep polling)
pending+offline / canceled → scheduled hand-off,
terminal only after SCHEDULED_SETTLE_POLLS consecutive scheduled reads
Creation milestone reporting (round 8 emit path)
agent runs loopany progress <step> --connect-key <key> per milestone
→ progress-cli.ts runProgress → POST /api/claim/progress (resolves claim from snippet)
→ wizard polls claimProgress alongside claimStatus (checklist NEVER gates)
Technical Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Own route | /onboarding, never a homepage overlay |
Never restructures the dashboard |
| Detection-driven | Steps advance only on .online/.done |
Never a claimed Next; mirrors real creation |
| State persistence | lib/onboardingState.ts pure per-team |
Mid-flow reload resumes; Back can't re-trigger auto-start |
| Cinematic | Spring-physics CSS/SVG/JS, one elapsed-ms clock | No framer-motion; vitest fake timers drive it; reduced-motion stills |
| Milestone enum | lib/creationSteps.ts deriveStepStates tolerant of junk |
Keys off the highest reported index |
| Emit path | The creation SKILL, not a curl in the pasted prompt | references/create.md teaches loopany progress; the snippet stays lean |
| Shared surface | One CreationChecklist for wizard AND compose modal |
The two can't drift |
| Sim gate | onboardingSimEnabled() on both affordance and effect |
A prod build can reach neither |
Risks and Unknowns
- The wizard polls claimStatus and firstRunStatus on timers; backend restarts drop the in-memory claim-progress map (the checklist is never gating, so this only dims milestones).
simulateFirstRunseeds a finished run so the LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. page shows content; it must never run against prod.
Out of Scope
- The notification channel internals (shared
ChannelAddFormis owned by the notifications feature). - The onboarding template content itself (templates feature owns the Housekeeper card).
Test Plan: Onboarding & First-Run Experience
Scope
Testing the onboarding wizard state machine, the Housekeeper cinematic, the creation checklist, the claim-progress endpoint guards, the first-run branch table, the dev-only sim gate, and the onboarding-state persistence. Out of scope: dashboard list surfaces and notification binding internals.
Unit Tests
| ID | Description | Input | Expected Output |
|---|---|---|---|
| TC-1 | Wizard auto-starts once for an empty workspace | No loops + no machines | Wizard starts; markOnboardingDismissed set BEFORE redirect so Back can't re-trigger |
| TC-2 | Onboarding entry prefers the URL team over the cookie | ?team=<B> bookmarked |
MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard.+claim mint into team B |
| TC-3 | MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. step gates Continue on .online |
machineStatus offline |
Continue disabled until detected online |
| TC-4 | Create step auto-advances on .done |
claimStatus done |
Wizard advances without a claimed Next |
| TC-5 | Onboarding state persists per team across reloads | Mid-flow reload | Resumes at the persisted step + tokens |
| TC-6 | Creation checklist derives milestone states | Skipped/repeated/out-of-order/absent/junk reports | Highest reported index lights up; junk tolerated |
| TC-7 | First-run branch table returns each hand-off | done / error / running / pending+offline / canceled | done→payoff, error→failed (honest copy), running→live, others→scheduled |
| TC-8 | Scheduled hand-off settles only after consecutive reads | Transient pending reads then settled | SCHEDULED_SETTLE_POLLS consecutive reads before terminal |
| TC-9 | Sim gate refuses in production builds | onboardingSimEnabled() false |
Sim endpoints refuse; affordance hidden |
| TC-10 | Claim-progress is enum-only and re-validated at storage | Junk step value | Rejected; bounded TTLTime-to-live'd map keyed by claim token |
| TC-11 | Cinematic advances from one elapsed-ms clock | Hover pause, replay, finished flag | Pure function of elapsed; interval stops once finished |
| TC-12 | Cinematic reduced-motion path | prefers-reduced-motion |
Separate 4-frame static-stills path; data-act="stills" |
Edge Cases and Failure Scenarios
| ID | Scenario | Expected Behavior |
|---|---|---|
| TC-13 | First run errors | Honest non-success hand-off, never "First run complete" |
| TC-14 | Claim-progress flood/oversize | IP flood guard + body cap run before the body read |
| TC-15 | Checklist quiet period | Elapsed-aware reassurance after 25s quiet, never gating |
| TC-16 | Cinematic with the ticker guard | Reads a finished boolean, not elapsed, so it stops once finished |
| TC-17 | React synthesized enter/leave | Tests dispatch delegated mouseover/mouseout, not raw enter/leave |
Test Infrastructure
- vitest with jsdom and fake timers driving the elapsed-ms clock and poll loops.
- Dev-sim exercised through the same store rows a real daemon writes (pgliteEmbedded WASM Postgres by ElectricSQL integration).
data-act/data-testid=hk-score/hk-dayare the cinematic test hooks.
Coverage Matrix
| Requirement | Test Cases |
|---|---|
| FR-1MustOnboarding shall be its own route, entered once for a fully empty workspace (no loops and no machines) and re-openable as a quiet banner while the user has no loops. | TC-1 |
| FR-2MustThe route shall carry the dashboard's team as `?team=<id>` and validate it with the same `canViewTeam` gate as `/t/$teamId`. | TC-2 |
| FR-3MustThe machine step shall reuse `createMachine`/`machineStatus` and gate Continue on `.online`. | TC-3 |
| FR-4MustThe create step shall reuse `mintClaim`/`claimStatus` with the Housekeeper template `description`, auto-advancing on `.done`. | TC-4 |
| FR-5MustThe wizard shall persist step + minted tokens per team via a pure state module so a mid-flow reload resumes. | TC-5 |
| FR-6MustThe "Meet Housekeeper" step shall play a four-act spring-physics storyboard driven by one elapsed-ms clock, with hover pause and a reduced-motion stills path. | TC-11, TC-12, TC-16 |
| FR-7MustThe creation checklist shall show agent-reported milestones lighting up, derived from a fixed enum tolerant of skipped/repeated/out-of-order/absent/junk reports. | TC-6 |
| FR-8MustThe `live` step shall poll `firstRunStatus` (done → payoff, error → honest failed hand-off, running → live, pending+offline/canceled → scheduled hand-off) and settle only after consecutive scheduled reads. | TC-7, TC-8 |
| FR-9MustThe `live` step shall offer notification binding via the shared `ChannelAddForm`, fully optional with dashboard/see-result always available. | live-step binding shared surface (covered by ChannelAddForm integration) |
| FR-10ShouldA dev-only sim (production build never exposes it) shall simulate machine connect and loop creation through the same store rows a real daemon would write. | TC-9 |
| NFR-1MustThe checklist's claim-progress endpoint shall be enum-only with a body cap and IP flood guard before the body read. | TC-10, TC-14 |
| NFR-2Must`prefers-reduced-motion` renders a separate 4-frame static-stills path; the storyboard is decorative and never gates Continue. | TC-12 |
| NFR-3ShouldThe milestone progress map shall be bounded/TTLTime-to-live'd in memory, keyed by the claim token. | TC-10 |
| NFR-4ShouldThe first-run poll shall never trigger a run; it only reads run rows. | TC-7 (read-only poll) |
requirements
- None: behavior is fully determined by the code and its tests.
Vocabulary
Domain Terms
| Term | Definition |
|---|---|
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. | A scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. |
| Loop folderThe on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. | The on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. |
| Open loopA loop with `goal = null` that runs indefinitely (monitor/digest); it never self-terminates. | A loop with goal = null that runs indefinitely (monitor/digest); it never self-terminates. |
| Closed loopA loop with a non-null `goal`; each exec run judges state against the goal and may call `loopany finish` when met, stamping `completedAt` and disabling the loop. | A loop with a non-null goal; each exec run judges state against the goal and may call loopany finish when met, stamping completedAt and disabling the loop. |
| Exec runA scheduled execution of a loop (role `exec`); only exec runs produce user-facing notifications. | A scheduled execution of a loop (role exec); only exec runs produce user-facing notifications. |
| Evolve runA self-improvement pass (role `evolve`) that reviews run history and rewrites the loop's brief, state schema, and dashboard. | A self-improvement pass (role evolve) that reviews run history and rewrites the loop's brief, state schema, and dashboard. |
| Edit runAn owner-requested change (role `edit`) that applies an instruction to the loop, then clears the request. | An owner-requested change (role edit) that applies an instruction to the loop, then clears the request. |
| Run token / run credentialThe per-run lease credential (`rk_…`) authorizing in-run `loopany` verbs and the final report. | The per-run lease credential (rk_…) authorizing in-run loopany verbs and the final report. |
| Device tokenThe `dk_`-prefixed credential that fully impersonates a machine for owner verbs and polling. | The dk_-prefixed credential that fully impersonates a machine for owner verbs and polling. |
| Connect keyA one-time claim token minted by the dashboard that binds a newly connecting machine to its owner and team. | A one-time claim token minted by the dashboard that binds a newly connecting machine to its owner and team. |
| Task fileThe loop's durable context+log document on the machine; its `## Spec` section is the standing brief. | The loop's durable context+log document on the machine; its ## Spec section is the standing brief. |
| WorkflowAn optional zero-LLM async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. | An optional zero-LLMLarge Language Model async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. |
| Generative dashboardLoop-authored `ui` markup (with `loop-embed`/`loop-calendar`/`loop-kanban` primitives) rendered by the web UI from synced front-matter artifacts. | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal.-authored ui markup (with loop-embed/loop-calendar/loop-kanban primitives) rendered by the web UIUser interface from synced front-matter artifacts. |
| Front matterAn optional fenced `---` block of flat `key: value` scalars at the top of a markdown product; the indexed subset `{type?, title?, date?}` is parsed once at byte ingress. | An optional fenced --- block of flat key: value scalars at the top of a markdown product; the indexed subset {type?, title?, date?} is parsed once at byte ingress. |
| TemplateA canned loop intent (meta.json `description` paste-prompt, optional `reference.md`, `thumb.svg`, `story.md`, flow spec) under `src/skill/templates/`. | A canned loop intent (meta.json description paste-prompt, optional reference.md, thumb.svg, story.md, flow spec) under src/skill/templates/. |
| BundleA curated category of templates under `src/skill/bundles/`; every template belongs to exactly one bundle. | A curated category of templates under src/skill/bundles/; every template belongs to exactly one bundle. |
| MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. | A teammate's daemon; the identity unit that owns loops and scopes the dashboard. |
| TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. | The ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. |
Technical Terms
| Term | Definition |
|---|---|
| Pending runA run row in phase `pending`; it is the durable inbox a machine's poll claims. | A run row in phase pending; it is the durable inbox a machine's poll claims. |
| Run leaseA durable row (`run_leases`) minted per delivery holding per-run caps; state machine `active` → `terminal-grace` → retired. | A durable row (run_leases) minted per delivery holding per-run caps; state machine active → terminal-grace → retired. |
| Terminal-graceA swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. | A swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. |
| Long-pollThe opt-in server-held poll (`wait:true`, ~20s) an idle daemon uses for near-zero dispatch latency. | The opt-in server-held poll (wait:true, ~20s) an idle daemon uses for near-zero dispatch latency. |
| Watch set / watchDigestThe per-machine cache of loop folders to watch, digest-echoed so an unchanged client omits the payload. | The per-machine cache of loop folders to watch, digest-echoed so an unchanged client omits the payload. |
| ManifestThe full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. | The full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. |
| BlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. | Content-addressed bytes keyed by sha256 hash, stored in R2Cloudflare R2 object storage or in-memory; referenced by blobs/artifact_files rows. |
| OversizeA file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. | A file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. |
| Run snapshotThe loop's full artifact manifest captured at each run's finalize, diffed against the prior run for the run page. | The loop's full artifact manifest captured at each run's finalize, diffed against the prior run for the run page. |
| SweepThe periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GC. | The periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GCGarbage collection. |
| Circuit breaker`notifyRunFailure` auto-pause of a loop after a configurable consecutive-exec-failure streak. | notifyRunFailure auto-pause of a loop after a configurable consecutive-exec-failure streak. |
| Misfire catch-upBoot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. | Boot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. |
| TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. | The axi-shaped text output rendered by gateway/toon.ts for every /api/machine/cli verb. |
| BYOABring-Your-Own-Agent | Bring-your-own-agent: execution runs with the user's own coding agent and credentials on their own machine. |
Acronyms and Abbreviations
| Abbreviation | Expansion |
|---|---|
| BYOABring-Your-Own-Agent | Bring-Your-Own-Agent |
| R2Cloudflare R2 object storage | Cloudflare R2Cloudflare R2 object storage object storage |
| TTLTime-to-live | Time-to-live |
| GCGarbage collection | Garbage collection |
| LLMLarge Language Model | Large Language Model |
| WSWebSocket | WebSocket |
| UIUser interface | User interface |
| PRPull Request | Pull Request |
| npm OIDCnpm OpenID Connect trusted publishing | npm OpenID Connect trusted publishing |
| Fly / Fly.ioFly.io app hosting | Fly.io app hosting |
| pgliteEmbedded WASM Postgres by ElectricSQL | Embedded WASM Postgres by ElectricSQL |
| MCPModel Context Protocol | Model Context Protocol |
Pipeline Progress ?⚪ not started not yet begun✏️ draft initial version🔍 in review under review🚧 in progress actively worked on⛔ blocked waiting on dependency✅ done completed⏭️ skipped not applicable
Requirements: TemplateA canned loop intent (meta.json `description` paste-prompt, optional `reference.md`, `thumb.svg`, `story.md`, flow spec) under `src/skill/templates/`. Market & Bundles
Overview
The template market is a curated catalog of ready-to-run loops. A template is a folder under src/skill/templates/<name>/ carrying a static meta.json (whose description is the guided paste-prompt the user copies into their coding agent) plus optional reference.md, thumb.svg, story.md, and a flow spec. Bundles group templates into curated categories shown on the dashboard carousel and the public market at /templates. The market renders zero-auth public pages that SSR for crawlers and unfurlers, and the compose modal consumes the same template intent, so there is exactly one creation path.
Stakeholders
| Stakeholder | Interest |
|---|---|
| End user | Discover ready-to-run loops, compare mechanisms, and start a loop with one paste. |
| Content editor | Add a template by writing content (meta.json + optional assets) plus one bundle line; shape tests cover it automatically. |
| Product | A public, shareable catalog that deep-links into the existing single-template compose path. |
Functional Requirements
Order rows by priority: Must first, then Should, then May.
| ID | Priority | Requirement |
|---|---|---|
| FR-1MustA template shall be a folder under `skill/templates/<name>/` with a static `meta.json`; the registry builds `TEMPLATES` from an `import.meta.glob` over `meta.json`. | Must | A template shall be a folder under skill/templates/<name>/ with a static meta.json; the registry builds TEMPLATES from an import.meta.glob over meta.json. |
| FR-2MustEvery template shall belong to exactly one bundle, and a template in no bundle shall be invisible to every user-facing surface. | Must | Every template shall belong to exactly one bundle, and a template in no bundle shall be invisible to every user-facing surface. |
| FR-3MustThe market pages (`/templates`, `/templates/<slug>`) shall do zero auth checks, render logged out, and SSR real HTML for crawlers. | Must | The market pages (/templates, /templates/<slug>) shall do zero auth checks, render logged out, and SSR real HTML for crawlers. |
| FR-4MustThe detail page shall render a split layout: left = flow diagram + mechanism facts + optional field notes; right = sticky verbatim prompt + Copy. | Must | The detail page shall render a split layout: left = flow diagram + mechanism facts + optional field notes; right = sticky verbatim prompt + Copy. |
| FR-5MustThe detail CTA shall deep-link `/?template=<name>`, forwarded through the gated `/t/<team>` redirect and preserved across OAuth, reusing the existing single-template compose. | Must | The detail CTA shall deep-link /?template=<name>, forwarded through the gated /t/<team> redirect and preserved across OAuth, reusing the existing single-template compose. |
| FR-6MustThe market card shall be one shared component (with a body fed by editorial ratings) rendered by `/templates`, the dashboard teaser, and the pre-login landing. | Must | The market card shall be one shared component (with a body fed by editorial ratings) rendered by /templates, the dashboard teaser, and the pre-login landing. |
| FR-7MustThe flow spec in `lib/templateFlow.tsx` shall be ONE source drawn by both the compose modal's animated preview and the public detail page's static diagram. | Must | The flow spec in lib/templateFlow.tsx shall be ONE source drawn by both the compose modal's animated preview and the public detail page's static diagram. |
| FR-8MustAn unknown template slug shall throw `notFound()` for a real HTTP 404. | Must | An unknown template slug shall throw notFound() for a real HTTP 404. |
| FR-9ShouldOn-demand `reference.md` (artifact contracts, dashboard markup, state schemas) shall be served at `/api/skill/references/templates/<name>/reference.md` only; `meta.json`/`thumb.svg` stay off that route. | Should | On-demand reference.md (artifact contracts, dashboard markup, state schemas) shall be served at /api/skill/references/templates/<name>/reference.md only; meta.json/thumb.svg stay off that route. |
| FR-10ShouldA template's `description` shall spell out the specifics (per-run workflow, hard rules, boundaries, quality gates) as a guided multi-step setup conversation, optionally embedding the task-file skeleton. | Should | A template's description shall spell out the specifics (per-run workflow, hard rules, boundaries, quality gates) as a guided multi-step setup conversation, optionally embedding the task-file skeleton. |
| FR-11ShouldThe slug shall be stable: renaming a folder permanently 404s old share links and deep links. | Should | The slug shall be stable: renaming a folder permanently 404s old share links and deep links. |
Non-Functional Requirements
Order rows by priority: Must first, then Should, then May.
| ID | Priority | Category | Requirement |
|---|---|---|---|
| NFR-1Must`reference.md` serving is a static map of only the four exact names; `meta.json`/`thumb.svg` never resolve. | Must | Security | reference.md serving is a static map of only the four exact names; meta.json/thumb.svg never resolve. |
| NFR-2MustField notes render through marked WITHOUT DOMPurify only because the content is trusted repo content (SSR has no DOM). | Must | Reliability | Field notes render through marked WITHOUT DOMPurify only because the content is trusted repo content (SSR has no DOM). |
| NFR-3MustCarousel auto-play and the typed hero are off under `prefers-reduced-motion`. | Must | Accessibility | Carousel auto-play and the typed hero are off under prefers-reduced-motion. |
| NFR-4ShouldThumbnails are stripped from public list payloads (the market draws no illustration). | Should | Performance | Thumbnails are stripped from public list payloads (the market draws no illustration). |
| NFR-5ShouldA change to a prompt-only `.md` compiles into the server bundle and must deploy (explicit paths-ignore, not `**/*.md`). | Should | Availability | A change to a prompt-only .md compiles into the server bundle and must deploy (explicit paths-ignore, not **/*.md). |
Constraints
- TemplateA canned loop intent (meta.json `description` paste-prompt, optional `reference.md`, `thumb.svg`, `story.md`, flow spec) under `src/skill/templates/`./flow/dashboard content lives under
src/skill/and is compiled into the bundle via?rawimports. sync-skill.mjsstays selective: neitherskill/templates/norskill/bundles/ever ships in the daemon npm tarball.- A new template needs a
meta.json(paste-prompt), membership in exactly one bundle meta, and atemplateRatings.tsentry, or it fails its shape tests.
Acceptance Criteria
Every FR and NFR shall have at least one acceptance criterion.
- FR-1MustA template shall be a folder under `skill/templates/<name>/` with a static `meta.json`; the registry builds `TEMPLATES` from an `import.meta.glob` over `meta.json`.
- Given a new template folder with
meta.json - When the registry builds
- Then the template appears in
TEMPLATESand the shape tests cover it automatically
- Given a new template folder with
- FR-2MustEvery template shall belong to exactly one bundle, and a template in no bundle shall be invisible to every user-facing surface.
- Given a template named in no bundle meta
- When the market renders
- Then it is invisible;
bundles.test.tsfails the shape guard
- FR-3MustThe market pages (`/templates`, `/templates/<slug>`) shall do zero auth checks, render logged out, and SSR real HTML for crawlers.
- Given a logged-out visitor on
/templates - When the page loads
- Then real SSR HTML renders without any auth redirect
- Given a logged-out visitor on
- FR-4MustThe detail page shall render a split layout: left = flow diagram + mechanism facts + optional field notes; right = sticky verbatim prompt + Copy.
- Given the detail page for a template with a flow spec and story
- When it renders
- Then the left shows the derived diagram + mechanism facts + field notes and the right shows the verbatim prompt + Copy
- FR-5MustThe detail CTA shall deep-link `/?template=<name>`, forwarded through the gated `/t/<team>` redirect and preserved across OAuth, reusing the existing single-template compose.
- Given a signed-out user clicking Create on the detail page
- When they complete OAuth
- Then
/?template=<name>survives the redirect and opens the existing compose
- FR-6MustThe market card shall be one shared component (with a body fed by editorial ratings) rendered by `/templates`, the dashboard teaser, and the pre-login landing.
- Given the shared market card
- When rendered on all three surfaces
- Then the body is the rating-fed FlowStrip; the dashboard teaser previews the catalog's curated order
- FR-7MustThe flow spec in `lib/templateFlow.tsx` shall be ONE source drawn by both the compose modal's animated preview and the public detail page's static diagram.
- Given a template with a
FlowSpec - When the modal and the public diagram render
- Then both draw from the same spec (derived, never hand-authored twice)
- Given a template with a
- FR-8MustAn unknown template slug shall throw `notFound()` for a real HTTP 404.
- Given an unknown slug
- When the detail route loads
- Then a real HTTP 404 is returned
- NFR-1Must`reference.md` serving is a static map of only the four exact names; `meta.json`/`thumb.svg` never resolve.
- Given a request for
meta.jsonorthumb.svgthrough the references route - When resolved
- Then it 404s; only
reference.mdresolves
- Given a request for
Conflicts
None identified yet.
Open Questions
- None: behavior is fully determined by the code and its tests.
Specification: TemplateA canned loop intent (meta.json `description` paste-prompt, optional `reference.md`, `thumb.svg`, `story.md`, flow spec) under `src/skill/templates/`. Market & Bundles
Overview
The market is a file-based, zero-exec template system. server/templates.ts builds TEMPLATES from an import.meta.glob over skill/templates/*/meta.json, pairs each folder's optional thumb.svg (?raw), and merges editorial rating from server/templateRatings.ts. server/bundles.ts builds BUNDLES the same way from skill/bundles/*/meta.json, listing member template NAMES in display order. Every user-facing surface reads BUNDLES; a template in no bundle is invisible. listPublicBundles/getPublicTemplate return the same registry minus every inlined thumbnail.
Architecture
skill/templates/<name>/
meta.json → server/templates.ts TEMPLATES (name/label/desc/description)
reference.md → on-demand route (optional bulky detail)
thumb.svg → ?raw pair (dashboard carousel only, never the public market)
story.md → "Field notes" (attached ONLY by findPublicTemplate)
skill/bundles/<name>/meta.json → server/bundles.ts BUNDLES (label/tagline/accent/members)
server/templateRatings.ts → TemplateInfo.rating (merged)
lib/templateFlow.tsx → FLOWS: one FlowSpec per template (nodes + dashboard widgets)
Surfaces:
routes/templates.tsx+templates_.$slug.tsx: public market + detail (zero auth, SSR).components/TemplateCard.tsx: ONE card component (TemplateCard+bundleItems), body = rating-fedFlowStrip.components/TemplatesPreview.tsx: catalog teaser on the dashboard and the pre-login landing (SignIn).components/BundleCarousel.tsx: hero carousel on the dashboard; in-bundle card fan (rows of up to 3).components/ComposeModal.tsx: handles blank/template/bundle; template/bundle snippets skip the host chooser.components/TemplateFlowDiagram.tsx: static detail-page diagram derived from the FlowSpec.routes/api.skill.references.$.ts: serves ONLYskill/templates/<name>/reference.md.
Data Models
TemplateInfo:name,label,desc(one-line card blurb),description(paste-prompt task text), optionalreferencepresence,thumb(optional),rating(merged editorial),hasFlow/storypresence (derived).BundleInfo:name,label,tagline,accent(a--color-<accent>token),members(template names in display order),individual(true only for the "Goal Loops" catch-allothers).Rating: ease, cadence + mechanism, effect visibility, humanizedschedule, optionalexitCondition(closed loops only). Mechanism REUSES the open/closed distinction (theothersbundle is exactly the closed set).
API Contracts
GET /api/skill/references/templates//reference.md
Static map; only the exact reference.md name resolves for a known template folder. meta.json/thumb.svg are never exposed (pinned by test). Returns the file or 404.
Public lists (loader data, no auth)
listPublicBundles() / getPublicTemplate(slug) — the registry minus every inlined thumb.svg. Detail resolves BY SLUG (findPublicTemplate); unknown slug throws notFound().
Sequences
Public detail render
GET /templates/<slug> (SSR, no auth)
findPublicTemplate(slug) → notFound() if unknown
templateFlowDiagram(name) → static diagram (from FlowSpec, hook-free)
story.md attached ONLY here (never on list payloads)
marked (no DOMPurify — trusted repo content, SSR lacks DOM)
media refs assets/<file> rewritten → /template-assets/<name>/…
sticky verbatim prompt + Copy
CTA deep-link /?template=<name>
Create from market
/?template=<name> → gated /t/<team> redirect → callbackURL preserves across OAuth
→ DashboardView.openTemplate → EXISTING single-template compose
(never a parallel creation path)
Technical Decisions
| Decision | Choice | Rationale |
|---|---|---|
| File-based registry | import.meta.glob over meta.json |
Zero-exec; adding a template is content + one bundle line |
| BundleA curated category of templates under `src/skill/bundles/`; every template belongs to exactly one bundle. visibility | Every surface reads BUNDLES | No bundle = invisible, pinned by test |
| Slug = folder name | No alias/redirect map | Rename permanently 404s old links; rename only young templates |
| No thumbnails in market | thumb.svg stripped from public payloads |
The market draws no illustration; detail flow strip is the anchor |
| Trusted markdown | marked without DOMPurify on detail | Repo-authored content; DOMPurify needs a DOM the SSR pass lacks |
| Media in public/ | public/template-assets/<name>/ |
nitro serves public/ verbatim; Vite ?url assets 404 in prod |
| One creation path | Detail CTA reuses compose | Never a parallel creation path |
| Accent tokens | Calm --color-<accent>, plain :root, never red |
A category must not read as an error state; Tailwind v4 tree-shakes a var only used in an inline style |
Risks and Unknowns
- A template folder rename permanently breaks old share links and
/?template=deep links — a deliberate trade-off, documented as a Should. reference.mdroute works in prod but vite's static layer 404s.mdin dev (covered by unit test).- BundleA curated category of templates under `src/skill/bundles/`; every template belongs to exactly one bundle. NAME keys must never change (tests key on names); labels/taglines may.
Out of Scope
- The onboarding Housekeeper template's actual content (owned by onboarding + templates content).
- The daemon skill bundle (
sync-skill.mjswhitelist) — deliberately excludes templates/bundles.
Test Plan: TemplateA canned loop intent (meta.json `description` paste-prompt, optional `reference.md`, `thumb.svg`, `story.md`, flow spec) under `src/skill/templates/`. Market & Bundles
Scope
Testing the template/bundle registries, the shape and editorial-rating guards, the public market/detail SSR routes, the shared card, the flow-spec derivation, the reference.md serving, and the compose deep-link. Out of scope: dashboard card fan geometry (owned by the dashboard feature) and the daemon skill whitelist (owned by daemon packaging).
Unit Tests
| ID | Description | Input | Expected Output |
|---|---|---|---|
| TC-1 | Registry builds TEMPLATES from the folder glob | Folder list under skill/templates/ |
templates.test.ts pins the full name list; each template's defining behaviors stay in its description |
| TC-2 | Every template belongs to exactly one bundle | TemplateA canned loop intent (meta.json `description` paste-prompt, optional `reference.md`, `thumb.svg`, `story.md`, flow spec) under `src/skill/templates/`. ↔ bundle map | bundles.test.ts passes; no template invisible, none duplicated |
| TC-3 | Editorial ratings complete and consistent | TemplateA canned loop intent (meta.json `description` paste-prompt, optional `reference.md`, `thumb.svg`, `story.md`, flow spec) under `src/skill/templates/`. with no rating or misclassified open/closed | templateRatings.test.ts fails |
| TC-4 | Detail resolves by slug with real 404 | Unknown slug | notFound() throws for a real HTTP 404 |
| TC-5 | Flow spec is single-sourced | A template with a FlowSpec |
Compose preview and TemplateFlowDiagram both derive from the same spec |
| TC-6 | Reference route is a static map of ONLY reference.md | Request meta.json/thumb.svg |
404; only the exact name resolves (pinned by -api.skill.references.test.ts) |
| TC-7 | Detail page is hook-free and measurement-free | SSR pass over the detail route | Guard test pins the route stays hook-free (crawler-safe) |
| TC-8 | Public payloads carry no thumbnails | listPublicBundles/getPublicTemplate |
No inlined thumb.svg on list or detail payloads |
| TC-9 | Card is one shared component | Dashboard teaser, market, landing | All three render the same TemplateCard with the rating-fed FlowStrip |
| TC-10 | Story media rewrites resolve | templateStory with assets/<file> refs |
Rewritten to /template-assets/<name>/…; nitro serves public/ verbatim |
| TC-11 | TemplateA canned loop intent (meta.json `description` paste-prompt, optional `reference.md`, `thumb.svg`, `story.md`, flow spec) under `src/skill/templates/`. shape pins | New/renamed/removed folder | Shape tests (templates.test.ts, bundles.test.ts) cover the change automatically |
| TC-12 | BundleA curated category of templates under `src/skill/bundles/`; every template belongs to exactly one bundle.-name stability | Label/tagline changes only | Tests key on names, so label/tagline edits pass |
Edge Cases and Failure Scenarios
| ID | Scenario | Expected Behavior |
|---|---|---|
| TC-13 | TemplateA canned loop intent (meta.json `description` paste-prompt, optional `reference.md`, `thumb.svg`, `story.md`, flow spec) under `src/skill/templates/`. in no bundle | Invisible on every surface; shape test fails loudly |
| TC-14 | Accent declared only in :root |
Tailwind v4 doesn't tree-shake the var used in a runtime inline style |
| TC-15 | Folder renamed | Old /templates/<old> share link and /?template=<old> permanently 404 (documented, Should) |
| TC-16 | Markdown detail under SSR | Renders through marked without DOMPurify (trusted repo content) |
Test Infrastructure
- vitest unit + route tests;
templates.test.ts/bundles.test.ts/templateRatings.test.tsread source via the VARIABLE-pathreadFileSyncguard form. - Public routes verified for SSR HTML and the zero-auth contract.
Coverage Matrix
| Requirement | Test Cases |
|---|---|
| FR-1MustA template shall be a folder under `skill/templates/<name>/` with a static `meta.json`; the registry builds `TEMPLATES` from an `import.meta.glob` over `meta.json`. | TC-1, TC-11 |
| FR-2MustEvery template shall belong to exactly one bundle, and a template in no bundle shall be invisible to every user-facing surface. | TC-2, TC-13 |
| FR-3MustThe market pages (`/templates`, `/templates/<slug>`) shall do zero auth checks, render logged out, and SSR real HTML for crawlers. | public-route SSR + zero-auth contract |
| FR-4MustThe detail page shall render a split layout: left = flow diagram + mechanism facts + optional field notes; right = sticky verbatim prompt + Copy. | TC-5 (diagram), TC-10 (story media) |
| FR-5MustThe detail CTA shall deep-link `/?template=<name>`, forwarded through the gated `/t/<team>` redirect and preserved across OAuth, reusing the existing single-template compose. | compose deep-link + OAuth-forward integration |
| FR-6MustThe market card shall be one shared component (with a body fed by editorial ratings) rendered by `/templates`, the dashboard teaser, and the pre-login landing. | TC-9 |
| FR-7MustThe flow spec in `lib/templateFlow.tsx` shall be ONE source drawn by both the compose modal's animated preview and the public detail page's static diagram. | TC-5 |
| FR-8MustAn unknown template slug shall throw `notFound()` for a real HTTP 404. | TC-4 |
| FR-9ShouldOn-demand `reference.md` (artifact contracts, dashboard markup, state schemas) shall be served at `/api/skill/references/templates/<name>/reference.md` only; `meta.json`/`thumb.svg` stay off that route. | TC-6 |
| FR-10ShouldA template's `description` shall spell out the specifics (per-run workflow, hard rules, boundaries, quality gates) as a guided multi-step setup conversation, optionally embedding the task-file skeleton. | TC-1 |
| FR-11ShouldThe slug shall be stable: renaming a folder permanently 404s old share links and deep links. | TC-15 |
| NFR-1Must`reference.md` serving is a static map of only the four exact names; `meta.json`/`thumb.svg` never resolve. | TC-6 |
| NFR-2MustField notes render through marked WITHOUT DOMPurify only because the content is trusted repo content (SSR has no DOM). | TC-16 |
| NFR-3MustCarousel auto-play and the typed hero are off under `prefers-reduced-motion`. | reduced-motion contract (carousel + typed hero) |
| NFR-4ShouldThumbnails are stripped from public list payloads (the market draws no illustration). | TC-8 |
| NFR-5ShouldA change to a prompt-only `.md` compiles into the server bundle and must deploy (explicit paths-ignore, not `**/*.md`). | deploy paths-ignore (CI contract) |
requirements
- None: behavior is fully determined by the code and its tests.
Vocabulary
Domain Terms
| Term | Definition |
|---|---|
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. | A scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. |
| Loop folderThe on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. | The on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. |
| Open loopA loop with `goal = null` that runs indefinitely (monitor/digest); it never self-terminates. | A loop with goal = null that runs indefinitely (monitor/digest); it never self-terminates. |
| Closed loopA loop with a non-null `goal`; each exec run judges state against the goal and may call `loopany finish` when met, stamping `completedAt` and disabling the loop. | A loop with a non-null goal; each exec run judges state against the goal and may call loopany finish when met, stamping completedAt and disabling the loop. |
| Exec runA scheduled execution of a loop (role `exec`); only exec runs produce user-facing notifications. | A scheduled execution of a loop (role exec); only exec runs produce user-facing notifications. |
| Evolve runA self-improvement pass (role `evolve`) that reviews run history and rewrites the loop's brief, state schema, and dashboard. | A self-improvement pass (role evolve) that reviews run history and rewrites the loop's brief, state schema, and dashboard. |
| Edit runAn owner-requested change (role `edit`) that applies an instruction to the loop, then clears the request. | An owner-requested change (role edit) that applies an instruction to the loop, then clears the request. |
| Run token / run credentialThe per-run lease credential (`rk_…`) authorizing in-run `loopany` verbs and the final report. | The per-run lease credential (rk_…) authorizing in-run loopany verbs and the final report. |
| Device tokenThe `dk_`-prefixed credential that fully impersonates a machine for owner verbs and polling. | The dk_-prefixed credential that fully impersonates a machine for owner verbs and polling. |
| Connect keyA one-time claim token minted by the dashboard that binds a newly connecting machine to its owner and team. | A one-time claim token minted by the dashboard that binds a newly connecting machine to its owner and team. |
| Task fileThe loop's durable context+log document on the machine; its `## Spec` section is the standing brief. | The loop's durable context+log document on the machine; its ## Spec section is the standing brief. |
| WorkflowAn optional zero-LLM async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. | An optional zero-LLMLarge Language Model async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. |
| Generative dashboardLoop-authored `ui` markup (with `loop-embed`/`loop-calendar`/`loop-kanban` primitives) rendered by the web UI from synced front-matter artifacts. | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal.-authored ui markup (with loop-embed/loop-calendar/loop-kanban primitives) rendered by the web UIUser interface from synced front-matter artifacts. |
| Front matterAn optional fenced `---` block of flat `key: value` scalars at the top of a markdown product; the indexed subset `{type?, title?, date?}` is parsed once at byte ingress. | An optional fenced --- block of flat key: value scalars at the top of a markdown product; the indexed subset {type?, title?, date?} is parsed once at byte ingress. |
| TemplateA canned loop intent (meta.json `description` paste-prompt, optional `reference.md`, `thumb.svg`, `story.md`, flow spec) under `src/skill/templates/`. | A canned loop intent (meta.json description paste-prompt, optional reference.md, thumb.svg, story.md, flow spec) under src/skill/templates/. |
| BundleA curated category of templates under `src/skill/bundles/`; every template belongs to exactly one bundle. | A curated category of templates under src/skill/bundles/; every template belongs to exactly one bundle. |
| MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. | A teammate's daemon; the identity unit that owns loops and scopes the dashboard. |
| TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. | The ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. |
Technical Terms
| Term | Definition |
|---|---|
| Pending runA run row in phase `pending`; it is the durable inbox a machine's poll claims. | A run row in phase pending; it is the durable inbox a machine's poll claims. |
| Run leaseA durable row (`run_leases`) minted per delivery holding per-run caps; state machine `active` → `terminal-grace` → retired. | A durable row (run_leases) minted per delivery holding per-run caps; state machine active → terminal-grace → retired. |
| Terminal-graceA swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. | A swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. |
| Long-pollThe opt-in server-held poll (`wait:true`, ~20s) an idle daemon uses for near-zero dispatch latency. | The opt-in server-held poll (wait:true, ~20s) an idle daemon uses for near-zero dispatch latency. |
| Watch set / watchDigestThe per-machine cache of loop folders to watch, digest-echoed so an unchanged client omits the payload. | The per-machine cache of loop folders to watch, digest-echoed so an unchanged client omits the payload. |
| ManifestThe full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. | The full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. |
| BlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. | Content-addressed bytes keyed by sha256 hash, stored in R2Cloudflare R2 object storage or in-memory; referenced by blobs/artifact_files rows. |
| OversizeA file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. | A file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. |
| Run snapshotThe loop's full artifact manifest captured at each run's finalize, diffed against the prior run for the run page. | The loop's full artifact manifest captured at each run's finalize, diffed against the prior run for the run page. |
| SweepThe periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GC. | The periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GCGarbage collection. |
| Circuit breaker`notifyRunFailure` auto-pause of a loop after a configurable consecutive-exec-failure streak. | notifyRunFailure auto-pause of a loop after a configurable consecutive-exec-failure streak. |
| Misfire catch-upBoot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. | Boot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. |
| TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. | The axi-shaped text output rendered by gateway/toon.ts for every /api/machine/cli verb. |
| BYOABring-Your-Own-Agent | Bring-your-own-agent: execution runs with the user's own coding agent and credentials on their own machine. |
Acronyms and Abbreviations
| Abbreviation | Expansion |
|---|---|
| BYOABring-Your-Own-Agent | Bring-Your-Own-Agent |
| R2Cloudflare R2 object storage | Cloudflare R2Cloudflare R2 object storage object storage |
| TTLTime-to-live | Time-to-live |
| GCGarbage collection | Garbage collection |
| LLMLarge Language Model | Large Language Model |
| WSWebSocket | WebSocket |
| UIUser interface | User interface |
| PRPull Request | Pull Request |
| npm OIDCnpm OpenID Connect trusted publishing | npm OpenID Connect trusted publishing |
| Fly / Fly.ioFly.io app hosting | Fly.io app hosting |
| pgliteEmbedded WASM Postgres by ElectricSQL | Embedded WASM Postgres by ElectricSQL |
| MCPModel Context Protocol | Model Context Protocol |
Pipeline Progress ?⚪ not started not yet begun✏️ draft initial version🔍 in review under review🚧 in progress actively worked on⛔ blocked waiting on dependency✅ done completed⏭️ skipped not applicable
Requirements: Notifications & Channel Bindings
Overview
Loopany notifies owners about run outcomes (and deferred runs) over external channels (Slack, Telegram, Feishu). Channels are created from a webhook/token via the shared ChannelAddForm, verified with a live testChannel ping, and consumed by gateway/notify.ts. Only exec runs produce user-facing notifications, success or failure, with anti-spam streak logic, an offline deferred message, and a failure circuit breaker that auto-pauses a loop after a streak.
Stakeholders
| Stakeholder | Interest |
|---|---|
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. owner | Get notified on run success/failure without watching the dashboard; silenceable via notify: "never". |
| Product | Alerting that is calm and non-spammy: streak-based copy, deferred-run dedup, autopause with one subsuming note. |
| Security | Channels are webhook-shaped credentials; the webhook guard must prevent open SSRF and keep tokens out of list payloads. |
Functional Requirements
Order rows by priority: Must first, then Should, then May.
| ID | Priority | Requirement |
|---|---|---|
| FR-1MustChannels shall be created via one shared `ChannelAddForm` (slack/telegram/feishu) used by both the notifications modal and the onboarding live step, with a live `testChannel` ping before saving. | Must | Channels shall be created via one shared ChannelAddForm (slack/telegram/feishu) used by both the notifications modal and the onboarding live step, with a live testChannel ping before saving. |
| FR-2MustNotifications shall fire on run success and failure, but ONLY for exec runs; evolve/edit runs produce no user-facing notifications. | Must | Notifications shall fire on run success and failure, but ONLY for exec runs; evolve/edit runs produce no user-facing notifications. |
| FR-3MustFailure notifications shall be anti-spam: derived from persisted run rows, notifying at streak 1 then every 5th; a success resets the streak. | Must | Failure notifications shall be anti-spam: derived from persisted run rows, notifying at streak 1 then every 5th; a success resets the streak. |
| FR-4Must`notify: "never"` shall silence everything, including the autopause note. | Must | notify: "never" shall silence everything, including the autopause note. |
| FR-5MustA deferred exec run on an offline machine shall get exactly ONE calm `deferredMessage`, deduped by the deferred progress stamp. | Must | A deferred exec run on an offline machine shall get exactly ONE calm deferredMessage, deduped by the deferred progress stamp. |
| FR-6MustThe failure circuit breaker shall auto-pause a loop (unschedule + `enabled=false`) at the configured streak, with one autopause note that SUBSUMES the failure alert. | Must | The failure circuit breaker shall auto-pause a loop (unschedule + enabled=false) at the configured streak, with one autopause note that SUBSUMES the failure alert. |
| FR-7ShouldChannel credentials shall be validated by a webhook guard before any outbound request (SSRF containment). | Should | Channel credentials shall be validated by a webhook guard before any outbound request (SSRF containment). |
| FR-8ShouldThe gateway shall take an injectable notifier so tests observe pushes without network. | Should | The gateway shall take an injectable notifier so tests observe pushes without network. |
Non-Functional Requirements
Order rows by priority: Must first, then Should, then May.
| ID | Priority | Category | Requirement |
|---|---|---|---|
| NFR-1MustChannel tokens must never appear in channel-list payloads or logs. | Must | Security | Channel tokens must never appear in channel-list payloads or logs. |
| NFR-2MustThe webhook guard must reject private/internal targets and non-http(s) schemes before outbound delivery. | Must | Security | The webhook guard must reject private/internal targets and non-http(s) schemes before outbound delivery. |
| NFR-3ShouldFailure pushes must be de-alarmed and name sleep as the likely cause, distinguishing an interrupted run from a skipped scheduled one. | Should | Reliability | Failure pushes must be de-alarmed and name sleep as the likely cause, distinguishing an interrupted run from a skipped scheduled one. |
Constraints
runsrows are the single source for streak/outcome; notifiers must not carry their own persisted state.- Notifications are outbound only; there is no inbound reply handling.
notifyenum lives on the loop record (default/never), consumed at dispatch time.
Acceptance Criteria
Every FR and NFR shall have at least one acceptance criterion.
- FR-1MustChannels shall be created via one shared `ChannelAddForm` (slack/telegram/feishu) used by both the notifications modal and the onboarding live step, with a live `testChannel` ping before saving.
- Given a channel form open in either the modal or the wizard
- When a channel is added
- Then a live
testChannelping runs before save and both surfaces share the form
- FR-2MustNotifications shall fire on run success and failure, but ONLY for exec runs; evolve/edit runs produce no user-facing notifications.
- Given an evolve or edit run completing
- When it reports
- Then no user-facing notification is pushed
- FR-3MustFailure notifications shall be anti-spam: derived from persisted run rows, notifying at streak 1 then every 5th; a success resets the streak.
- Given a series of exec failures
- When the streak reaches 1 and then every 5th
- Then notifications fire only at those points; a success resets the streak
- FR-4Must`notify: "never"` shall silence everything, including the autopause note.
- Given a loop with
notify: "never" - When any failure or autopause occurs
- Then nothing is pushed
- Given a loop with
- FR-5MustA deferred exec run on an offline machine shall get exactly ONE calm `deferredMessage`, deduped by the deferred progress stamp.
- Given a genuinely offline machine with a deferred exec run
- When the deferral is recorded
- Then exactly one calm
deferredMessageis sent, deduped by the progress stamp
- FR-6MustThe failure circuit breaker shall auto-pause a loop (unschedule + `enabled=false`) at the configured streak, with one autopause note that SUBSUMES the failure alert.
- Given the failure streak reaching the autopause threshold
- When the loop pauses
- Then
enabled=false+ unschedule and ONE autopause note subsuming the failure alert
- NFR-1MustChannel tokens must never appear in channel-list payloads or logs.
- Given a channel list call
- When the payload renders
- Then no token is present
- NFR-2MustThe webhook guard must reject private/internal targets and non-http(s) schemes before outbound delivery.
- Given a channel webhook pointing at a private/internal host
- When delivery is attempted
- Then the guard rejects it before any outbound request
Conflicts
None identified yet.
Open Questions
- None: behavior is fully determined by the code and its tests.
Specification: Notifications & Channel Bindings
Overview
Channels are webhook-shaped credentials (slack/telegram/feishu) created through the shared ChannelAddForm (extracted from NotificationsModal, reused by the onboarding live step), each verified by a live testChannel ping. gateway/notify.ts consumes them at run finalization: only exec runs notify, the failure streak derives from persisted run rows (notify at streak 1 then every 5th, success resets), deferred offline runs get ONE calm message, and the circuit breaker auto-pauses at the configured streak with one subsuming note. webhookGuard.ts SSRF-contains outbound delivery. The notifier is injected into the gateway so tests observe pushes without network.
Architecture
ChannelAddForm (shared: NotificationsModal + onboarding live step)
createChannel(teamId, {kind, token}) → store.channels
testChannel(channel) → live ping (ok/error)
gateway/notify.ts (injectable notifier into MachineGateway)
notifyRunSuccess / notifyRunFailure
failureMessage: de-alarmed, names sleep as likely cause
deferredMessage: one per offline deferred exec run (dedup via DEFERRED_LABEL stamp)
gateway/circuitBreaker (via notifyRunFailure)
streak >= LOOPANY_FAILURE_AUTOPAUSE_STREAK → enabled=false + unschedule + ONE note
webhookGuard.ts
scheme/SSRF validation before outbound delivery
Channel routes: createChannel, listChannels, testChannel, deleteChannel (session-authed, team-scoped). notify: "never" silences every push.
Data Models
channelstable: per-team rows carryingkind+ an encrypted/webhook credential; list payloads never include the token.loops.notify: enum (default/never) consumed at dispatch time.- Streak: derived from
runsrows (phaseerroronly;skippedis transparent to the streak).
API Contracts
Channel CRUD (session-authed, team-scoped)
| Method | Path | Purpose |
|---|---|---|
| POST | /api/teams/<teamId>/channels |
createChannel with live testChannel ping |
| GET | /api/teams/<teamId>/channels |
listChannels (no tokens) |
| POST | /api/teams/<teamId>/channels/test |
testChannel |
| DELETE | /api/teams/<teamId>/channels/<id> |
deleteChannel |
Webhook guard
Rejects before any outbound request: non-http(s) schemes and private/internal targets (loopback, link-local, metadata, private ranges).
Sequences
Failure with autopause
report() !ok (exec run)
streak = count consecutive phase=error rows
if notify:"never" → stop
notify at streak 1, then every 5th (persisted rows, deploy-safe)
if streak >= LOOPANY_FAILURE_AUTOPAUSE_STREAK (default 10, 0=off)
→ store.updateLoop enabled=false + unschedule
→ ONE autopause note that SUBSUMES the failure alert
→ silent under notify:"never"; plain pause, re-enable resumes
Deferred offline run
pending exec run on an unreachable machine
sweep holds it; alarm policy mirrors presence
asleep (<6h) → fully silent
genuinely offline → ONE calm deferredMessage per run
dedup = DEFERRED_LABEL progress stamp (doubles as the UIUser interface "waiting" hint)
Technical Decisions
| Decision | Choice | Rationale |
|---|---|---|
| Shared form | ChannelAddForm extracted from NotificationsModal |
Two binding surfaces can't drift |
| Persisted streak | Derived from run rows, not notifier state | Exact, deploy-safe |
| Autopause | enabled=false + unschedule + one subsuming note |
Circuit breaker`notifyRunFailure` auto-pause of a loop after a configurable consecutive-exec-failure streak. without spam; a plain pause, re-enable resumes |
| SSRF containment | webhookGuard before outbound delivery |
Webhooks are attacker-adjacent credentials |
| Injectable notifier | Inject into MachineGateway | Tests observe pushes without network |
| De-alarmed copy | Names sleep as likely cause | Distinguishes an interrupted run from a skipped scheduled one |
Risks and Unknowns
skippedruns must stay transparent to the streak (excluded, quiet gray in the UIUser interface) — the count keys off phaseerroronly.- The autopause threshold is env-tunable (
LOOPANY_FAILURE_AUTOPAUSE_STREAK),0 = off. - Deferred dedup relies on the DEFERRED_LABEL progress stamp; an asleep machine is fully silent.
Out of Scope
- Inbound replies or webhook callbacks from channels.
- The onboarding live step's binding surface (onboarding feature owns the wizard; the form itself is shared here).
Test Plan: Notifications & Channel Bindings
Scope
Testing channel CRUD with the shared form and live test ping, exec-only notification rules, streak-based anti-spam, the deferred offline message with dedup, the autopause circuit breaker, the webhook guard SSRF containment, and the injectable notifier seam. Out of scope: the onboarding wizard flow and dashboard channel UIUser interface chrome.
Unit Tests
| ID | Description | Input | Expected Output |
|---|---|---|---|
| TC-1 | Shared ChannelAddForm is used by both binding surfaces | Notifications modal + onboarding live step | Both render the same form component (single source) |
| TC-2 | createChannel runs a live test ping | Valid + invalid channel | Ping ok → saved; ping error → rejected |
| TC-3 | listChannels never returns tokens | Channel list with credentials | Payload has no token fields |
| TC-4 | Only exec runs notify | Exec success/failure + evolve/edit outcomes | Exec → push; evolve/edit → no user-facing notification |
| TC-5 | Failure streak notifies at 1 then every 5th | Series of exec failures | Notifications at streak 1, 6, 11, ...; success resets |
| TC-6 | skipped runs are transparent to the streak | streak with skipped runs interleaved | Count keys off phase error only; skipped never counted |
| TC-7 | notify:"never" silences everything | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. with notify never + failure/autopause | No push at all, including the autopause note |
| TC-8 | Autopause subsumes the failure alert | Streak reaches threshold | enabled=false + unschedule + ONE note; re-enable resumes |
| TC-9 | Offline deferred exec run gets one calm message | Genuinely offline machine | One deferredMessage, deduped by the DEFERRED_LABEL stamp |
| TC-10 | Asleep machine is silent | MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. seen < 6h ago | No deferred message |
| TC-11 | Webhook guard rejects SSRF targets | Loopback/metadata/private-range/unsupported scheme | Rejected before any outbound request |
| TC-12 | Notifier is injectable | Gateway with a spy notifier | Tests observe pushes without network |
Edge Cases and Failure Scenarios
| ID | Scenario | Expected Behavior |
|---|---|---|
| TC-13 | Streak threshold configured to 0 | Autopause disabled entirely |
| TC-14 | Interrupted running run vs skipped scheduled run | Failure copy names sleep as the likely cause, distinguishes the two |
| TC-15 | Deferred run claimed at next poll | No duplicate message; supersede retires as outcome skipped |
| TC-16 | Delete channel mid-delivery | Delivery handles the missing channel gracefully |
Test Infrastructure
- vitest; gateway tests use an injected spy notifier (no network).
- Webhook guard tests run against a resolver-seam over private/loopback/metadata targets.
Coverage Matrix
| Requirement | Test Cases |
|---|---|
| FR-1MustChannels shall be created via one shared `ChannelAddForm` (slack/telegram/feishu) used by both the notifications modal and the onboarding live step, with a live `testChannel` ping before saving. | TC-1, TC-2 |
| FR-2MustNotifications shall fire on run success and failure, but ONLY for exec runs; evolve/edit runs produce no user-facing notifications. | TC-4 |
| FR-3MustFailure notifications shall be anti-spam: derived from persisted run rows, notifying at streak 1 then every 5th; a success resets the streak. | TC-5, TC-6 |
| FR-4Must`notify: "never"` shall silence everything, including the autopause note. | TC-7 |
| FR-5MustA deferred exec run on an offline machine shall get exactly ONE calm `deferredMessage`, deduped by the deferred progress stamp. | TC-9, TC-10, TC-15 |
| FR-6MustThe failure circuit breaker shall auto-pause a loop (unschedule + `enabled=false`) at the configured streak, with one autopause note that SUBSUMES the failure alert. | TC-8, TC-13 |
| FR-7ShouldChannel credentials shall be validated by a webhook guard before any outbound request (SSRF containment). | TC-11 |
| FR-8ShouldThe gateway shall take an injectable notifier so tests observe pushes without network. | TC-12 |
| NFR-1MustChannel tokens must never appear in channel-list payloads or logs. | TC-3 |
| NFR-2MustThe webhook guard must reject private/internal targets and non-http(s) schemes before outbound delivery. | TC-11 |
| NFR-3ShouldFailure pushes must be de-alarmed and name sleep as the likely cause, distinguishing an interrupted run from a skipped scheduled one. | TC-14 |
requirements
- None: behavior is fully determined by the code and its tests.
Vocabulary
Domain Terms
| Term | Definition |
|---|---|
| LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. | A scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal. |
| Loop folderThe on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. | The on-machine directory the agent works in; its task file is the README and its contents live-sync to the server as artifacts. |
| Open loopA loop with `goal = null` that runs indefinitely (monitor/digest); it never self-terminates. | A loop with goal = null that runs indefinitely (monitor/digest); it never self-terminates. |
| Closed loopA loop with a non-null `goal`; each exec run judges state against the goal and may call `loopany finish` when met, stamping `completedAt` and disabling the loop. | A loop with a non-null goal; each exec run judges state against the goal and may call loopany finish when met, stamping completedAt and disabling the loop. |
| Exec runA scheduled execution of a loop (role `exec`); only exec runs produce user-facing notifications. | A scheduled execution of a loop (role exec); only exec runs produce user-facing notifications. |
| Evolve runA self-improvement pass (role `evolve`) that reviews run history and rewrites the loop's brief, state schema, and dashboard. | A self-improvement pass (role evolve) that reviews run history and rewrites the loop's brief, state schema, and dashboard. |
| Edit runAn owner-requested change (role `edit`) that applies an instruction to the loop, then clears the request. | An owner-requested change (role edit) that applies an instruction to the loop, then clears the request. |
| Run token / run credentialThe per-run lease credential (`rk_…`) authorizing in-run `loopany` verbs and the final report. | The per-run lease credential (rk_…) authorizing in-run loopany verbs and the final report. |
| Device tokenThe `dk_`-prefixed credential that fully impersonates a machine for owner verbs and polling. | The dk_-prefixed credential that fully impersonates a machine for owner verbs and polling. |
| Connect keyA one-time claim token minted by the dashboard that binds a newly connecting machine to its owner and team. | A one-time claim token minted by the dashboard that binds a newly connecting machine to its owner and team. |
| Task fileThe loop's durable context+log document on the machine; its `## Spec` section is the standing brief. | The loop's durable context+log document on the machine; its ## Spec section is the standing brief. |
| WorkflowAn optional zero-LLM async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. | An optional zero-LLMLarge Language Model async function body (validated, machine-run) that does cheap mechanical work before the agent; a failure falls back to the agent with context. |
| Generative dashboardLoop-authored `ui` markup (with `loop-embed`/`loop-calendar`/`loop-kanban` primitives) rendered by the web UI from synced front-matter artifacts. | LoopA scheduled behavior bound to one machine: a cron schedule, a task file, an optional workflow pre-stage, and an optional goal.-authored ui markup (with loop-embed/loop-calendar/loop-kanban primitives) rendered by the web UIUser interface from synced front-matter artifacts. |
| Front matterAn optional fenced `---` block of flat `key: value` scalars at the top of a markdown product; the indexed subset `{type?, title?, date?}` is parsed once at byte ingress. | An optional fenced --- block of flat key: value scalars at the top of a markdown product; the indexed subset {type?, title?, date?} is parsed once at byte ingress. |
| TemplateA canned loop intent (meta.json `description` paste-prompt, optional `reference.md`, `thumb.svg`, `story.md`, flow spec) under `src/skill/templates/`. | A canned loop intent (meta.json description paste-prompt, optional reference.md, thumb.svg, story.md, flow spec) under src/skill/templates/. |
| BundleA curated category of templates under `src/skill/bundles/`; every template belongs to exactly one bundle. | A curated category of templates under src/skill/bundles/; every template belongs to exactly one bundle. |
| MachineA teammate's daemon; the identity unit that owns loops and scopes the dashboard. | A teammate's daemon; the identity unit that owns loops and scopes the dashboard. |
| TeamThe ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. | The ownership/scope unit (every user gets a personal team); loops, machines, and channels are listed/authorized by team. |
Technical Terms
| Term | Definition |
|---|---|
| Pending runA run row in phase `pending`; it is the durable inbox a machine's poll claims. | A run row in phase pending; it is the durable inbox a machine's poll claims. |
| Run leaseA durable row (`run_leases`) minted per delivery holding per-run caps; state machine `active` → `terminal-grace` → retired. | A durable row (run_leases) minted per delivery holding per-run caps; state machine active → terminal-grace → retired. |
| Terminal-graceA swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. | A swept run's lease state with a bounded expiry that allows exactly one reconciling late wake-report. |
| Long-pollThe opt-in server-held poll (`wait:true`, ~20s) an idle daemon uses for near-zero dispatch latency. | The opt-in server-held poll (wait:true, ~20s) an idle daemon uses for near-zero dispatch latency. |
| Watch set / watchDigestThe per-machine cache of loop folders to watch, digest-echoed so an unchanged client omits the payload. | The per-machine cache of loop folders to watch, digest-echoed so an unchanged client omits the payload. |
| ManifestThe full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. | The full sha256 manifest of a loop folder's files; hashing is incremental, the manifest always full. |
| BlobContent-addressed bytes keyed by sha256 hash, stored in R2 or in-memory; referenced by `blobs`/`artifact_files` rows. | Content-addressed bytes keyed by sha256 hash, stored in R2Cloudflare R2 object storage or in-memory; referenced by blobs/artifact_files rows. |
| OversizeA file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. | A file exceeding the per-file byte cap (10MB); stored metadata-only with no bytes. |
| Run snapshotThe loop's full artifact manifest captured at each run's finalize, diffed against the prior run for the run page. | The loop's full artifact manifest captured at each run's finalize, diffed against the prior run for the run page. |
| SweepThe periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GC. | The periodic server maintenance pass that reclaims stale runs, holds deferred pending runs, and runs retention/GCGarbage collection. |
| Circuit breaker`notifyRunFailure` auto-pause of a loop after a configurable consecutive-exec-failure streak. | notifyRunFailure auto-pause of a loop after a configurable consecutive-exec-failure streak. |
| Misfire catch-upBoot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. | Boot-time reconstruction of a missed cron occurrence inside a deploy window, firing one compensating tick. |
| TOONThe axi-shaped text output rendered by `gateway/toon.ts` for every `/api/machine/cli` verb. | The axi-shaped text output rendered by gateway/toon.ts for every /api/machine/cli verb. |
| BYOABring-Your-Own-Agent | Bring-your-own-agent: execution runs with the user's own coding agent and credentials on their own machine. |
Acronyms and Abbreviations
| Abbreviation | Expansion |
|---|---|
| BYOABring-Your-Own-Agent | Bring-Your-Own-Agent |
| R2Cloudflare R2 object storage | Cloudflare R2Cloudflare R2 object storage object storage |
| TTLTime-to-live | Time-to-live |
| GCGarbage collection | Garbage collection |
| LLMLarge Language Model | Large Language Model |
| WSWebSocket | WebSocket |
| UIUser interface | User interface |
| PRPull Request | Pull Request |
| npm OIDCnpm OpenID Connect trusted publishing | npm OpenID Connect trusted publishing |
| Fly / Fly.ioFly.io app hosting | Fly.io app hosting |
| pgliteEmbedded WASM Postgres by ElectricSQL | Embedded WASM Postgres by ElectricSQL |
| MCPModel Context Protocol | Model Context Protocol |