Imported from block/builder-syndicate (
AGENTS.md). Install upstream withnpx skills add block/builder-syndicate. Copyright stays with the author.
Builder Syndicate - Agent Guide
Project Goals
- OSS-first enterprise link aggregator (Reddit/HN/Lobsters style)
- Exemplary Misk reference implementation — showcase idiomatic patterns, no escape hatches
- LLM-built — multiple AI builders (Claude, Amp, ChatGPT, Gemini)
Architecture
Hexagonal-lite Package Structure
src/main/kotlin/xyz/block/buildersyndicate/
├── core/ # Pure Kotlin — NO framework imports
│ ├── users/ # User entity, UserRepository interface
│ ├── posts/ # Post entity, PostRepository, PostService, MarkdownRenderer
│ ├── comments/ # (future)
│ └── ...
├── adapters/ # Framework integrations
│ ├── db/ # jOOQ repository implementations, DatabaseModule
│ ├── misk/ # WebActions, gRPC services, AuthModule
│ └── ...
└── app/ # Wiring only
├── BuilderSyndicateService.kt # Thin main()
└── BuilderSyndicateModule.kt # Root Guice module
Core Rules (Invariants)
core/has NO framework imports — only stdlib +java.timemain()is thin — only instantiate modules, callMiskApplication.start()- All Guice modules, services, actions, repositories are
public(notinternal) - Interfaces in
core/, implementations inadapters/
Enterprise Composition
OSS ships DefaultBuilderSyndicateModule bundling defaults. Enterprises swap modules:
AuthModule— SSO integrationDatabaseModule— connection configStorageModule— S3/CDN
Tech Stack
| Layer | Technology |
|---|---|
| Language | Kotlin, Java 21 |
| Framework | Misk |
| Build | Gradle Kotlin DSL |
| API | gRPC (Wire) + WebActions (JSON) |
| Database | MySQL 8.0 (Aurora in prod) |
| Migrations | Flyway |
| Query | jOOQ (generated Kotlin) |
| Frontend | React + TypeScript |
Commands
just dev # Start MySQL via docker-compose
just down # Stop containers
just db-migrate # Run Flyway migrations
just codegen # Generate jOOQ + proto code
just build # Build project
just test # Run tests
just run # Start the app
Conventions
Database
- Migrations:
V001__description.sql,V002__description.sql - Primary keys:
id BIGINT AUTO_INCREMENT - Timestamps:
created_at DATETIME,updated_at DATETIME - Charset:
utf8mb4_unicode_ci - No polymorphic FKs — application-level integrity
gRPC / WebActions
- One gRPC service per domain:
PostsService,UsersService - Proto3 syntax, request/response naming:
CreatePostRequest,CreatePostResponse - WebActions path:
/api/v1/{resource}/{action} - JSON ↔ protobuf mapping handled server-side
Testing
- Unit tests: Core logic (pure Kotlin, no DB)
- Integration tests: Repositories against docker MySQL
- Service tests: gRPC services with test fixtures
- Test fakes mirror mainline paths —
core/users/FakeUserRepository.ktnottesting/FakeUserRepository.kt - No junk drawer packages — avoid generic
testing/orutil/packages
Commits
- Conventional commits:
feat(scope):,fix(scope):,chore: - Scopes:
core,db,adapters,misk,ui - One logical change per commit
Stacked PRs (Graphite)
This repo uses Graphite for stacked PR workflows:
gt log short # View current stack
gt create -m "feat: add feature" # Create branch + commit in stack
gt stack submit # Push all branches, create/update stacked PRs
gt stack restack # Rebase stack after parent merges
gt track --parent <branch> <branch> # Adopt existing branch into stack
- Always use
gt stack restackafter a parent branch merges — don't manually rebase - Use
gt createinstead ofgit checkout -bto keep branches tracked - PRs are automatically linked as a stack on GitHub
Authentication (Dev Mode)
For local development, the OSS version ships UnsafeDevLoginAction:
GET /api/v1/auth/dev-userslists available dev usersPOST /api/v1/auth/loginwith{"username":"dev-alice"}creates session- User is created in DB on first login if not exists
AuthModule is swappable — enterprises replace with real SSO.
Frontend (Misk UI)
- React + TypeScript in
web/directory - Built assets served by Misk at
/ - Build integration:
./gradlew buildWeb - Use standard React patterns (hooks, functional components)
- API calls via generated proto clients or fetch to WebActions
Ticket Guidance
When creating or working tickets:
- Behavioral AC only — "user can X", not "file exists"
- Human-comprehensible size — a reviewer should understand the full changeset without scrolling endlessly
- No standalone scaffolding — fold entity+repo+migration+impl into one vertical slice
- Split by capability — read vs write, not by code layer
- Facets are vertical slices — tickets are labeled by facet (e.g.,
auth,posts,comments) not by code layer
Code Style
- OOP naming: object→noun, method→verb — classes are true nouns (
PostResponse,PostService), methods are verbs (from(),create()). Avoid-ernouns that are just objectified verbs (Mapper,Builder,Handler) — if a class is named for what it does rather than what it is, rethink the design. Prefer companion factories (PostResponse.from(post, author)) over single-purpose-erclasses. - No sloppy overcommenting — code should be self-explanatory; comments are for "why", not "what"
- Don't narrate the obvious —
// Create a new userabovecreateUser()is noise - Match existing patterns — look at neighboring code before inventing new conventions
- Don't refactor unrelated code — only modify files/patterns directly required by the task; "improving" existing patterns creates review noise and merge conflicts
File Paths
| Path | Purpose |
|---|---|
src/main/resources/db/migration/ |
Flyway SQL migrations |
src/main/proto/ |
Proto definitions |
web/src/ |
React frontend |
Common Pitfalls
- Don't import Misk in
core/— keep domain pure - Don't put logic in
main()— use Guice modules - Don't use
internal— modules must be composable - Repository methods return
nullfor not-found — not exceptions
Agent Execution Tips
Environment Pre-flight (CRITICAL)
- Check for port conflicts before Docker: Run
lsof -i :3306— local MySQL will shadow Docker container - If port 3306 is in use: Change docker-compose to use port 3307 and update all JDBC URLs
- Diagnose connection issues at the source — auth errors often mean you're hitting the wrong MySQL instance
Dependency Strategy
- Always use latest stable versions — web search Maven Central / Gradle Plugin Portal before adding any dependency
- Never trust pinned versions — versions in tips files, old branches, or existing code are snapshots, not guidance
- Misk BOM is source of truth for Misk-related dependency versions — don't pin Misk modules separately
- Flyway on Gradle 9+: Use
net.ltgt.flywayplugin (notorg.flywaydb.flywaywhich uses deprecated APIs) - Solve compatibility at root cause — don't downgrade to work around issues, fix forward
Problem-Solving Approach
- Don't cargo-cult from old branches — understand WHY a config exists before copying it
- Separate problems — port conflicts, auth plugins, and plugin compatibility are distinct issues
- Fail fast on environment issues — check ports/connectivity before debugging code
Context Management
- Use
Tasksubagents for multi-file work — creating 5+ files or modifying across layers should be one Task call - Delegate research upfront — use
librarianororaclefor framework questions before writing code - Batch verification — write a verify script, don't run 5 separate curl commands
- Read tickets before implementing — they're in
tasks/, drift is expensive
Misk-Specific Gotchas
- YAML config files cannot be empty — use
{}as minimum content for environment overrides - Prometheus metrics on separate port — default 9102, not
/_admin/metricson main port - Add logback.xml early — Jetty DEBUG logs consume huge token counts; set level to WARN before testing
- Use
jakarta.injectnotjavax.injectfor annotations - WebAction methods can't take HttpCall as parameter — inject
ActionScoped<HttpCall>via constructor instead - Per-request scoped providers — extend
ActionScopedProviderModule, usebindProvider(T::class, TProvider::class); never use@Singleton @ProvideswithActionScopeddependencies - FakeHttpCall for tests — use
misk.web.FakeHttpCall(in misk-testing); it's inmisk.web, notmisk.testing
Build & Test
- DB tasks need
-PwithDbflag — Flyway/jOOQ tasks are skipped during normal build to allow cold builds - Prefer
gradle compileKotlinovergradle run— verify compilation without port conflicts - Hermit manages tooling — use
gradlenot./gradlew, activate withsource bin/activate-hermit - jOOQ generated code is committed — lives in
src/generated/jooq/, enables DB-free builds; regenerate withjust codegenafter migrations - Kill background processes — if you spawn
gradle runfor testing, kill it when done; checklsof -i :8080or:9102
jOOQ & Database Wiring
- DatabaseModule must provide DSLContext — jOOQ repos inject
DSLContext; the module needs a@Providesmethod - BuilderSyndicateModule must install DatabaseModule — auth actions depend on UserRepository
- Test FK constraints — when cleaning test data, delete child tables before parent (e.g., posts before users)
Prior Work
- Check git log first —
git log --oneline -10shows what's already done - Learnings in
tips/— files document non-obvious discoveries (environment-specific, not version pins) - Specs in
specs/specs/— PRD, engineering spec, implementation plan are authoritative - Old branches are not reference implementations — they may contain workarounds, not solutions
Reference Docs
PR Diff Hygiene (GitHub folding / noise files)
To keep PRs reviewer-friendly, configure GitHub’s default diff folding via a root-level .gitattributes and follow these rules:
- Root
.gitattributesis authoritative for GitHub Linguist.- Use
linguist-generated=trueto auto-collapse noisy files in PRs. - Use
-diffonly when line-by-line diffs are not useful (e.g., binary blobs). Prefer folding over suppressing diffs.
- Use
- Required patterns (kept up to date):
# Hermit and helper scripts: collapse in PRs as generated bin/hermit linguist-generated=true bin/activate-hermit linguist-generated=true bin/**/hermit* linguist-generated=true # Gradle wrapper and related binaries gradle/wrapper/** linguist-generated=true # Common generated/minified assets (UI) *.min.js linguist-generated=true *.map linguist-generated=true # Optional (use sparingly): completely suppress diffs # bin/hermit -diff # bin/activate-hermit -diff - If a change introduces new generated/noisy files, update
.gitattributesin the SAME PR. - Prefer
.gitignorefor ephemeral tooling outputs so they never enter the repo. - For already-open PRs, push a new commit after editing
.gitattributesso GitHub reclassifies and folds diffs. - Do not refer to internal branch acronyms in PR descriptions when the work maps to a ticket; link the issue instead (e.g.,
ref: #123).