Slop Docs Home
◆  Internals

Multi-Repo Management,
Snapshots and Auth

One Slop instance manages as many GitHub repositories as you need. This page explains how per-repo scoping works, how the snapshot cache keeps the board fast without hammering the GitHub API, and how optional authentication protects the server when it runs outside localhost.

Multi-Repo Snapshot Cache Authentication SSE Updates

Watching Multiple Repos

Each repository Slop manages is represented by a Repo row in SQLite. You add repos on the Config page; there is no hard limit on how many you can add. All other Slop data (workers, issue configs, snapshots, the ready queue) carries a repoId foreign key so every query is scoped to the selected repo.

Repo Table Schema

Column Type Notes
id String PK The owner/name slug. Also used as the path component inside WORKTREES_ROOT.
path String Absolute path to the local git checkout on disk.
baseBranch String? Default branch for this repo. Falls back to the global baseBranch config key, then "main".
parallelismCap Int? Maximum concurrent workers for this repo. null defers to the global cap.
removedAt DateTime? Set on soft-delete. Active queries filter WHERE removedAt IS NULL.
createdAt DateTime Row creation timestamp, used to order repos in the UI.

Worktree Path Pattern

When the daemon starts a worker it creates an isolated git worktree at a deterministic path derived from the environment, the repo id, and the issue number:

<WORKTREES_ROOT>/<repoId>/<issueNumber>

Because repoId is the owner/name slug, a concrete example for issue 42 in acme/myapp looks like:

/home/user/worktrees/acme/myapp/42

Set WORKTREES_ROOT in your environment before starting Slop. The directory must exist and be writable by the process. Each repo gets its own subdirectory, so worktrees from different repos never collide.

Scoped vs. Global Tables

Scoped by repoId

TableHolds
WorkerAgent sessions per issue
IssueConfigPer-issue overrides
InternalIssueBuilt-in tracker issues
LabelGitHub label cache
ReadyIssueReady queue membership
ReadyOrderDrag-drop queue order
RepoSnapshotCached GitHub data
BatchBatch run groups

Global (no repoId FK)

TableHolds
ConfigKey-value global settings
WorkerReportCost and token data
RunIndividual agent runs
EventStreaming log entries

Adding and Removing Repos

Adding: Config page → "Add Repository" form → enter the GitHub slug (owner/name), the absolute path to your local clone, and optionally a base branch. Submitting calls daemon.addRepo(). The snapshot poll runs on the next daemon cycle.

Removing: The "Remove" button calls daemon.removeRepo(repoId). This soft-deletes the Repo row (sets removedAt) and cascades hard-deletes to all child rows. In-flight workers for the removed repo are allowed to finish.

One token, all repos. A single GITHUB_TOKEN environment variable authenticates API calls for every watched repo. Per-repo tokens are not supported. Ensure the token has read and write access to all repositories you intend to watch.

Repository Snapshot System

GitHub's REST API is slow and rate-limited. Fetching all open issues, PRs, CI check runs, and releases on every board refresh would produce high latency and exhaust the 5,000 requests-per-hour rate limit quickly on any active repo.

The solution: the daemon polls GitHub on a background schedule and stores the results in a RepoSnapshot row (one per repo) in SQLite. The board and issue list pages read from that local row; they do not call GitHub directly.

Key Files

FileResponsibility
src/server/daemon/repo-snapshot-poll.ts Fetches data from GitHub, aggregates CI status, saves and publishes the snapshot
src/db/repo-snapshot.repo.ts Prisma wrappers: getRepoSnapshot / saveRepoSnapshot
src/lib/repo/repo-snapshot.ts encodeRepoSnapshot / decodeRepoSnapshot with strict runtime validation
src/types/repo-snapshot.ts TypeScript types: RepoSnapshot, SnapshotPullRequest, CiStatus

Snapshot JSON Structure

The RepoSnapshot.data column holds a compact JSON blob. All UI reads parse it through decodeRepoSnapshot, which validates every field and throws on unexpected shapes.

{
  "repo":       "acme/myapp",
  "fetchedAt":  "2026-06-13T10:00:00.000Z",

  // Up to 100 open GitHub issues
  "openIssues": [
    {
      "number":   42,
      "title":    "Fix the thing",
      "labels":   ["bug"],
      "htmlUrl":  "https://github.com/acme/myapp/issues/42",
      "state":    "open",
      "closedAt": null
    }
  ],

  // Last 20 recently-closed issues (for the Done column)
  "closedIssues": [ /* same shape */ ],

  // Up to 100 open pull requests, each with aggregated CI status
  "pullRequests": [
    {
      "number":    7,
      "title":     "Fix the thing (#42)",
      "state":     "open",
      "draft":     false,
      "headSha":   "abc123def456",
      "mergeable": true,
      "body":      "Closes #42",
      "htmlUrl":   "https://github.com/acme/myapp/pull/7",
      "ciStatus":  "passing"    // "passing" | "failing" | "pending" | "none"
    }
  ],

  // Last 5 releases (including drafts)
  "releases": [
    {
      "tagName":     "v1.2.3",
      "name":        "Release 1.2.3",
      "publishedAt": "2026-06-01T09:00:00.000Z",
      "htmlUrl":     "https://github.com/acme/myapp/releases/tag/v1.2.3",
      "isDraft":     false,
      "isPrerelease": false
    }
  ],

  // CI health of the base branch HEAD
  "baseCi": "passing"           // "passing" | "failing" | "pending" | "none"
}

