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.
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:
Because repoId is the owner/name slug, a concrete example
for issue 42 in acme/myapp looks like:
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
| Table | Holds |
|---|---|
Worker | Agent sessions per issue |
IssueConfig | Per-issue overrides |
InternalIssue | Built-in tracker issues |
Label | GitHub label cache |
ReadyIssue | Ready queue membership |
ReadyOrder | Drag-drop queue order |
RepoSnapshot | Cached GitHub data |
Batch | Batch run groups |
Global (no repoId FK)
| Table | Holds |
|---|---|
Config | Key-value global settings |
WorkerReport | Cost and token data |
Run | Individual agent runs |
Event | Streaming 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.
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
| File | Responsibility |
|---|---|
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
| Value | Meaning |
|---|---|
"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.
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
?token=<secret> in the URL. Best for first-time browser access.
On success: 307 redirect (token stripped from URL) and slop_auth cookie set.
Authorization: Bearer <secret>. Standard choice for scripts and
programmatic API clients. Does not set a cookie.
slop_auth cookie carrying the secret. Set automatically after a successful
query-parameter auth. Subsequent browser requests are authenticated silently.
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.
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
| Step | Mechanism |
|---|---|
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.
?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:
| Field | Value |
|---|---|
| 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.
GITHUB_TOKEN
covers both; the only distinction is whether a Repo row exists for it.