Skip to main content

Thread System

Loom’s thread system provides persistent conversation history with bidirectional synchronization between local storage and the server. Every coding session is a thread that captures:
  • Complete message history (user, assistant, tool calls)
  • Agent state (waiting, executing tools, error)
  • Git context (branch, commits, remote URL)
  • Workspace metadata

Architecture

┌─────────────────┐
│  loom CLI       │
│  ┌───────────┐  │
│  │  Thread   │  │
│  └─────┬─────┘  │
│        │        │
└────────┼────────┘


┌────────────────────┐       ┌──────────────────┐
│ SyncingThreadStore │◀─────▶│ LocalThreadStore │
└────────┬───────────┘       └──────────────────┘
         │                    ~/.local/share/loom/threads/

┌────────────────────┐
│ ThreadSyncClient   │
└────────┬───────────┘
         │ HTTPS

┌────────────────────┐
│ loom-server        │
│ /api/threads       │
└────────┬───────────┘


┌────────────────────┐
│ PostgreSQL         │
│ threads table      │
└────────────────────┘
Components:
  • Thread: Core data model (defined in loom-common-thread/src/model.rs)
  • LocalThreadStore: XDG-compliant local storage
  • SyncingThreadStore: Hybrid store with background sync
  • ThreadSyncClient: HTTP client for server communication
  • PendingSyncQueue: Offline-first sync queue

Thread Data Model

Thread Structure

pub struct Thread {
    // Identity
    pub id: ThreadId,                    // T-<uuidv7>
    pub version: u64,                    // Optimistic locking
    pub created_at: String,              // ISO 8601
    pub updated_at: String,
    pub last_activity_at: String,

    // Workspace context
    pub workspace_root: Option<String>,  // /home/user/project
    pub cwd: Option<String>,             // /home/user/project/src
    pub loom_version: Option<String>,    // 0.1.0

    // Git metadata
    pub git_branch: Option<String>,              // main
    pub git_remote_url: Option<String>,          // github.com/user/repo
    pub git_initial_branch: Option<String>,
    pub git_initial_commit_sha: Option<String>,
    pub git_current_commit_sha: Option<String>,
    pub git_commits: Vec<String>,                // [sha1, sha2, ...]
    pub git_start_dirty: Option<bool>,
    pub git_end_dirty: Option<bool>,

    // LLM configuration
    pub provider: Option<String>,        // anthropic
    pub model: Option<String>,           // claude-3-5-sonnet-20241022

    // Conversation data
    pub conversation: ConversationSnapshot,
    pub agent_state: AgentStateSnapshot,
    pub metadata: ThreadMetadata,

    // Sync metadata
    pub is_private: bool,                // Never sync if true
    pub visibility: ThreadVisibility,    // organization, private, public
    pub is_shared_with_support: bool,
}

Thread ID Format

Thread IDs use UUIDv7 (time-ordered) with a T- prefix:
T-018e2b3c-4d5e-7f8a-9b0c-1d2e3f4a5b6c
│ └────────────────┬──────────────────┘
│                  └─ UUIDv7 (sortable by creation time)
└─ Prefix for visual identification
Benefits:
  • Sortable by creation time
  • No collision risk (globally unique)
  • Human-readable prefix
  • Compatible with databases (indexed strings)

Message Types

{
  "role": "user",
  "content": "Add error handling to login function"
}

Agent States

pub enum AgentStateKind {
    WaitingForUserInput,      // Idle, ready for input
    CallingLlm,               // Sending request to LLM
    ProcessingLlmResponse,    // Parsing LLM response
    ExecutingTools,           // Running tool calls
    PostToolsHook,            // Post-tool processing (e.g., auto-commit)
    Error,                    // Recoverable error (retries possible)
    ShuttingDown,             // Graceful shutdown in progress
}
Each state includes:
  • Retry count (for error recovery)
  • Last error message (if applicable)
  • Pending tool calls (for ExecutingTools)

Thread Visibility

pub enum ThreadVisibility {
    Organization,  // Visible to all org members (default)
    Private,       // Synced but only owner can access
    Public,        // Publicly visible (use with caution)
}
Sharing workflow:
# Share with organization
loom share <thread-id> --visibility organization