CI Status Aggregation

ValueMeaning
"failing" Any check run has a conclusion of failure, cancelled, timed_out, action_required, startup_failure, or stale
"pending" No failing runs, but at least one check is not yet completed
"passing" All check runs are completed with non-failing conclusions
"none" No check runs found for this commit

Refresh Cycle and SSE Notification

refreshRepoSnapshot runs as a step of every daemon poll cycle. It iterates all active repos and calls the GitHub API for each one. A failure for one repo is logged and does not block the others.

After saving the updated row, the poller publishes a repo.updated event on the internal event bus. Any connected browser receives this over the Server-Sent Events stream and re-renders the board without a full page reload.

Pausing the poll. Set pollPaused = true on the Config page to stop GitHub polling during an incident or rate-limit recovery. All other daemon operations (claiming issues, advancing workers) continue normally. The snapshot simply stays stale until polling resumes.

Authentication

Slop's auth is controlled by the SLOP_SECRET environment variable. When the variable is absent the server binds to 127.0.0.1 only and requires no credentials. When the variable is set, every request must carry the secret via one of three vectors.

Three Auth Vectors

1
Query Parameter
?token=<secret> in the URL. Best for first-time browser access. On success: 307 redirect (token stripped from URL) and slop_auth cookie set.
2
Authorization Header
Authorization: Bearer <secret>. Standard choice for scripts and programmatic API clients. Does not set a cookie.
3
Cookie
slop_auth cookie carrying the secret. Set automatically after a successful query-parameter auth. Subsequent browser requests are authenticated silently.
Constant-time comparison. All three vectors are compared using an XOR-based constant-time equality check (constantTimeEqual in src/middleware.ts) to prevent timing side-channel attacks. The comparison iterates both strings to their maximum length, accumulating XOR'd char codes. No early exit is possible.

First-Auth-Then-Cookie Flow

The query-parameter path is designed for the common case of opening Slop in a browser for the first time. The sequence below shows how a one-time token URL bootstraps a persistent cookie session.

Browser Middleware | | | GET /?token=mysecret | | --------------------------> | | | compare token vs SLOP_SECRET | | (constant-time) | | match | | | 307 Redirect (token stripped from URL) | Set-Cookie: slop_auth=mysecret; HttpOnly; SameSite=Lax | <-------------------------- | | | | GET / (cookie attached) | | --------------------------> | | | read slop_auth cookie | | compare vs SLOP_SECRET | | match | | | 200 OK - page served | | <-------------------------- | | | | (all further navigations) | | --------------------------> | cookie present, auto-authenticated | <-------------------------- | 200 OK

127.0.0.1 Binding Without SLOP_SECRET

When SLOP_SECRET is not set, no authentication middleware runs and the Next.js server binds only to 127.0.0.1. This makes Slop unreachable from any other host, so it is safe to run without credentials on a personal machine. Set SLOP_SECRET only if you need to access Slop from another machine or expose it over a network.

Middleware Placement

The entire auth layer lives in src/middleware.ts and runs before every Next.js page and API route. The matcher pattern excludes _next/static, _next/image, and favicon.ico so static assets are always served without authentication.

Repo Selection

The UI shows one repo at a time. The selected repo flows through the system via a query parameter and a cookie, so it persists across page navigations without appearing in every URL.

How Selection Flows

StepMechanism
1. User clicks a repo link or navigates with ?repo=acme/myapp Query parameter present on the request
2. Middleware reads ?repo= and writes slop_selected_repo=acme/myapp cookie withRepoCookie() in src/middleware.ts
3. Browser navigates to another page without ?repo= Cookie still present; repo selection retained
4. Server-side handlers read the selected repo from the slop_selected_repo cookie Passed via x-slop-repo-id request header injected by middleware

You can combine ?repo= and ?token= on a single URL. The middleware handles both in one pass: auth check first, then repo cookie update.

Switching repos. Navigate to any page with ?repo=<newRepoId> to switch the active repo. The cookie is overwritten and all subsequent requests use the new selection. The Config page lists all watched repos with links that include the correct ?repo= parameter.

Running Slop on Itself

There is an important distinction between "this Slop repo" (the checkout you are editing and running) and "a watched repo" (any repository configured on the Config page). Issues filed against the Slop GitHub repository are not automatically queued -- Slop does not watch itself by default.

To have Slop implement its own features, add its own checkout as a watched repo:

FieldValue
Slug <your-github-user>/slop (or the org fork you use)
Local path Absolute path to your Slop checkout, e.g. /home/user/slop
Base branch main (or leave blank)

Once added, any issue queued on the board will be implemented by the same agent pipeline that manages all other watched repos. This closes the loop: Slop can file issues against itself, implement them, push PRs, wait for CI, and merge -- entirely autonomously.

The rule in short: "This repo" = the Slop source you are running. "A watched repo" = any repository listed on the Config page. One GITHUB_TOKEN covers both; the only distinction is whether a Repo row exists for it.