FRAY uses a GUID-based architecture inspired by beads, enabling:
- Multi-machine coordination (git-mergeable)
- Cross-channel operations via a global channel registry
- Stable identifiers (GUIDs) with short display prefixes in UI
- Append-only JSONL storage (source of truth)
- SQLite cache (rebuildable from JSONL)
.fray/
fray-config.json # Project config (channel_id, known_agents, nicks)
messages.jsonl # Append-only source of truth
agents.jsonl # Append-only source of truth
questions.jsonl # Append-only source of truth
threads.jsonl # Append-only source of truth (threads + events)
history.jsonl # Pruned messages archive (optional)
.gitignore # Ignores *.db files
fray.db # SQLite cache (gitignored, rebuildable)
fray.db-wal # SQLite write-ahead log (gitignored)
fray.db-shm # SQLite shared memory (gitignored)
~/.config/fray/
fray-config.json # Global channel registry
cmd/
fray/ # CLI entry point
fray-mcp/ # MCP server entry point
internal/
chat/ # Bubble Tea chat UI
model.go # Core model and update loop
viewport.go # Viewport management
input.go # Input handling
messages.go # Message rendering
panels.go # Panel switching
questions.go # Question handling
suggestions.go # Autocomplete
layout.go # Layout calculations
colors.go # Theme and styling
highlighting.go # Syntax highlighting
command/ # Cobra CLI commands
hooks/ # Claude Code hook infrastructure
hook_install.go
hook_session.go
hook_prompt.go
hook_precommit.go
hook_precompact.go
hook_statusline.go
core/ # Project discovery, GUIDs, mentions, time parsing
db/ # Database layer
queries_agents.go # Agent CRUD
queries_messages.go # Message CRUD, reactions, replies
queries_threads.go # Thread CRUD, subscriptions
queries_questions.go # Question CRUD
queries_claims.go # Claims and collision detection
queries_config.go # Config, filters, read tracking
queries.go # Shared query helpers
jsonl_append.go # JSONL append operations
jsonl_read.go # JSONL read and version history
jsonl_rebuild.go # Database rebuild from JSONL
jsonl.go # JSONL types and constants
schema.go # SQLite schema
open.go # Database opening and auto-rebuild
types/ # Shared Go types
mcp/ # MCP server implementation
Format: <type>-<8char-base36> (0-9a-z)
- Messages:
msg-a1b2c3d4 - Agents:
usr-x9y8z7w6 - Channels:
ch-fraydev12
Why short GUIDs?
- 8 chars base36 = large space (36^8)
- Readable in logs/debugging
- Not catastrophic if collision (just reassign)
Format: #<guid-prefix>
- UI shows short prefixes like
#a1b2/#a1b2c/#a1b2c3 - Length grows with message count (4/5/6 chars)
- No separate display-id table; the canonical ID is always the GUID
Messages are append-only. Edits and deletes append message_update records.
{"type":"message","id":"msg-a1b2c3d4","channel_id":"ch-fraydev12","home":"room","from_agent":"adam","body":"@bob status","mentions":["bob"],"reactions":{"👍":["bob"]},"message_type":"agent","reply_to":null,"ts":1734612000,"edited_at":null,"archived_at":null}
{"type":"message_update","id":"msg-a1b2c3d4","body":"@bob updated status","edited_at":1734612600}home controls visibility: "room" is surfaced, thread GUIDs are hidden in the room. references and surface_message support surfacing (quote-retweet + backlink events). message_type can be surface for surfaced posts.
{"type":"agent","id":"usr-x9y8z7w6","name":"adam","global_name":"fray-adam","home_channel":"ch-fraydev12","created_at":"2025-12-19T09:00:00Z","active_status":"active","agent_id":"adam","status":"working","purpose":"developer relations","registered_at":1734608400,"last_seen":1734609000,"left_at":null}active_status is legacy; current presence is derived from last_seen/left_at with a staleness window.
{"type":"question","guid":"qstn-a1b2c3d4","re":"target market","from_agent":"party","to":"alice","status":"unasked","thread_guid":null,"asked_in":null,"answered_in":null,"created_at":1735500000}
{"type":"question_update","guid":"qstn-a1b2c3d4","status":"open","asked_in":"msg-x1y2z3w4"}{"type":"thread","guid":"thrd-b2c3d4e5","name":"market-analysis","parent_thread":null,"subscribed":["alice","bob"],"status":"open","created_at":1735500000}
{"type":"thread_subscribe","thread_guid":"thrd-b2c3d4e5","agent_id":"charlie","subscribed_at":1735500100}
{"type":"thread_message","thread_guid":"thrd-b2c3d4e5","message_guid":"msg-aaa","added_by":"alice","added_at":1735500200}{
"version": 1,
"channel_id": "ch-fraydev12",
"channel_name": "fray",
"created_at": "2025-12-19T10:00:00Z",
"known_agents": {
"usr-x9y8z7w6": {
"name": "adam",
"global_name": "fray-adam",
"home_channel": "ch-fraydev12",
"created_at": "2025-12-19T09:00:00Z",
"status": "working",
"nicks": ["devrel"]
}
}
}{
"version": 1,
"channels": {
"ch-fraydev12": {
"name": "fray",
"path": "/Users/adam/dev/fray"
},
"ch-party": {
"name": "party",
"path": "/Users/adam/dev/party"
}
}
}The SQLite cache stores runtime config like username, stale_hours, and the
current channel metadata (channel_id, channel_name). This is distinct from
.fray/fray-config.json and the global registry.
- Agent IDs are lowercase and may include numbers, hyphens, and dot segments
(e.g.,
alice,frontend-dev,alice.frontend,pm.3.sub). - Known agents are stored in
.fray/fray-config.jsonto resolve nicks. - Global names are stored as
channelName-agentIDfor disambiguation. - @mention prefix matching uses
.as a separator;@allis a broadcast. hereis computed fromlast_seen/left_atand a staleness window, not stored.
- Threads are playlists. Messages have a single
homeand can be curated into additional threads viafray_thread_messages. - Thread subscriptions live in
fray_thread_subscriptionsand are rebuilt fromthreads.jsonl(initialsubscribedlist + events). - Questions live in
fray_questions, optionally scoped to a thread viathread_guid.
cd ~/dev/fray
fray init
# Prompts for a channel name, creates .fray/, registers in global config.fray post --as adam "update" --in party
fray get --in party --last 10
fray chat partyChannel context resolution:
--in <channel>(matches by ID or name in global config)- Local
.fray/project config
fray ls # List registered channels
fray init # Create .fray/, register globallyfray prune # Keep last 100 messages, archive rest
fray prune --all # Wipe history.jsonl too
fray prune --keep 50 # Keep last 50 messagesWhat happens:
- Read messages.jsonl
- Append existing messages to history.jsonl (unless --all)
- Keep last N in messages.jsonl
- Rebuild SQLite from messages.jsonl
Guardrails:
- Requires a clean
.fray/git state - If the repo has an upstream, it must be in sync
In chat:
#abcd yes that works!
Behind the scenes:
- Parse
#abcd→msg-a1b2c3d4 - Strip from body: "yes that works!"
- Set
reply_tofield:msg-a1b2c3d4
Display:
- Byline with a colored background (
@agent:) - Body tinted to match the byline color
- Reply context line (
↪ Reply to @agent: ...) when available - Meta line
#abcd(dim) for the message GUID prefix - Click a message to prefill a threaded reply (
#id ...) - Double-click a message to copy it
fray migrateWhat it does:
- Copies
.fray/to.fray.bak/ - Generates GUIDs for agents/messages if missing
- Creates
messages.jsonlandagents.jsonl - Writes
.fray/fray-config.json - Rebuilds SQLite from JSONL and restores read receipts
- Registers the channel in global config
# Machine A
fray post @dev "update"
git add .fray/messages.jsonl .fray/agents.jsonl
git commit -m "Add message"
git push
# Machine B
git pull
# fray rebuilds SQLite from JSONL as needed
fray get # Sees new messageJSONL is append-only:
- messages.jsonl can be merged by appending both sides
- agents.jsonl conflicts are rare (GUID collision)
SQLite rebuild:
- After git merge, rebuild from JSONL
- GUIDs remain stable across machines
- Cross-project coordination via registered channels
- Reference messages via stable GUIDs
- Multi-machine sync via git (no server needed)
- Prune old messages without losing history
- JSONL + SQLite cache (rebuildable)
- GUIDs avoid ID collisions across machines
- Display prefixes are derived, not stored
- Git-mergeable logs with clear provenance
- Update
CHANGELOG.mdwith the new version and changes. - Keep
package.jsonversion in sync with the changelog. - Merge to
maintriggers the release workflow. - The workflow tags
vX.Y.Z, builds artifacts via GoReleaser, publishes a GitHub release, updates the Homebrew formula, and publishes the npm package via trusted publishing.
GITHUB_TOKENis provided automatically and is used for the GitHub release.HOMEBREW_TAP_TOKENis optional; if unset, the workflow falls back toGITHUB_TOKEN.- If branch protection blocks GitHub Actions from pushing formula updates, add a PAT as
HOMEBREW_TAP_TOKENor allow the Actions bot to push.
- If branch protection blocks GitHub Actions from pushing formula updates, add a PAT as
- npm trusted publishing uses GitHub OIDC; no
NPM_TOKENis required.
- Manually run the
Releaseworkflow and setforce=trueto publish even if the tag already exists.