# Share with support
loom share <thread-id> --support

# Make private
loom share <thread-id> --visibility private

Local Storage

XDG Directory

Threads are stored in:
~/.local/share/loom/threads/
├── T-018e2b3c-4d5e-7f8a-9b0c-1d2e3f4a5b6c.json
├── T-018e2b3c-5e6f-8a9b-0c1d-2e3f4a5b6c7d.json
└── ...
File format: Pretty-printed JSON (human-readable)

LocalThreadStore API

pub trait ThreadStore: Send + Sync {
    async fn load(&self, id: &ThreadId) -> Result<Option<Thread>>;
    async fn save(&self, thread: &Thread) -> Result<()>;
    async fn list(&self, limit: u32) -> Result<Vec<ThreadSummary>>;
    async fn delete(&self, id: &ThreadId) -> Result<()>;
    async fn save_and_sync(&self, thread: &Thread) -> Result<()>;
}
save_and_sync() blocks until server sync completes. Use for commands that exit immediately (e.g., loom share).
Fallback when server is unavailable:
pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<ThreadSummary>>
Searches:
  • Thread title
  • Git branch name
  • Git remote URL
  • Commit SHAs (prefix match)
  • Tags
  • Message content (full text)

Server Synchronization

Sync Strategy

Loom uses offline-first synchronization:
1

Save locally first

local_store.save(thread).await?;
2

Queue for sync

pending_sync_queue.enqueue(SyncOperation::Upsert(thread.id));
3

Background sync

Spawned task processes queue:
sync_client.upsert_thread(thread).await?;
4

Handle conflicts

Server returns latest version on conflict. Client merges or retries.
Benefits:
  • Works offline (local-first)
  • No blocking on network
  • Automatic retry on failure
  • Conflict detection via versioning

SyncingThreadStore

pub struct SyncingThreadStore {
    local: LocalThreadStore,
    sync_client: Option<ThreadSyncClient>,
    pending_sync: Arc<PendingSyncQueue>,
}
Usage:
let local_store = LocalThreadStore::from_xdg()?;
let sync_client = ThreadSyncClient::new(server_url, http_client)
    .with_auth_token(token);
let store = SyncingThreadStore::with_sync(local_store, sync_client);

Sync API Endpoints

POST /api/threads
PUT /api/threads/:id
Creates or updates a thread. Server validates:
  • Thread ownership (via auth token)
  • Version number (optimistic locking)
  • Visibility rules

Git Integration

Loom automatically captures git state:

Snapshot Workflow

fn snapshot_git_state(thread: &mut Thread, workspace_path: &Path) {
    match detect_repo_status(workspace_path) {
        Ok(Some(status)) => {
            // Initial state (first snapshot)
            if thread.git_initial_branch.is_none() {
                thread.git_initial_branch = status.branch.clone();
                thread.git_initial_commit_sha = status.head.as_ref().map(|h| h.sha.clone());
                thread.git_start_dirty = status.is_dirty;
            }

            // Current state (every save)
            thread.git_branch = status.branch;
            thread.git_current_commit_sha = status.head.as_ref().map(|h| h.sha.clone());
            thread.git_end_dirty = status.is_dirty;

            // Track commits
            if let Some(ref sha) = status.head.as_ref().map(|h| h.sha.clone()) {
                if !thread.git_commits.contains(sha) {
                    thread.git_commits.push(sha.clone());
                }
            }

            // Remote URL (normalized)
            if thread.git_remote_url.is_none() {
                thread.git_remote_url = status.remote_slug;
            }
        }
        Ok(None) => { /* Not a git repo */ }
        Err(e) => { /* Git unavailable */ }
    }
}
Called:
  • On thread creation
  • After every tool execution
  • On graceful shutdown (Ctrl+C, EOF)

Search by Git Metadata

# Find threads for a branch
loom search "feature/auth"

# Find threads for a repository
loom search "github.com/user/project"

# Find threads with specific commit
loom search "a1b2c3d"

Private Sessions

Create local-only sessions that never sync:
loom private
Sets:
  • is_private = true
  • visibility = Private
