Arabica - Project Context for AI Agents#
Coffee brew tracking application using AT Protocol for decentralized storage.
Work Management#
This project uses cells for task tracking and coordination. Cells are atomic, dependency-aware units of work designed for coordination between humans and AI agents.
For usage instructions, see: .cells/AGENTS.md
Quick reference for AI agents:
./cells list- View all active cells./cells list --status open- Show available work./cells show <cell-id>- View cell details
Note: Do NOT use ./cells run - this spawns a new agent session and should only be used by humans. AI agents should inspect cells using the commands above and perform work directly.
All work items are tracked as cells. When starting new work, check for existing cells first.
Workflow Rules#
Do NOT spend more than 2-3 minutes exploring/reading files before beginning implementation. If the task is clear, start writing code immediately. Ask clarifying questions rather than endlessly reading the codebase. When given a specific implementation task, produce code changes in the same session.
Dependencies#
When implementing features, prefer standard library solutions over external dependencies. Only add a third-party dependency if the standard library genuinely cannot handle the requirement. For Go: check stdlib first (e.g., os.Stdout for TTY detection). For JS/TS: check built-in APIs before npm packages.
Task Agents#
When spawning task agents, set a hard limit of 3 agents maximum. Each agent must have a clearly scoped deliverable and file output path. Do not poll agents in a loop—instead, give each agent its full instructions upfront and collect results at the end. If agents aren't producing results within 5 minutes, fall back to doing the work directly.
Testing & Verification#
For Go projects: always run go vet ./... and go build ./... after making changes. For JavaScript/CSS projects: verify template field names match backend struct fields before considering a task complete. Always test form submissions to verify content-type handling (JSON vs form-encoded).
Using Go Tooling Effectively#
- To see source files from a dependency, or to answer questions about a dependency, run
go mod download -json MODULEand use the returnedDirpath to read the files. - Use
go doc foo.Barorgo doc -all footo read documentation for packages, types, functions, etc. - Use
go run .orgo run ./cmd/fooinstead ofgo buildto run programs, to avoid leaving behind build artifacts.
Tech Stack#
- Language: Go 1.21+
- HTTP: stdlib
net/httpwith Go 1.22 routing - Storage: AT Protocol PDS (user data), BoltDB (sessions/feed registry)
- Frontend: HTMX + Alpine.js + Tailwind CSS
- Templates: Templ (type-safe Go templates)
- Logging: zerolog
Project Structure#
cmd/server/main.go # Application entry point
internal/
atproto/ # AT Protocol integration
client.go # Authenticated PDS client (XRPC calls)
oauth.go # OAuth flow with PKCE/DPOP
store.go # database.Store implementation using PDS
cache.go # Per-session in-memory cache
records.go # Model <-> ATProto record conversion
resolver.go # AT-URI parsing and reference resolution
public_client.go # Unauthenticated public API access
nsid.go # Collection NSIDs and AT-URI builders
handlers/
handlers.go # HTTP handlers for all routes
auth.go # OAuth login/logout/callback
web/
bff/
helpers.go # View helpers (formatting, UserProfile)
components/ # Reusable Templ components
layout.templ # Base HTML layout component
header.templ # Navigation header
footer.templ # Site footer
shared.templ # Shared UI components (EmptyState, PageHeader, etc.)
buttons.templ # Button components
forms.templ # Form input components
modal.templ # Modal dialog components
card.templ # Card container components
entity_modals.templ # Entity creation/edit modals
*_templ.go # Generated Go code (auto-generated by templ)
pages/ # Full-page Templ components
home.templ # Home page
about.templ # About page
brew_list.templ # Brew list page
brew_view.templ # Brew detail page
brew_form.templ # Brew creation/edit form
manage.templ # Entity management page
profile.templ # User profile page
feed.templ # Community feed page
*_templ.go # Generated Go code
database/
store.go # Store interface definition
boltstore/ # BoltDB implementation for sessions
feed/
service.go # Community feed aggregation
registry.go # User registration for feed
moderation/
models.go # Moderation types (roles, permissions, reports)
service.go # Role-based moderation service
models/
models.go # Domain models and request types
middleware/
logging.go # Request logging middleware
routing/
routing.go # Router setup and middleware chain
lexicons/ # AT Protocol lexicon definitions (JSON)
config/ # Configuration files (moderators.json.example)
static/ # CSS, JS, manifest
Key Concepts#
AT Protocol Integration#
User data stored in their Personal Data Server (PDS), not locally. The app:
- Authenticates via OAuth (indigo SDK handles PKCE/DPOP)
- Gets access token scoped to user's DID
- Performs CRUD via XRPC calls to user's PDS
Collections (NSIDs):
social.arabica.alpha.bean- Coffee beanssocial.arabica.alpha.roaster- Roasterssocial.arabica.alpha.grinder- Grinderssocial.arabica.alpha.brewer- Brewing devicessocial.arabica.alpha.brew- Brew sessions (references bean, grinder, brewer)
Record keys: TID format (timestamp-based identifiers)
References: Records reference each other via AT-URIs (at://did/collection/rkey)
Store Interface#
internal/database/store.go defines the Store interface. Two implementations:
AtprotoStore- Production, stores in user's PDS- BoltDB stores only sessions and feed registry (not user data)
All Store methods take context.Context as first parameter.
Request Flow#
- Request hits middleware (logging, auth check)
- Auth middleware extracts DID + session ID from cookies
- Handler creates
AtprotoStorescoped to user - Store methods make XRPC calls to user's PDS
- Results rendered via Templ components or returned as JSON
Caching#
SessionCache caches user data in memory (5-minute TTL):
- Avoids repeated PDS calls for same data
- Invalidated on writes
- Background cleanup removes expired entries
Backfill Strategy#
User records are backfilled from their PDS once per DID:
- On startup: Backfills registered users + known-dids file
- On first login: Backfills the user's historical records
- Deduplication: Tracks backfilled DIDs in
BucketBackfilledto prevent redundant fetches - Idempotent: Safe to call multiple times (checks backfill status first)
This prevents excessive PDS requests while ensuring new users' historical data is indexed.
Templ Architecture#
The application uses Templ for type-safe, component-based HTML templating. Templ generates Go code at compile time, providing full type safety and IDE support.
Component Structure#
Components are organized into two categories:
Pages (internal/web/pages/) - Full-page components that compose the layout with content:
- Each page component accepts
LayoutDataand page-specific props - Pattern:
PageName(layoutData *components.LayoutData, props PageProps) - Examples:
Home(),BrewList(),BrewView(),Profile()
Components (internal/web/components/) - Reusable UI building blocks:
layout.templ- Base HTML layout with head, body, header, footershared.templ- Common UI elements (EmptyState, PageHeader, LoadingSkeletonTable, etc.)buttons.templ- PrimaryButton, SecondaryButton, BackButtonforms.templ- TextInput, NumberInput, TextArea, Select, FormFieldmodal.templ- Modal dialogs with Alpine.js integrationcard.templ- Card containersentity_modals.templ- Entity creation/edit forms
LayoutData Pattern#
Every page shares a common LayoutData struct that contains authentication state and user information:
type LayoutData struct {
Title string
IsAuthenticated bool
UserDID string
UserProfile *bff.UserProfile
CSPNonce string
}
This data flows from handlers to the layout component, ensuring consistent header/footer rendering across all pages.
Handler Integration#
Handlers follow a consistent pattern:
func (h *Handler) HandlePageName(w http.ResponseWriter, r *http.Request) {
// 1. Get authentication state and user data
store, authenticated := h.getAtprotoStore(r)
userProfile, _ := h.getUserProfile(r, store)
// 2. Fetch page-specific data from store
data, err := store.GetData(r.Context())
// 3. Create LayoutData
layoutData := &components.LayoutData{
Title: "Page Title",
IsAuthenticated: authenticated,
UserDID: userDID,
UserProfile: userProfile,
CSPNonce: nonce,
}
// 4. Create page-specific props
pageProps := pages.PageProps{
Data: data,
// ... other props
}
// 5. Render templ component
if err := pages.PageName(layoutData, pageProps).Render(r.Context(), w); err != nil {
http.Error(w, "Failed to render page", http.StatusInternalServerError)
}
}
The Render(r.Context(), w) Pattern#
All templ components implement the templ.Component interface, which includes a Render(ctx context.Context, w io.Writer) method. This method:
- Takes the request context for cancellation/timeout support
- Writes directly to the http.ResponseWriter
- Returns an error if rendering fails
- Is type-safe at compile time
Example:
if err := pages.Home(layoutData, homeProps).Render(r.Context(), w); err != nil {
http.Error(w, "Failed to render page", http.StatusInternalServerError)
}
Component Composition#
Templ components compose naturally using the @ syntax:
// Page component wraps layout
templ Home(layout *components.LayoutData, props HomeProps) {
@components.Layout(layout, HomeContent(props))
}
// Content component uses shared components
templ HomeContent(props HomeProps) {
<div class="max-w-4xl mx-auto">
@components.WelcomeCard(components.WelcomeCardProps{
IsAuthenticated: props.IsAuthenticated,
})
@CommunityFeedSection()
</div>
}
Component Reusability#
Shared components accept props structs for configuration:
// EmptyState component with props
templ EmptyState(props EmptyStateProps) {
<div class="card card-inner text-center">
<p class="text-brown-800 text-lg mb-4 font-medium">{ props.Message }</p>
if props.SubMessage != "" {
<p class="text-sm text-brown-700 mb-4">{ props.SubMessage }</p>
}
if props.ActionURL != "" && props.ActionText != "" {
<a href={ templ.SafeURL(props.ActionURL) } class="btn-primary">
{ props.ActionText }
</a>
}
</div>
}
Used in pages:
@components.EmptyState(components.EmptyStateProps{
Message: "No brews yet",
SubMessage: "Start tracking your coffee journey",
ActionURL: "/brews/new",
ActionText: "Log Your First Brew",
})
HTMX Integration#
Templ works seamlessly with HTMX for dynamic content loading:
// Page with HTMX loading
<div hx-get="/api/brews" hx-trigger="load" hx-swap="innerHTML">
@LoadingSkeletonTable(LoadingSkeletonTableProps{Columns: 5, Rows: 3})
</div>
HTMX responses can render partial components:
func (h *Handler) HandleBrewsPartial(w http.ResponseWriter, r *http.Request) {
brews, _ := store.ListBrews(r.Context())
if err := components.BrewListTable(brews).Render(r.Context(), w); err != nil {
http.Error(w, "Failed to render", http.StatusInternalServerError)
}
}
Alpine.js Compatibility#
Alpine.js directives work natively in templ templates:
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open" x-transition>
Content
</div>
</div>
Modal components use Alpine.js for state management:
@Modal(ModalProps{
Show: "showBeanForm",
TitleExpr: "editingBean ? 'Edit Bean' : 'Add Bean'",
}, FormContent())
Type Safety Benefits#
Templ provides compile-time type checking:
// Props are strongly typed
type BrewViewProps struct {
Brew *models.Brew
Bean *models.Bean
Grinder *models.Grinder
Brewer *models.Brewer
IsOwner bool
}
// Compiler catches missing/wrong fields
pages.BrewView(layoutData, pages.BrewViewProps{
Brew: brew,
Bean: bean,
IsOwner: isOwner,
// Compile error: missing Grinder, Brewer fields
})
Common Tasks#
Run Development Server#
# Run server (uses firehose mode by default)
go run cmd/server/main.go
# Backfill known DIDs on startup
go run cmd/server/main.go --known-dids known-dids.txt
# Using nix
nix run
Run Tests#
go test ./...
Build#
go build -o arabica cmd/server/main.go
Generate Templ Components#
When you modify .templ files, you need to regenerate the Go code:
# Generate Go code from .templ files
templ generate
# Or in Nix environment
nix develop -c templ generate
# Watch mode for development (auto-regenerate on changes)
templ generate --watch
The generated *_templ.go should be regenerated whenever .templ files change.
Templ files must use tabs rather than spaces. Never use spaces for indentation in .templ files — the templ parser will error with a parse failure. This applies when writing new components, editing existing ones, and constructing multi-line template strings. A post-edit hook runs templ fmt automatically to catch any accidental spaces.
Command-Line Flags#
| Flag | Type | Default | Description |
|---|---|---|---|
--firehose |
bool | true | [DEPRECATED] Firehose is now the default (ignored) |
--known-dids |
string | "" | Path to file with DIDs to backfill (one per line) |
Known DIDs File Format:
- One DID per line (e.g.,
did:plc:abc123xyz) - Lines starting with
#are comments - Empty lines are ignored
- See
known-dids.txt.examplefor reference
Environment Variables#
| Variable | Default | Description |
|---|---|---|
PORT |
18910 | HTTP server port |
SERVER_PUBLIC_URL |
- | Public URL for reverse proxy |
ARABICA_DB_PATH |
~/.local/share/arabica/arabica.db | BoltDB path (sessions, registry) |
ARABICA_FEED_INDEX_PATH |
~/.local/share/arabica/feed-index.db | Firehose index BoltDB path |
ARABICA_MODERATORS_CONFIG |
- | Path to moderators JSON config |
ARABICA_PROFILE_CACHE_TTL |
1h | Profile cache duration |
OTEL_EXPORTER_OTLP_ENDPOINT |
localhost:4318 | OTLP HTTP endpoint for traces |
METRICS_PORT |
9101 | Internal metrics server port (localhost only) |
SECURE_COOKIES |
false | Set true for HTTPS |
LOG_LEVEL |
info | debug/info/warn/error |
LOG_FORMAT |
console | console/json |
Code Patterns#
Creating a Store#
// In handlers, store is created per-request
store, authenticated := h.getAtprotoStore(r)
if !authenticated {
http.Error(w, "Authentication required", http.StatusUnauthorized)
return
}
// Use store with request context
brews, err := store.ListBrews(r.Context(), userID)
Record Conversion#
// Model -> ATProto record
record, err := BrewToRecord(brew, beanURI, grinderURI, brewerURI)
// ATProto record -> Model
brew, err := RecordToBrew(record, atURI)
AT-URI Handling#
// Build AT-URI
uri := BuildATURI(did, NSIDBean, rkey) // at://did:plc:xxx/social.arabica.alpha.bean/abc
// Parse AT-URI
components, err := ResolveATURI(uri)
// components.DID, components.Collection, components.RKey
Rendering Pages with Templ#
// Standard page rendering pattern
func (h *Handler) HandleBrewList(w http.ResponseWriter, r *http.Request) {
store, authenticated := h.getAtprotoStore(r)
if !authenticated {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
// Fetch data
brews, err := store.ListBrews(r.Context(), userDID)
if err != nil {
http.Error(w, "Failed to load brews", http.StatusInternalServerError)
return
}
// Create layout data
layoutData := &components.LayoutData{
Title: "My Brews",
IsAuthenticated: authenticated,
UserDID: userDID,
UserProfile: userProfile,
CSPNonce: nonce,
}
// Create page props
brewListProps := pages.BrewListProps{
Brews: brews,
}
// Render templ component
if err := pages.BrewList(layoutData, brewListProps).Render(r.Context(), w); err != nil {
http.Error(w, "Failed to render page", http.StatusInternalServerError)
}
}
Rendering HTMX Partials with Templ#
// Partial component for HTMX responses
func (h *Handler) HandleBrewsPartial(w http.ResponseWriter, r *http.Request) {
store, authenticated := h.getAtprotoStore(r)
if !authenticated {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
brews, err := store.ListBrews(r.Context(), userDID)
if err != nil {
http.Error(w, "Failed to load brews", http.StatusInternalServerError)
return
}
// Render just the table component (no layout)
if err := components.BrewListTable(brews, userDID).Render(r.Context(), w); err != nil {
http.Error(w, "Failed to render", http.StatusInternalServerError)
}
}
Testing Conventions#
IMPORTANT: All tests in this codebase MUST use testify/assert for assertions. Do NOT use if statements with t.Error() or t.Errorf().
// CORRECT: Use testify assert
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFormatTemp(t *testing.T) {
got := FormatTemp(93.5)
assert.Equal(t, "93.5°C", got)
}
func TestPtrEquals(t *testing.T) {
val := 42
assert.True(t, PtrEquals(&val, 42))
assert.False(t, PtrEquals(&val, 99))
}
func TestRenderedHTML(t *testing.T) {
html := renderComponent()
assert.Contains(t, html, "btn-primary")
assert.NotContains(t, html, "deprecated-class")
}
// WRONG: Don't use if statements for assertions
func TestFormatTemp(t *testing.T) {
got := FormatTemp(93.5)
if got != "93.5°C" { // ❌ Don't do this
t.Errorf("got %q, want %q", got, "93.5°C")
}
}
Common testify assertions:
assert.Equal(t, expected, actual)- Equality checkassert.NotEqual(t, expected, actual)- Inequality checkassert.True(t, value)- Boolean trueassert.False(t, value)- Boolean falseassert.Nil(t, value)- Nil checkassert.NotNil(t, value)- Not nil checkassert.Contains(t, haystack, needle)- Substring/element checkassert.NotContains(t, haystack, needle)- Negative substring/element checkassert.NoError(t, err)- No error occurredassert.Error(t, err)- Error occurred
Future Vision: Social Features#
The app currently has a basic community feed. Future plans expand social interactions leveraging AT Protocol's decentralized nature.
Planned Lexicons#
social.arabica.alpha.like - Like a brew (references brew AT-URI)
social.arabica.alpha.comment - Comment on a brew
social.arabica.alpha.follow - Follow another user
social.arabica.alpha.share - Re-share a brew to your feed
Like Record (Planned)#
{
"lexicon": 1,
"id": "social.arabica.alpha.like",
"defs": {
"main": {
"type": "record",
"key": "tid",
"record": {
"type": "object",
"required": ["subject", "createdAt"],
"properties": {
"subject": {
"type": "ref",
"ref": "com.atproto.repo.strongRef",
"description": "The brew being liked"
},
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
}
}
Comment Record (Planned)#
{
"lexicon": 1,
"id": "social.arabica.alpha.comment",
"defs": {
"main": {
"type": "record",
"key": "tid",
"record": {
"type": "object",
"required": ["subject", "text", "createdAt"],
"properties": {
"subject": {
"type": "ref",
"ref": "com.atproto.repo.strongRef",
"description": "The brew being commented on"
},
"text": {
"type": "string",
"maxLength": 1000,
"maxGraphemes": 300
},
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
}
}
Implementation Approach#
Cross-user interactions:
- Likes/comments stored in the actor's PDS (not the brew owner's)
- Use
public_client.goto read other users' brews - Aggregate likes/comments via relay/firehose or direct PDS queries
Feed aggregation:
- Current: Poll registered users' PDS for brews
- Future: Subscribe to firehose for real-time updates
- Index social interactions in local DB for fast queries
UI patterns:
- Like button on brew cards in feed
- Comment thread below brew detail view
- Share button to re-post with optional note
- Notification system for interactions on your brews
Key Design Decisions#
- Strong references - Likes/comments use
com.atproto.repo.strongRef(URI + CID) to ensure the referenced brew hasn't changed - Actor-owned data - Your likes live in your PDS, not the brew owner's
- Public by default - Social interactions are public records, readable by anyone
- Portable identity - Users can switch PDS and keep their social graph
Deployment Notes#
CSS Cache Busting#
When making CSS/style changes, bump the version query parameter in internal/web/components/layout.templ:
<link rel="stylesheet" href="/static/css/output.css?v=0.1.3" />
Cloudflare caches static assets, so incrementing the version ensures users get the updated styles.
Templ Code Generation#
Templ templates must be compiled to Go code before building:
# Generate Go code from .templ files
templ generate
# Or in Nix environment
nix develop -c templ generate
This is automatically handled by the build process, but you may need to run it manually during development.