The SyncingThreadStore skips sync for private threads:
if thread.is_private {
    tracing::debug!("skipping sync for private thread");
    return Ok(());
}
Use cases:
  • Sensitive codebases
  • Offline development
  • Personal experiments

Thread Lifecycle

1

Creation

let mut thread = Thread::new();
thread.workspace_root = Some(workspace.display().to_string());
snapshot_git_state(&mut thread, &workspace);
store.save(&thread).await?;
2

User Input

let user_message = Message::user(input);
thread.conversation.messages.push(MessageSnapshot::from(&user_message));
3

LLM Response

let assistant_message = Message::assistant_with_tool_calls(content, tool_calls);
thread.conversation.messages.push(MessageSnapshot::from(&assistant_message));
4

Tool Execution

for tool_call in &tool_calls {
    let outcome = execute_tool(registry, tool_call, &ctx).await;
    let tool_message = Message::tool(&tool_call.id, &tool_call.tool_name, &result);
    thread.conversation.messages.push(MessageSnapshot::from(&tool_message));
}
5

State Update

thread.agent_state = AgentStateSnapshot {
    kind: AgentStateKind::WaitingForUserInput,
    retries: 0,
    last_error: None,
    pending_tool_calls: Vec::new(),
};
snapshot_git_state(&mut thread, &workspace);
thread.touch();  // Update last_activity_at
store.save(&thread).await?;

Resume Behavior

loom resume <thread-id>
Restores:
  • Full conversation history
  • Agent state (resumes from last checkpoint)
  • Workspace context (warns if workspace changed)
  • Git state (shows branch/commit changes)
Example output:
Resuming thread: T-018e2b3c-4d5e-7f8a-9b0c-1d2e3f4a5b6c
Title: "Add authentication to API"
Messages: 23
Last activity: 2 hours ago
Git: main @ a1b2c3d (dirty)

Search Implementation

PostgreSQL full-text search:
CREATE INDEX threads_search_idx ON threads USING gin(to_tsvector('english', 
    coalesce(title, '') || ' ' || 
    coalesce(git_branch, '') || ' ' ||
    coalesce(conversation::text, '')
));
Query:
SELECT * FROM threads
WHERE to_tsvector('english', ...) @@ plainto_tsquery('english', $1)
ORDER BY ts_rank(...) DESC
LIMIT $2;

Local Search Fallback

Substring matching across all fields:
fn matches_query(&self, thread: &Thread, query: &str) -> bool {
    // Title
    if thread.metadata.title.as_ref().map(|t| t.to_lowercase().contains(query)).unwrap_or(false) {
        return true;
    }
    // Git metadata
    if thread.git_branch.as_ref().map(|b| b.to_lowercase().contains(query)).unwrap_or(false) {
        return true;
    }
    // Commit SHAs (prefix)
    for sha in &thread.git_commits {
        if sha.to_lowercase().starts_with(query) {
            return true;
        }
    }
    // Messages
    for msg in &thread.conversation.messages {
        if msg.content.to_lowercase().contains(query) {
            return true;
        }
    }
    false
}

Performance Optimizations

Lazy Loading

Thread summaries load minimal data. Full threads fetched on demand.

Background Sync

Non-blocking sync prevents UI lag. Errors retried automatically.

Local Caching

Recently accessed threads cached in memory (future enhancement).

Indexed Search

PostgreSQL GIN indexes for fast full-text queries.

Troubleshooting

Check network connectivity:
curl https://loom.ghuntley.com/health
Verify authentication:
loom login
The thread is still saved locally. Sync retries automatically.
List all threads:
loom list
Search by content:
loom search "keyword"
Check local storage:
ls ~/.local/share/loom/threads/
Happens when thread modified on multiple devices simultaneously.The server returns HTTP 409. Client should:
  1. Fetch latest version from server
  2. Merge changes (manual or automatic)
  3. Retry with updated version
Try:
  • Broader query terms
  • Different keywords (branch name, repo URL)
  • Local search if server is down
Verify thread exists:
loom list | grep <keyword>

Build docs developers (and LLMs) love