The Loom Guide¶
A complete, single-page reference for the Loom macOS workspace app. Covers installation, every pane, every settings tab, the agent stack, the kanban, the auto-update pipeline, the release flow, and the underlying architecture.
Loom is a personal command center for builders. One window holds your terminal, editor, AI agent, and task board side by side. Local-first, no tenants, no tiers, no cloud sync.
This guide is generated and maintained alongside the app. The hosted MkDocs version of these chapters lives at bigbeardedman.github.io/Loom.
Table of Contents¶
- Overview
- Install
- First Run
- Workspaces
- Prompt Workspace
- Ideas Workspace
- Review Workspace
- Layout System
- Panes
- Terminal
- Editor
- Tasks (Kanban)
- Notes
- Preview
- Agent
- Agent Providers
- Claude Code (Default)
- Anthropic API (Direct)
- Local LLMs (Ollama and OpenAI compatible)
- Custom Providers
- Live Agent Tasks
- Task Handoff
- Usage Dashboard
- Settings
- Updates
- Keyboard Shortcuts
- Architecture
- Security Model
- Reference
- Releasing a New Build
- Building from Source
- Troubleshooting
- Roadmap
1. Overview¶
Loom is a native macOS workspace app built in SwiftUI on Swift 6 with strict concurrency. It targets macOS 14 (Sonoma) and above. The app combines four first-class capabilities in one resizable window:
| Capability | Built on | Notes |
|---|---|---|
| Terminal | SwiftTerm | PTY-backed login shell with click-to-position cursor |
| Editor | SwiftUI TextEditor |
File tree with breadcrumb, plain-text editing |
| AI Agent | Claude Code subprocess + HTTP providers | Sub-agent picker, local LLM streaming |
| Tasks | SwiftData kanban | Five fixed columns, task-to-agent and task-to-terminal handoff |
Loom is single-user by design. There are no accounts, no telemetry, no hosted control plane. Every secret lives in the macOS Keychain. Every persistent record lives in SwiftData on disk. Network access is limited to four code paths: GitHub Releases polling, the Anthropic API (when configured), user-defined local LLM endpoints, and the in-app web preview.
Product Principles¶
- Personal command center first. Optimize for one builder moving quickly.
- Local-first by default. Tasks, settings, workspace metadata, and agent configuration live on this Mac unless explicitly synced.
- Bring your own providers. API keys live in Keychain; provider integrations stay replaceable.
- No artificial tiers. If Loom can do something locally, it should be available.
- Terminal work should be reviewable. Commands, output, exit status, and agent actions become structured history over time.
Status¶
Current shipping version: see project.yml MARKETING_VERSION. Loom 1.x
covers the four-pane cockpit, multi-vendor agent integration (Claude Code,
Anthropic API, Ollama, OpenAI compatible), the live agent tasks mirror, the
SwiftData kanban with handoff, the rolling Usage dashboard, stable local
codesigning, and SHA-256 verified over-the-air updates.
2. Install¶
Loom ships as a notarized-shape but ad-hoc-signed .dmg from GitHub Releases.
Steps¶
- Download the latest
Loom-<version>.dmgfrom the Releases page. - Open the DMG.
- Drag Loom onto the Applications alias inside the mounted volume.
- Eject the volume.
First launch (Gatekeeper)¶
The build is ad-hoc signed (no Apple Developer ID), so macOS will refuse to launch it on the first try with the standard "Apple cannot check it for malicious software" dialog.
Bypass once:
- Open
/Applicationsin Finder. - Right-click Loom, choose Open.
- Confirm in the dialog.
Subsequent launches behave normally. macOS remembers the override.
Requirements¶
- macOS 14 Sonoma or later (uses
@Observable, SwiftData, Swift 6 strict concurrency). - Internet access for: GitHub release polling (60 second cadence), the optional Anthropic API, and any local LLM endpoint you configure.
- Optional:
claude(Claude Code CLI) on yourPATHif you want the default agent provider to work without an API key.
Uninstall¶
Drag /Applications/Loom.app to the Trash. Loom-owned data lives in:
~/Library/Application Support/Loom/(staging directory and update manifest)~/Library/Application Support/com.chasesims.Loom/(SwiftData store)~/Library/Preferences/com.chasesims.Loom.plist(UserDefaults)- macOS Keychain, service
com.chasesims.Loom(Anthropic key, local endpoint bearer tokens)
See File Paths and Keychain Keys for the full inventory.
3. First Run¶
When Loom opens for the first time it seeds three default workspaces (Prompt, Ideas, Review) and a corresponding empty layout for each. The window is sized 1024 by 640 minimum, 1400 by 800 by default.
A 30-second tour:
- Click a workspace in the left sidebar to select it. The center deck re-renders with that workspace's pane lineup.
- Use the top-bar Add Block strip (or
Command Shift 1throughCommand Shift 4) to add a pane that the current workspace kind allows. - In a Prompt workspace, an Agent pane is preconfigured. Type a prompt at the bottom and press Return to send it to the default Claude Code provider.
- Drag any pane's title bar to reorder. Drop near the left, right, top, or bottom edge to pin the pane to that side. Drop on top of another pane to swap their positions.
- Use
Command Option Arrowto pin the focused pane via keyboard, orCommand Option Fto toggle the focused pane to a full-row span.
Setting a workspace folder¶
Each workspace can carry a folder URL (its working directory). Right-click a workspace row in the sidebar and choose Set folder... to bind one. Once set, the folder feeds three things:
- The Terminal pane's startup
cwd. - The Editor pane's file tree root.
- The Agent pane's
cwd(passed to theclaudesubprocess).
The folder also appears under the workspace name in the sidebar so the row self-documents.
Renaming panes¶
Double-click a pane's title bar to rename it inline. The custom name is remembered per-block and survives workspace switches. Right-click the title bar and choose Reset name to clear the override.
4. Workspaces¶
A workspace is one named layout in the sidebar with its own folder, color, kind, and pane configuration. Loom ships with three kinds, picked at creation and immutable afterward.
| Raw value | Sidebar label | Icon | Available panes |
|---|---|---|---|
code |
Prompt | text.cursor | Terminal, Editor, Tasks, Agent |
ideas |
Ideas | lightbulb | Notes, Agent |
review |
Review | magnifyingglass | Preview, Agent |
The kind drives:
- Which buttons appear in the top-bar Add Block strip.
- Which
Command Shift <N>shortcut adds which pane (the order inavailablePanelsis the shortcut order). - Which sidebar section appears below the workspace list (Terminal Sessions, Ideas, or nothing for Review).
Persistence¶
Each workspace persists:
- Its name, color, kind, folder path, last-opened timestamp, created-at timestamp.
- Its layout per kind (block list with kind, custom title, pin, full-row span, preview URL override, terminal slot index, preview slot index, terminal cwd path).
Layout is stored in ~/Library/Application Support/Loom/layout.json and
keyed by workspace kind. The store is read once into an in-memory cache; saves
are coalesced through a single in-flight task so two rapid mutations cannot
race each other to disk.
Switching workspaces¶
- Click a row in the sidebar.
Command Shift Oflips back to the previous workspace (handy when bouncing between Prompt and Review).- The previously selected workspace is remembered in
WorkspaceLayout'spreviousWorkspaceIDso flip is one keystroke.
When you switch workspaces Loom disables the default SwiftUI cross-fade and swaps the deck in a single frame. Cross-fades cost about 100ms of perceived latency for no payoff here.
4.1. Prompt Workspace¶
Loom's cockpit. Four panes available, in this default order:
- Terminal (
Command Shift 1) - Editor (
Command Shift 2) - Tasks (
Command Shift 3) - Agent (
Command Shift 4)
Default layout: Terminal, Tasks, Agent (the Editor pane is added on demand).
Use Prompt for:
- Active build and debug loops.
- Driving a CLI agent (Claude Code, Codex, Gemini) inside the terminal while watching its tasks mirror in the Tasks pane.
- Holding multiple terminals side by side. Add as many
Terminalblocks as you want; each gets an auto-incrementing slot index (Terminal, Terminal 2, Terminal 3) so the sidebar list stays legible.
4.2. Ideas Workspace¶
Two panes available:
- Notes (
Command Shift 1) - Agent (
Command Shift 2)
Use Ideas for:
- Drafting copy, plans, and journal-style notes.
- Bouncing rough ideas off a model without spinning up a terminal.
- Capturing fleeting thoughts that do not yet belong on a kanban board.
Notes are SwiftData-backed (IdeaNote model) with autosave. Each note has a
title and a body. The sidebar's Ideas section lists every note in the active
workspace, sorted newest-first.
4.3. Review Workspace¶
Two panes available:
- Preview (
Command Shift 1) - Agent (
Command Shift 2)
Use Review for:
- Looking at a localhost dev server alongside the agent that built it.
- Reviewing a deployed page, a GitHub PR diff, or rendered output without switching to a separate browser.
The Preview pane defaults to http://localhost:3000 for the first preview
block, http://localhost:3001 for the second, etc. The auto-incrementing
slot is global across kinds so two Review workspaces' previews do not
collide. The block remembers any URL override; setting the URL back to the
default clears the override so the slot keeps tracking its port.
5. Layout System¶
Every pane (called a "block" internally) lives on the workspace's deck. The deck arranges blocks into a grid that adapts to the window size. Blocks can be reordered via drag, pinned to an edge, expanded to a full row, or swapped with another block.
Deck capacity¶
The deck's capacity scales with the window size:
| Window width | Window height | Cols | Rows | Max blocks |
|---|---|---|---|---|
| 1800+ | 900+ | 4 | 3 | 12 |
| 1300+ | (any) | 4 | 2 | 8 |
| 900+ | (any) | 3 | 2 | 6 |
| (smaller) | (any) | 2 | 2 | 4 |
When the deck is at capacity the Add Block buttons in the top bar dim out and tooltips read "Block limit reached for this window size." Resize the window or remove a block to add a new one.
Pinning¶
Pinning docks one block to one edge of the deck. The remaining blocks distribute evenly across the complementary region.
| Shortcut | Action |
|---|---|
Command Option Left |
Pin to left half |
Command Option Right |
Pin to right half |
Command Option Up |
Pin to top half |
Command Option Down |
Pin to bottom half |
Command Option F |
Toggle full row span |
Command Option U |
Unpin |
Drag-to-pin is supported as well. When you drag a pane near an edge, a dashed orange-blue ghost appears showing where the pin will land. Corner zones (top-left, top-right, bottom-left, bottom-right) take priority over edge zones. Dropping in a corner pins the block to that quadrant; the "neighbor" quadrant takes the next-most-recent block; the rest fill the complementary half-row.
Only one block can be pinned at a time. Pinning a second block clears the first pin.
Full row span¶
Command Option F toggles the focused block between "shares its row with
peers" and "spans the full deck width." Useful for the Terminal pane when
you need wide command output without unpinning everything else.
Reorder by drag¶
Drag a block's title bar to reorder it. Drop on top of another block to swap. Drop near an edge to pin. The deck animates with a 320ms spring; the ghost indicator uses a 120ms ease-out so the drop target stays snappy.
Resize¶
Each block fills its allotted grid cell automatically. There are no draggable dividers between blocks today; the cell sizes are computed from the deck capacity, the block count, and any pin and span flags.
Persistence¶
Layout state is serialized via LayoutPersistence to
~/Library/Application Support/Loom/layout.json. Each kind (Prompt, Ideas,
Review) has its own block list. Switching kinds preserves each kind's
last-seen layout: drop two terminals into a Prompt workspace, switch to
Ideas, switch back, and the two terminals are still there.
What is not persisted: the live TerminalSession object (PTYs are not
checkpointable, so a restored terminal block gets a fresh shell that respawns
in the saved cwd) and the in-memory message log of an Agent pane.
6. Panes¶
6.1. Terminal¶
Loom's Terminal pane is a real terminal, backed by SwiftTerm. It runs your login shell with a TTY so interactive tools (Vim, top, fzf, ssh) work the same as they do in iTerm.
What it ships with¶
- Login shell. Defaults to
/bin/zsh -l; respects$SHELLif set. The argv 0 is set to-zshso the shell treats itself as a login shell and runszprofile/zshrc(where Homebrew'sPATHlives). - TTY allocation.
top,bat, and friends get a real width. - Working directory seeded from the workspace folder.
- Standard ANSI color and 256-color support; truecolor via SwiftTerm.
TERM=xterm-256color,COLORTERM=truecolor,TERM_PROGRAM=Loom,TERM_PROGRAM_VERSION=<MARKETING_VERSION>.
What it strips from the inherited environment¶
The PTY shell sources your dotfiles and re-exports anything you actually want. Loom defensively strips a list of credential-shaped variables before spawning the shell so a leaked secret in Loom's launch environment does not flow into every subprocess you run:
- Exact matches:
ANTHROPIC_API_KEY,OPENAI_API_KEY,OPENAI_ORG_ID,GOOGLE_API_KEY,GEMINI_API_KEY,GROQ_API_KEY,MISTRAL_API_KEY,DEEPSEEK_API_KEY,XAI_API_KEY,AWS_SECRET_ACCESS_KEY,AWS_SESSION_TOKEN,AZURE_OPENAI_API_KEY,HUGGINGFACE_TOKEN,HF_TOKEN,GITHUB_TOKEN,GH_TOKEN,NPM_TOKEN,STRIPE_SECRET_KEY. - Suffix matches: any variable ending in
_API_KEY,_SECRET_KEY,_ACCESS_TOKEN, or_AUTH_TOKEN.
Multi-row click to position cursor¶
When a known CLI agent (claude, codex, gemini) is the foreground
process, single-clicking inside its prompt area sends arrow-key sequences to
walk the cursor to the clicked column and row. Same UX as Warp or iTerm with
shell integration.
Vertical movement is gated behind the CLI-agent check because sending up or down arrows into a plain shell prompt would walk command history rather than move the cursor. Cross-row clicks are also bounded to a 10-row radius from the cursor so accidental scrollback clicks do not blast a hundred arrow sequences into the foreground.
Single clicks with any modifier (Shift, Command, Option, Control) are ignored so SwiftTerm's native selection and word-lookup gestures keep working.
CLI agent detection¶
The Terminal session reads tcgetpgrp on the PTY's child file descriptor to
find the foreground process group, then sysctl(KERN_PROC_PID) to read its
command name. Three names are currently recognized: claude, codex,
gemini.
When detection fires:
- The active sessions badge in the workspace sidebar increments.
- The Tasks pane (in Prompt workspaces) starts mirroring the agent's live
task list from
~/.claude/tasks/<session>/<id>.json. - The terminal's click-to-position behavior unlocks vertical movement.
When the agent process exits, detection drops on the next 2 second poll.
Copy / paste¶
Standard macOS shortcuts: Command C copies the selection, Command V
pastes. Selection works with mouse drag.
Scrollback¶
SwiftTerm keeps the default 1000-line scrollback. Scroll with two-finger
drag or your terminal's Page Up / Page Down (depending on terminfo).
Restart¶
There is no "restart shell" button. Close the pane (the x in its title bar) and re-add it to get a fresh shell that respawns in the workspace folder. The previous shell's scrollback is lost.
Roadmap items¶
- Command-block history. Every shell command becomes its own scrollable, copyable card with exit code and timing metadata.
- Multi-pane terminal layouts (split panes inside one Terminal pane).
- Built-in MCP server bridging.
6.2. Editor¶
The Editor pane today is a plain-text file editor backed by SwiftUI's
TextEditor, with a recursive file tree on the left.
What works today¶
- Browse the workspace folder via the file tree (left side).
- Click a file to open it; non-binary text files load into the editor.
- Edit the buffer;
Command Ssaves to disk. - A yellow dot in the title bar marks unsaved changes.
- The breadcrumb in the title bar shows the current file path relative to
the workspace folder (or relative to
~if it lives outside). - A folder icon button in the title bar opens an
NSOpenPanelto pick a file outside the workspace tree. Command Sshortcut to save,xmark.circlebutton to close.
Binary file guard¶
The editor refuses to open files whose extension is in this set:
png, jpg, jpeg, gif, webp, heic, icns,
pdf, zip, tar, gz, dmg, app,
mp3, mp4, mov, wav, flac,
ttf, otf, woff, woff2,
sqlite, db, store, data
Trying to open one shows "Binary files aren't supported in the editor yet." in the error banner.
What it does not do (yet)¶
- Syntax highlighting.
- Multi-file tabs.
- Diff or git decoration.
- Find and replace.
The roadmap includes a CodeEdit
integration to swap the plain TextEditor for a real NSTextView-based
editing surface with syntax highlighting and Loom-native chrome.
6.3. Tasks (Kanban)¶
A SwiftData-backed kanban board. Available in Prompt workspaces.
Models¶
KanbanBoard
├── name
├── createdAt
├── workspace (relationship)
└── columns: [KanbanColumn]
├── name
├── position
├── board (relationship)
└── cards: [KanbanCard]
├── title
├── instructions
├── taskKnowledge
├── status (KanbanStatus enum)
├── agentName
├── projectPath
└── timestamps
Five fixed columns¶
The board ships with five status columns. Order is fixed; renaming is not supported today.
| Column | Status raw value |
|---|---|
| Todo | todo |
| In Progress | inProgress |
| In Review | inReview |
| Complete | complete |
| Cancelled | cancelled |
Drag cards between columns to update status.
Card fields¶
title. Short label shown on the card face.instructions. Long-form description. Surfaced in the inspector.taskKnowledge. Free-form notes and prior context.status. Drives the column.agentName. Optional CLI agent name to use when handing off to the Agent pane (passed as--agent <name>).projectPath. Working folder for handoff. Defaults to the workspace folder if blank.
Inspector¶
Click a card to open the inspector. Edit any field; changes save immediately to SwiftData. Press Escape to dismiss.
Persistence¶
KanbanCard is a @Model. The container is on disk under the standard
SwiftData application-support location, so cards survive app relaunches.
Live agent tasks block¶
When a CLI agent is detected in any Terminal pane (in any workspace), the Tasks pane shows its in-progress task list above the kanban columns. See Live Agent Tasks.
6.4. Notes¶
The Notes pane is a list of IdeaNote records on the left and a markdown-
adjacent body editor on the right. Available in Ideas workspaces.
Each note has:
title(auto-derived from the first line of the body)body(plain text, soft-wrapped, monospaced)createdAtandupdatedAttimestamps
Notes are workspace-scoped (workspace: Workspace? relationship). The
sidebar's Ideas section shows the active workspace's notes sorted newest
first; double-click any row to rename inline.
Bulk operations:
- The trash icon in the sidebar's Ideas section header opens a confirmation, then deletes every note in the active workspace.
6.5. Preview¶
The Preview pane is a WKWebView with a URL bar, back / forward / reload
controls, and a small loading indicator overlay. Available in Review
workspaces.
URL handling¶
Type into the address bar and hit Return (or click Go) to navigate.
URL normalization rules:
- Anything containing
://is used as-is. - A leading
/or~is treated as a file path;~is expanded. - A bare
localhostor a leading digit is prefixed withhttp://. - Otherwise the input is prefixed with
https://.
Auto-default URL¶
Each Preview block has an autoPreviewIndex (1, 2, 3, ...) used to compute
its default URL: http://localhost:3000 for the first block,
http://localhost:3001 for the second, etc. The default is recomputed across
all kinds so two Review workspaces with one Preview each both default to
localhost:3000 only if they were created in different sessions; auto
indexes are unique within a single launch.
If you set a custom URL, the block remembers it. Resetting the URL field to the default clears the override.
WebKit configuration¶
WKWebView is configured with javaScriptCanOpenWindowsAutomatically: false
to avoid pop-ups during dev preview. The WebController is owned by the
block (not the view) so the loaded page survives workspace switches without
forcing a full reload of the previewed URL.
6.6. Agent¶
The Agent pane is a streaming chat surface that routes prompts to one of two provider families (CLI subprocess, HTTP streaming) through a single picker. Available in Prompt, Ideas, and Review workspaces.
See Agent Providers for the per-provider details. This section covers the pane mechanics.
Header¶
The header carries:
- A spark icon (orange).
- The agent picker (vendor and display name with a chevron).
- A subtitle: for CLI providers, the first 8 characters of the session id; for local HTTP providers, the endpoint host (and port).
- A refresh button to re-query the registry. Useful after
claude agentsadds a new sub-agent orollama pulllands a new model. - A small spinner when the registry is refreshing.
- During an in-flight turn: a progress spinner and a Stop button.
Picker¶
The picker groups agents by section. Sections are determined by each
descriptor's group field:
Defaultfor Claude Code's vendor default.Plugin agents,Built-in agents, etc. for sub-agents discovered viaclaude agents list.Local . <endpoint name>for each Ollama model or each OpenAI-compatible endpoint.
Click any item to switch providers within the same pane. Switching mid- conversation creates a new logical conversation; the new provider does not get the prior history of the previous one. Open a fresh workspace to start clean across providers.
Message bubbles¶
Three roles render with distinct chrome:
| Role | Avatar | Background |
|---|---|---|
| User | person.crop.circle.fill (blue) | white 5% |
| Assistant | sparkles (orange) | white 3% |
| System | exclamationmark.bubble (gray) | orange 8% |
Assistant bubbles label themselves AGENT for CLI providers and LOCAL for
Ollama / OpenAI-compatible endpoints. Text is selectable; an empty bubble
during streaming shows a single ellipsis.
Input bar¶
A multiline TextField (1 to 6 lines) with a placeholder ("Ask the
agent..."). Press Return to send, Shift Return for a newline. The send
button (orange arrow) lights up when the draft is non-empty and no turn is
in flight.
Streaming¶
| Provider | Stream | Cancel mechanism |
|---|---|---|
| Claude Code (CLI) | One-shot. Bubble fills when the subprocess exits. | Process.terminate() on the active subprocess. Resumes on next turn. |
| Anthropic API | Live token stream (SSE) | Cancels the URLSession task. |
| Ollama | Live token stream (NDJSON) | Cancels the URLSession task. |
| OpenAI compatible | Live token stream (SSE) | Cancels the URLSession task. |
Conversation memory¶
- CLI providers retain context server-side via
--resume <session-id>. The session id is shown in the header so you can see when a new conversation starts. - HTTP providers are stateless. Loom replays the full chat history on every turn, so context survives but request size grows over the conversation.
Per-workspace state¶
Each workspace renders its own Agent pane with local message state. The in-memory log survives workspace switches (the pane's view re-mounts but the state is held by the deck), but it is not persisted across app relaunches today.
7. Agent Providers¶
7.1. Claude Code (Default)¶
Loom's default agent. Drives the Claude Code CLI as a subprocess so the chat surface uses your existing OAuth login. No API key needed.
Requirements¶
claudeonPATH. Install vianpm i -g @anthropic-ai/claude-codeor the official installer.- An authenticated Claude Code session (
claude auth login).
If claude is not on PATH, sending a prompt fails with "Failed to launch
claude:" plus the underlying error. Surface it in the chat error banner.
Subprocess invocation¶
ClaudeCodeProvider builds the argv array directly and runs through
/usr/bin/env:
The first turn passes --session-id <uuid>. Subsequent turns pass
--resume <uuid> so the conversation has memory.
Arguments are passed as an array, never as a shell string. This keeps any user-controlled value (the prompt, the agent name) from being interpreted as shell syntax.
PATH resolution¶
The Claude Code provider runs zsh -lic 'echo $PATH' once on the first send
to capture the user's interactive PATH. The result is cached in a static
across the app's lifetime so subsequent sends do not re-spawn a login shell
just to read PATH.
Sub-agent picker¶
The Agent registry queries claude agents list and parses the textual
output:
Each row becomes an AgentDescriptor grouped under its section header.
Picking one passes its name as --agent <name> on the next turn.
Click the refresh icon in the Agent pane header after installing a new
plugin or editing your ~/.claude/agents/ definitions to re-query.
Cancellation¶
The Stop button calls cancel(), which bumps a generation counter and calls
Process.terminate() on the active subprocess. The captured generation in
the in-flight send() ensures stale responses do not deliver to the UI
after a cancel; the assistant placeholder is removed and the user sees the
input come back unblocked.
Working directory¶
The Agent pane passes the workspace's folder URL as the subprocess cwd so
claude's tool calls target the right project. Set the workspace folder
before sending the first prompt; switching folders mid-session does not
migrate context.
No token streaming¶
The CLI's -p mode is one-shot; the subprocess prints the full response
when it exits. If you want token-by-token streaming, point the picker at a
local LLM or the Anthropic API.
7.2. Anthropic API (Direct)¶
Loom can talk directly to https://api.anthropic.com/v1/messages using an
Anthropic API key. Provider-direct (no Claude Code CLI), with live token
streaming.
When to use it¶
- You want streamed tokens.
- You want a model that the Claude Code CLI's
-pmode does not yet expose. - You are running on a machine without
claudeonPATH.
For day-to-day work the Claude Code provider is preferred (no key management, full sub-agent system). Use this path when the above tradeoffs matter.
Setup¶
- Get an API key from console.anthropic.com.
- Open Settings -> Advanced.
- Paste the key into the Anthropic API Key field.
- Click Save.
The key is stored in macOS Keychain under service com.chasesims.Loom,
account anthropic_api_key. See Keychain Keys.
Wire format¶
POST https://api.anthropic.com/v1/messages
content-type: application/json
x-api-key: <your key>
anthropic-version: 2023-06-01
Request body shape:
{
"model": "claude-opus-4-7",
"max_tokens": 4096,
"stream": true,
"system": "<optional>",
"messages": [{"role": "user", "content": "..."}]
}
Streamed content_block_delta events with delta.type == "text_delta" are
emitted as tokens.
Default model¶
claude-opus-4-7, max tokens 4096. Both are tunable in code
(AnthropicProvider) but not yet exposed in Settings.
Cost¶
Direct API calls bill against your Anthropic account, separate from any Claude Code subscription. The Usage dashboard reads on-disk Claude Code session logs and does not track direct API usage.
7.3. Local LLMs (Ollama and OpenAI compatible)¶
Loom can stream chat from any LLM you run on localhost or your LAN. Two
integrations are built in.
| Kind | Best for | Wire format |
|---|---|---|
| Ollama | ollama serve running locally or on a homelab box |
POST /api/chat (NDJSON), GET /api/tags for models |
| OpenAI compatible | LM Studio, llama.cpp's llama-server, Jan, vLLM, LocalAI, anything that speaks /v1/chat/completions |
OpenAI SSE stream |
Both are added in Settings -> Providers.
Ollama setup¶
- Install Ollama:
brew install ollama(or download from ollama.com). - Pull a model:
ollama pull llama3.2:3b. - Ensure the daemon is running:
ollama serve(the GUI installer launches it automatically; brew install does not). - Loom -> Settings -> Providers -> Add.
- Display name:
Ollama - Kind: Ollama
- Base URL:
http://localhost:11434 - Default model: leave blank. Loom auto-discovers via
/api/tags. - Requires auth: off.
- Click Test connection. Should report
N model(s). - Click Save.
The Agent pane picker now has a Local . Ollama group with one entry per
pulled model. Pick one, send a prompt, watch tokens stream in.
LAN setup: run OLLAMA_HOST=0.0.0.0:11434 ollama serve on the remote box and
set Loom's Base URL to http://<host>:11434.
OpenAI-compatible setup¶
These tools all expose an OpenAI-shaped HTTP API. Pick one, start its server, then add an endpoint in Loom.
LM Studio:
- In LM Studio: Developer -> Local Server -> Start Server (default port 1234).
- Note the model identifier (e.g.
lmstudio-community/Llama-3.1-8B-Instruct). - Loom -> Settings -> Providers -> Add.
- Display name:
LM Studio - Kind: OpenAI-compatible
- Base URL:
http://localhost:1234/v1 - Model: the identifier from step 2
- Requires auth: off
- Save.
llama.cpp:
In Loom, Base URL = http://localhost:8080/v1, Model = whatever string you
want (llama-server echoes it back regardless).
Jan: default port 1337/v1. Same setup; paste in the model identifier from
Jan's UI.
vLLM / LocalAI: same shape. Set Base URL to wherever the server listens
(commonly http://localhost:8000/v1), set the Model id to whatever the
server expects, save.
Auth tokens¶
Some local servers (or LAN proxies in front of them) want a bearer token.
Toggle Requires auth in the editor and paste the token. It is stored in
macOS Keychain under account local_endpoint_<UUID> and sent as
Authorization: Bearer <token> on every request.
URL safety filter¶
LocalEndpoint.isAllowedURL enforces a conservative allowlist on every
endpoint URL:
- Scheme must be
httporhttps.file://,ftp://, etc. are rejected outright. - Hostname must be present.
- Three host strings are denied:
169.254.169.254,metadata.google.internal,metadata,fd00:ec2::254(cloud instance metadata IPs).
A rejected URL silently fails to materialize as an AgentDescriptor, so the
endpoint is invisible in the picker. The Test connection button surfaces
"Invalid URL" so the user can fix it.
Streaming and cancel¶
All HTTP providers stream tokens live into the assistant bubble. Hit the Stop button to cancel; the URLSession task is canceled and the bubble shows whatever was already emitted.
7.4. Custom Providers¶
Need to point Loom at something that is not Claude Code, Ollama, or an OpenAI-compatible server? Two paths.
Try OpenAI compatible first¶
A surprising number of "weird" LLM servers actually speak the OpenAI wire format. If your server has any of these in its docs, add it as OpenAI-compatible:
- "OpenAI-compatible API"
- "OpenAI proxy"
- A
POST /v1/chat/completionsendpoint - A
POST /chat/completionsendpoint (set Base URL to the parent and Loom appends/chat/completions)
This covers vLLM, LocalAI, OpenRouter (with their key), Together, Groq, Mistral's chat endpoint, Anyscale, Perplexity, Fireworks, DeepInfra, and dozens more.
Add a new provider in code¶
If your target speaks a non-OpenAI wire format, drop a file into
Loom/Agents/ that conforms to LLMProvider:
struct MyCoolProvider: LLMProvider {
let baseURL: URL
let model: String
var displayName: String { "MyCool . \(model)" }
func stream(
messages: [LLMMessage],
system: String?
) -> AsyncThrowingStream<LLMEvent, Error> {
AsyncThrowingStream { continuation in
let task = Task {
do {
// Build URLRequest, call URLSession.shared.bytes(for:),
// parse the wire format, yield .textDelta(...) per token.
continuation.yield(.done)
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
}
}
Then:
- Add a vendor case to
AgentDescriptor.VendorinAgentRegistry.swift. Mark itisLocalHTTPif HTTP-streamed. - Surface the provider in the registry. Either register it from a
LocalEndpoint.Kindor add a hardcoded descriptor. - Wire it into
AgentPaneView.sendViaLocalHTTP(or write a parallel send method for unique requirements). - Run
xcodegen generateto regenerate the project after adding the file.
Reference implementations: OllamaProvider.swift and
OpenAICompatibleProvider.swift.
8. Live Agent Tasks¶
When a CLI agent runs in a Terminal pane (anywhere in Loom), the Tasks pane mirrors its in-progress task list in real time.
Where the data comes from¶
Claude Code (and other compatible CLIs) write per-session task state to:
Each JSON file describes one task: id, subject, description, activeForm,
status. Loom polls this directory every 2 seconds via
LiveAgentTasksService (off-main-thread JSON decode) and surfaces the
active tasks grouped by session id.
Task statuses¶
| Raw value | Label |
|---|---|
pending |
Todo |
in_progress |
In progress |
completed |
Done |
cancelled |
Cancelled |
deleted |
(hidden from the pane) |
Within a group, tasks are sorted by status priority then by updatedAt
descending.
What you see¶
In a Prompt workspace's Tasks pane, live agent tasks appear in their own section above the kanban columns:
- Header: Live .
(e.g. Live . 33280421). - One row per task, with a status badge.
- Click a task to expand and read its full description and
activeForm.
When a session finishes (or its session id rotates), the live block clears on the next 2 second poll.
Multiple sessions¶
If multiple CLI agents are running across multiple Terminal panes (or outside Loom), each appears with its own header. The active session count in the workspace sidebar increments accordingly.
Stale window¶
Configurable in Settings -> Tasks. Sessions whose most recent task update is older than the window are treated as dead and hidden from the pane:
| Window | Hides sessions older than |
|---|---|
| 30 minutes | 30 min |
| 1 hour | 1 h (default) |
| 4 hours | 4 h |
| 12 hours | 12 h |
| 24 hours | 24 h |
| Never | (always show everything) |
The poll keeps running regardless of the window; it is purely a display filter.
Lock and highwatermark files¶
Claude Code touches .lock and .highwatermark files even on dormant
sessions. Loom deliberately ignores those mtimes when computing
"is this session active" so long-completed sessions do not look alive
forever. Only .json task-file mtimes count.
Privacy¶
Loom only reads files under ~/.claude/tasks/. Nothing leaves your
machine. The polling service uses standard FileManager calls and does not
watch via FSEvents (which would require a separate privacy entitlement).
Clearing¶
The trash icon next to a session's header deletes that session's .json
files. Live CLI sessions will rewrite them on the next turn, so this only
"sticks" for crashed or zombie sessions.
The "Clear all" button in the pane header opens a confirmation, then deletes every visible session's task files.
9. Task Handoff¶
Kanban cards can carry the next action (a prompt or a shell command) and dispatch it to the right pane in one click.
Two handoff fields¶
Every card has two optional fields:
agentPrompt: text auto-injected into the Agent pane on handoff.terminalCommand: shell command auto-injected into the Terminal pane on handoff.
Set them in the card inspector. Either or both can be filled.
Send to agent¶
In the card inspector or the card's context menu, click Send to agent. Loom:
- Grabs
agentPrompt. - Auto-fills the Agent pane's input.
- Submits the prompt.
- Optionally selects the configured
agentNamein the picker (passed as--agentfor Claude Code).
If no Agent pane is open in the current workspace, Loom adds one first.
Send to terminal¶
Click Send to terminal. Loom:
- Grabs
terminalCommand. - Injects it into the focused Terminal pane (typed into the foreground process's stdin).
- Sends a newline so the command runs.
If no Terminal pane is open, Loom adds one first.
Caveats¶
- No multi-line scripts. The whole command is one stdin write. Most shells handle this fine; partially-typed control-flow blocks can interleave oddly.
- No password prompts. Do not inject
sudoand expect the password prompt to fill itself. - The shell sees it as user input. History (
history,Up) records injected commands the same as typed ones. - No auto-execute of agent suggestions. The agent does not silently inject commands. Every handoff is one explicit click.
Why two separate fields?¶
Some tasks are pure conversation ("Have the agent draft a release note"). Some are pure execution ("Run the migration script"). And some are both: set both fields, fire one then the other. Keeping the pipelines separate avoids gymnastics about whether a string is a prompt or a command.
10. Usage Dashboard¶
Loom reads on-disk usage data from the local CLI agents you have installed and renders it as a full-deck dashboard. Three tabs in the top bar (Claude Usage, Codex Usage, Gemini Usage) sit immediately to the right of the Loom logo. Click any tab to open that CLI's dedicated full-width dashboard; click the same tab again or click any workspace in the sidebar to dismiss. Each tab tints to its CLI's brand color when active.
What it tracks¶
Three CLIs are recognized by name today: Claude Code, Codex, Gemini.
| CLI | Source |
|---|---|
| Claude Code | ~/.claude/projects/<slug>/<id>.jsonl (line by line) |
| Codex | ~/.codex/sessions/.../<rollout>.jsonl (cumulative total_token_usage event) |
| Gemini | Stub. The Gemini CLI does not expose usage we can read locally yet. |
For Claude Code, every JSONL session file is scanned line by line for
"usage":{...} events (actual per-turn timestamp + model) and "role":"user"
prompt lines. This drives:
- Per-bucket token totals across the selected timeframe.
- Per-model token attribution.
- Per-project token slices.
- Hourly distribution (when in the day did you actually drive the CLI).
- Top topics across user prompts (filtered against a hand-curated stopword list).
- Recent prompts (newest first, capped for display).
Timeframes¶
Pick a timeframe at the top of the dashboard:
| Timeframe | Buckets | Span |
|---|---|---|
| Day | 24 hourly buckets | Last 24 hours (rolling) |
| Week | 7 daily buckets | Last 7 days (rolling) |
| Month | 30 daily buckets | Last 30 days (rolling) |
| Year | 12 monthly buckets | Last 365 days (rolling) |
Switching timeframe triggers a full snapshot recompute.
Refresh cadence¶
Two cadences:
- Light path. Every 3 seconds, count
.jsonlfiles modified within the last 5 minutes. Drives the active sessions badge. - Full snapshot. Heavy. On demand (timeframe change or pane open). Reads every JSONL file in full, runs the regex scan off the main actor. Year-range refreshes can take roughly a minute on large logs; the dashboard shows a giant centered throbber over the existing data while it computes so the wait is announced.
Per-CLI dashboard¶
Each tab opens a single-CLI dashboard tinted with that CLI's brand color (Claude orange, Codex green, Gemini blue). The dashboard shows:
- Total tokens across the timeframe (input, output, cached).
- Today's session count.
- Total session count.
- Active session count.
- Last activity timestamp.
- A bar chart of per-bucket token totals.
- Top projects, top models, top topics.
- An hourly distribution.
- Recent prompts (clickable to expand).
CLIs that are not installed render an "installed but no data" placeholder so the tab still works as a feature-discovery surface.
Why no live API quotas?¶
The Anthropic console's quota and billing dashboards are the source of truth for paid usage. Loom's dashboard is purely a local-disk read of CLI session logs. It does not call the Anthropic API or the OpenAI API to look up live quotas.
Privacy¶
Same model as the live agent tasks reader. Loom only reads
~/.claude/projects/, ~/.codex/sessions/, and ~/.gemini/. Nothing
leaves your machine.
11. Settings¶
Loom's Settings window is a four-tab TabView (SettingsScene.swift),
sized 620x460:
- Appearance
- Tasks
- Providers
- Advanced
Open it via Command , or the Loom -> Settings... menu item.
11.1. Appearance¶
| Field | Storage | Purpose |
|---|---|---|
| Appearance picker | UserDefaults loom.appearance |
Match System / Light / Dark (default) |
The picker is a segmented control. Switching applies to every Loom window
immediately via the loomAppearance() modifier.
11.2. Tasks¶
| Field | Storage | Purpose |
|---|---|---|
| Stale window | UserDefaults loom.tasks.staleHours (Double, hours) |
Hides CLI sessions whose most recent task update is older than the window |
Options: 30 minutes, 1 hour (default), 4 hours, 12 hours, 24 hours, Never.
Lower the window when cycling through many short Claude Code runs and you do not want yesterday's sessions cluttering the pane. Raise it for long-running agents that go idle for hours between turns.
11.3. Providers¶
Manages the local LLM endpoints listed in the Agent pane's picker.
The Providers tab lists every configured endpoint with its kind, base URL, and a row of actions:
- Edit. Opens the editor sheet pre-filled.
- Trash icon. Removes the endpoint and clears its Keychain auth token.
Empty state shows a hint pointing at Add.
Add a provider¶
| Field | Notes |
|---|---|
| Display name | Free-form. Shown as the menu group header (Local . <name>). |
| Kind | Ollama or OpenAI-compatible. Switching kinds swaps the default base URL hint. |
| Base URL | Full URL. Trailing slash is stripped. Defaults: http://localhost:11434 (Ollama), http://localhost:1234/v1 (OpenAI-compatible). |
| Default model / Model | For Ollama: optional fallback when /api/tags fails. For OpenAI-compatible: required. |
| Requires auth token | Toggle. When on, reveals a SecureField for a bearer token. |
Test connection¶
Click Test connection before saving:
- Ollama: hits
GET <baseURL>/api/tags. Reports the number of models or "No models / unreachable". - OpenAI compatible: hits
GET <baseURL>/models. Reports HTTP 200 or the failure reason.
Test does not save the endpoint; you still have to click Save.
Storage¶
- Endpoint metadata: UserDefaults under key
loom.localEndpoints, JSON-encoded[LocalEndpoint]. - Auth tokens: macOS Keychain, service
com.chasesims.Loom, accountlocal_endpoint_<UUID>.
Saving (or removing) an endpoint triggers AgentRegistry.refresh(...). The
Agent pane picker updates without an app restart.
11.4. Advanced¶
| Field | Storage | Purpose |
|---|---|---|
| Anthropic API Key | Keychain anthropic_api_key |
Optional, for the Anthropic API direct provider |
The key is masked in a SecureField. Save writes to Keychain; Clear deletes the item. A green "Saved" badge appears for ~2 seconds after a successful save.
The key is read just-in-time when an AnthropicProvider is instantiated.
There is no in-memory cache.
12. Updates¶
12.1. Auto Update¶
Loom polls GitHub Releases on a 60 second cadence. New builds are downloaded and verified in the background; the Update pill in the top bar lights up once a build is staged. Click the pill to swap in the new version.
Cadence¶
- Remote poll: every 60 seconds.
- Local manifest poll: every 4 seconds (cheap; just
stats the staging directory). - API endpoint:
https://api.github.com/repos/BigBeardedMan/Loom/releases/latest(unauthenticated; 60 req/hr per IP).
Pipeline¶
GitHubReleaseFetcher.fetchLatest()returns the latest release. If its tag is a strictly higher semver than the running build, proceed.- Integrity check. Fetch the published
.sha256sidecar asset. The release MUST publish a SHA-256 of the DMG (hex, optionally followed by filename). Loom downloads the DMG and computes its SHA-256 in 256 KB chunks. If the hash does not match (or the sidecar is missing), refuse to mount. Without this, an attacker who compromised the GitHub release could replace the DMG with arbitrary code and Loom would silently install it. - Mount the DMG read-only at a private mountpoint via
hdiutil attach. - Copy
Loom.appfrom the mounted volume into~/Library/Application Support/Loom/staging/Loom.app. - Detach the DMG via
hdiutil detach -forceand remove the mountpoint. - Strip the iCloud
com.apple.fileprovider.fpfs#Pxattr (which would otherwise trip iCloud "uploading..." rename behavior). Quarantine is left in place so Gatekeeper still blesses the bundle on first launch. - Read
CFBundleShortVersionStringandCFBundleVersionfrom the stagedInfo.plistand writemanifest.jsonnext to the staged bundle. - The
UpdateService.availableflag flips on the next 4 second local poll.
Apply (click the pill)¶
Clicking the pill calls applyAndRelaunch():
- Spawn a small detached helper script. The script body lives only in the
helper process's argv (passed via
zsh -c "<body>"); no script file is written to a user-writable directory and re-executed. - The helper waits up to 10 seconds for the running Loom PID to exit.
- The helper removes
/Applications/Loom.appand copies the staged bundle in. - The helper removes the staged manifest.
- The helper relaunches Loom from
/Applicationsviaopen. - Logs land in
~/Library/Application Support/Loom/staging/last-apply.logfor forensics.
The hand-off is fast. Loom quits, the new build launches in well under a second.
Failure surfacing¶
When a remote check fails (network down, GitHub 5xx, missing checksum
sidecar, mount failure, copy failure), the error is captured in
UpdateService.lastRemoteError. The next Help -> Check for Updates...
surfaces it in the alert ("Update check failed: ...") instead of silently
returning "up to date".
12.2. Manual Check¶
Use Help -> Check for Updates... in the menu bar (or ? in the menu)
to force a remote check now. The path is the same as the automatic poll;
it just bypasses the 60 second interval and posts an alert with the result:
- "Update available: Loom
( ) is ready. Click Update in the top bar to install and relaunch." - "Update check failed:
" - "Loom is up to date. You're running
( )."
The menu item is disabled while a remote check is in flight.
Disabling auto-update¶
There is no UI toggle today. To stop it, edit
Loom/App/UpdateService.swift and short-circuit start(), then rebuild.
Or kill the Loom process and remove
~/Library/Application Support/Loom/staging/.
13. Keyboard Shortcuts¶
Every shortcut is wired in Loom/App/LoomApp.swift via Commands and
shows up in the menu bar.
Workspaces¶
| Shortcut | Action |
|---|---|
Command N |
New workspace (focuses sidebar) |
Command Shift O |
Switch to previous workspace |
Adding panes¶
The number maps to the panel order for the current workspace kind. In a
Prompt workspace the order is Terminal, Editor, Tasks, Agent, so
Command Shift 1 adds a terminal and Command Shift 4 adds an agent.
| Shortcut | Action |
|---|---|
Command Shift 1 |
Add the first available panel |
Command Shift 2 |
Add the second |
Command Shift 3 |
Add the third |
Command Shift 4 |
Add the fourth |
Layout¶
These act on the focused (first) pane.
| Shortcut | Action |
|---|---|
Command Option Left |
Pin focused pane to the left |
Command Option Right |
Pin focused pane to the right |
Command Option Up |
Pin focused pane to the top |
Command Option Down |
Pin focused pane to the bottom |
Command Option F |
Toggle full row span |
Command Option U |
Unpin |
Editor¶
| Shortcut | Action |
|---|---|
Command S |
Save the active file |
Help¶
| Shortcut | Action |
|---|---|
Help -> Check for Updates... |
Force a remote release check now |
Build & Run (Xcode)¶
When you are hacking on Loom itself in Xcode:
| Shortcut | Action |
|---|---|
Command R |
Build & run |
Command Shift K |
Clean build folder |
Command B |
Build only |
14. Architecture¶
Loom is one Swift app target with all source under Loom/. The codebase
is intentionally compact: roughly 35 Swift files across nine top-level
directories.
14.1. Source Layout¶
Loom/
App/ @main, scene, environment wiring, update service,
GitHub release fetcher, theme, app icon exporter
Workspace/ Deck container, layout persistence, sidebar, model,
block + workspace types
Terminal/ SwiftTerm-backed pane, PTY session, click-to-position
Editor/ File tree, breadcrumb, FSNode, plain-text editor
Agents/ LLM provider protocol, Anthropic, Claude Code
subprocess, Ollama, OpenAI compatible, registry,
agent pane UI, Keychain store, usage service,
live agent tasks, local endpoints
Kanban/ SwiftData task board, columns, cards, inspector
Notes/ IdeaNote model, notes pane
Build/ Preview pane (WKWebView)
Settings/ Preferences window (Appearance / Tasks / Providers /
Advanced)
Resources/ Asset catalog, app icon, accent color
Info.plist Bundle metadata
Loom.entitlements App sandbox off, network client on
14.2. Storage¶
Three storage backends, each chosen for what it does well.
SwiftData¶
Persistent app data: workspaces, kanban, notes.
| Model | Purpose |
|---|---|
Workspace |
One row per workspace; kind + folder URL + color + timestamps |
KanbanBoard |
Per-workspace board container |
KanbanColumn |
Status column (Todo / In Progress / In Review / Complete / Cancelled) |
KanbanCard |
Title, instructions, knowledge, agent prompt, terminal command, project path |
IdeaNote |
Title (derived) + body for the Notes pane |
The schema is declared in LoomApp.swift inside the ModelContainer
definition. Default storage location: macOS application support container
(managed by SwiftData). Not in iCloud.
UserDefaults¶
Lightweight settings and lists where SwiftData would be overkill.
| Key | Type | Purpose |
|---|---|---|
loom.appearance |
String | Theme: system, light, dark |
loom.tasks.staleHours |
Double | Live agent tasks stale window (hours) |
loom.localEndpoints |
Data | JSON-encoded [LocalEndpoint] |
loom.workspaceSeed.v0_8 |
Bool | Migration flag (v0.8 seed cleanup) |
loom.workspaceSeed.v0_9 |
Bool | Migration flag (v0.9 build -> review) |
loom.workspaceSeed.v0_10 |
Bool | Migration flag (v0.10 Code -> Prompt) |
Migration flags are only flipped on a successful save; otherwise a write failure would silently mark the migration "done" and the work would never re-run.
Keychain¶
Secrets only. Service: com.chasesims.Loom. See
Keychain Keys.
What is not persisted¶
- Agent message history. In-memory only.
- Terminal scrollback. SwiftTerm holds it; not persisted across launches.
- In-flight HTTP requests and subprocesses. All canceled on quit.
14.3. Swift Concurrency¶
Loom builds with SWIFT_STRICT_CONCURRENCY: complete on Swift 6. Every
value that crosses an actor boundary is Sendable.
Default isolation¶
- App-level state (workspace layout, agent registry, live tasks, settings,
usage service) is
@MainActor. SwiftUI views read these directly; mutations land on the main actor. - Pure data types (
LocalEndpoint,LLMMessage,LLMEvent,KanbanCard) are structs or enums markedSendable. They cross actors freely. - HTTP providers (
AnthropicProvider,OllamaProvider,OpenAICompatibleProvider) are structs.
Subprocess providers¶
ClaudeCodeProvider is a @MainActor final class that owns mutable state
(activeProcess, hasLaunchedSession, sessionID, generation). The
actual subprocess work runs off main via
withCheckedThrowingContinuation plus a terminationHandler so a
cancelling Process.terminate() from the main actor actually unblocks the
awaiting caller. The previous process.waitUntilExit() form blocked
indefinitely.
Streaming¶
AsyncThrowingStream<LLMEvent, Error> is the streaming primitive. Each
provider builds a stream like this (via makeLLMStream helper):
AsyncThrowingStream { continuation in
let task = Task {
do {
try await runStream(..., continuation: continuation)
continuation.yield(.done)
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
continuation.onTermination = { _ in task.cancel() }
}
Cancellation is two-way:
- The caller drops the stream.
onTerminationfires, the innerTaskcancels,URLSession.bytes(for:)throwsCancellationError. - The inner task hits an HTTP error. It calls
continuation.finish(throwing:)and the caller'sfor try awaitrethrows.
URLSession.bytes(for:)¶
The streaming body iterator. Crucially, it propagates Task.cancel() into
the underlying URLSessionDataTask. We do call
try Task.checkCancellation() inside the inner loop as belt and braces.
Static parsing helpers¶
Where parsing is pure, the function is nonisolated static so it can run
off any actor and be unit-tested in isolation. Examples:
AgentRegistry.parseClaudeAgentsList(_:), every regex-driven parse in
UsageService.
nonisolated(unsafe): avoided¶
Loom does not use nonisolated(unsafe) to silence concurrency warnings.
Anything that is tempting becomes a @MainActor access, a Sendable
struct, or a Task.detached.
SwiftData on the main actor¶
All ModelContext access is @MainActor. The schema is on the main actor.
There is no fan-out to background contexts; the data volume does not
warrant it.
15. Security Model¶
Loom is unsandboxed (network and filesystem access are required for the core feature set), runs hardened-runtime, and ad-hoc-signed by default. The security model is built around four ideas:
-
Secrets live in Keychain. Anthropic API key and per-endpoint bearer tokens are stored as
kSecClassGenericPassworditems withkSecAttrAccessibleWhenUnlockedThisDeviceOnlyandkSecAttrSynchronizable: false. They never sync to iCloud Keychain and never write to the UserDefaults plist on disk. -
Subprocess invocation is array-only. Every
Processlaunch passes arguments as an array, never as a shell-interpolated string. The Claude Code provider runs through/usr/bin/env claude ...with the prompt as a discrete argv element, so a prompt containing shell metacharacters cannot escape into command execution. The auto-update helper script body is passed inline viazsh -c "<body>"; no script file is written to a user-writable directory and re-executed. -
Auto-update is hash-verified. Every release MUST publish a
<dmg-name>.sha256sidecar containing the SHA-256 of the DMG.GitHubReleaseFetcherdownloads the DMG, computes its hash in 256 KB streaming chunks, and refuses to mount if the hash does not match (or if the sidecar is missing). Without this, an attacker who compromised the GitHub release (stolen PAT, MITM'd CDN) could replace the DMG with arbitrary code and Loom would silently install it. -
Local endpoints are URL-allowlisted.
LocalEndpoint.isAllowedURLrefuses non-http(s)schemes (so a typo cannot turn afile://URL into a local file read), refuses empty hostnames, and explicitly blocks cloud instance metadata IPs (169.254.169.254,metadata.google.internal,fd00:ec2::254). Rejected URLs simply never materialize asAgentDescriptors.
Adjacent precautions:
- The PTY shell environment strips a list of credential-shaped variables before spawn (see Terminal). Loom reads its own keys from Keychain, not from the inherited environment.
- The auto-update helper waits up to 10 seconds for the running Loom PID
to exit before swapping the bundle. If the PID is still alive after the
wait, the helper sends
SIGTERMrather than racing the swap. - The auto-update manifest no longer accepts a
bundlePathoverride; the staged bundle path is computed fromstagingRoot. Without this, a user-writable manifest pointing the path outside the staging dir was a path-traversal vector that the apply script would happilycp -Rinto/Applications. - HTTP errors from LLM providers log the response body privately
(
Logger(... privacy: .private)) but expose only the status code in the chat error banner. Provider error payloads can contain account or billing identifiers we do not want in the UI.
16. Reference¶
16.1. File Paths¶
Loom-owned¶
| Path | Purpose |
|---|---|
~/Library/Application Support/Loom/staging/Loom.app |
Newly downloaded build, waiting for Update click |
~/Library/Application Support/Loom/staging/manifest.json |
{ version, build, stagedAt } for the staged build |
~/Library/Application Support/Loom/staging/last-apply.log |
Helper-script log from the last apply |
~/Library/Application Support/Loom/layout.json |
Per-kind block list (custom titles, pins, span flags, terminal cwds) |
~/Library/Application Support/com.chasesims.Loom/default.store |
SwiftData store (workspaces, kanban, notes) |
~/Library/Preferences/com.chasesims.Loom.plist |
UserDefaults |
Loom-read (external)¶
| Path | Why Loom reads it |
|---|---|
~/.claude/tasks/<session-id>/<task-id>.json |
Live agent tasks polling |
~/.claude/projects/<slug>/<id>.jsonl |
Usage dashboard (Claude Code totals, per-bucket, per-model, per-project) |
~/.codex/sessions/.../<rollout>.jsonl |
Usage dashboard (Codex total_token_usage) |
~/.gemini/... |
Existence check for the Gemini installed flag |
| The workspace's folder URL | Editor file tree root, terminal cwd, agent cwd |
Build / dev paths¶
| Path | Purpose |
|---|---|
<repo>/ |
Wherever you cloned Loom |
~/Library/Developer/Xcode/DerivedData/Loom-*/Build/Products/Release/Loom.app |
Build output |
<repo>/build/release/Loom-<version>.dmg |
Packaged DMG ready for gh release upload |
<repo>/build/release/Loom-<version>.dmg.sha256 |
SHA-256 sidecar for the DMG |
Why Application Support, not the app bundle?¶
The app bundle is read-only after Gatekeeper-blessing it; SwiftData and the staging directory both need write access. Application Support is the standard macOS location for that.
Why not iCloud Drive?¶
Two reasons:
- iCloud renames build artifacts mid-build (
Foo->Foo 2) when sync detects a duplicate, which breaks Xcode and DMG packaging. - Loom is single-device by design.
The repo's project.yml defensively sweeps * 2.* and * 3.* shadow
files before each build and excludes them from the source list.
16.2. Keychain Keys¶
Service: com.chasesims.Loom. Every secret uses
kSecClassGenericPassword with
kSecAttrAccessibleWhenUnlockedThisDeviceOnly and
kSecAttrSynchronizable: false.
| Account | Set by | Purpose |
|---|---|---|
anthropic_api_key |
Settings -> Advanced | Anthropic API key for direct-API agent provider |
local_endpoint_<UUID> |
Settings -> Providers (when Requires auth is on) | Bearer token for an OpenAI-compatible local endpoint |
<UUID> is the LocalEndpoint.id. Each endpoint gets its own Keychain
item; deleting an endpoint deletes its item.
CLI inspection¶
# View what Loom has stored.
security dump-keychain | grep -A1 "com.chasesims.Loom"
# Read a specific value (shows the password in stdout).
security find-generic-password -s com.chasesims.Loom -a anthropic_api_key -w
# Delete one.
security delete-generic-password -s com.chasesims.Loom -a anthropic_api_key
# Delete every Loom secret in one go (DESTRUCTIVE).
security dump-keychain | awk -F\" '/svce.*com.chasesims.Loom/{getline; print $4}' | \
xargs -I{} security delete-generic-password -s com.chasesims.Loom -a {}
What is NOT in Keychain¶
- The Claude Code OAuth token. Lives in
~/.claude/credentials.json, managed by the Claude Code CLI itself. Loom does not read or modify it. - Workspace data (kanban, notes). SwiftData on disk.
- Settings (theme, stale window). UserDefaults.
16.3. UserDefaults Keys¶
| Key | Type | Purpose |
|---|---|---|
loom.appearance |
String | Theme picker value |
loom.tasks.staleHours |
Double | Live tasks stale window (hours) |
loom.localEndpoints |
Data | JSON-encoded [LocalEndpoint] |
loom.workspaceSeed.v0_8 |
Bool | One-time migration flag |
loom.workspaceSeed.v0_9 |
Bool | One-time migration flag |
loom.workspaceSeed.v0_10 |
Bool | One-time migration flag |
Inspect via:
17. Releasing a New Build¶
Loom's release script is bin/release.sh. Run from the repo root:
# 1. Bump MARKETING_VERSION (and CURRENT_PROJECT_VERSION) in project.yml.
# 2. Commit + push.
bin/release.sh
Prereqs¶
xcodegenandxcodebuild(Xcode CLI tools).hdiutil(built-in).ghCLI authenticated for the active account (gh auth login -h github.com).- A clean working tree at the commit you want to tag.
What the script does¶
- Reads version from
project.yml(MARKETING_VERSIONandCURRENT_PROJECT_VERSION). - Pre-flight: verify
ghis authed (viagh api user), the local tag does not already exist, and the GitHub release does not already exist. - Regenerate the Xcode project:
xcodegen generate. - Build Release:
xcodebuild ... -configuration Release build. - Locate the built
.appunder DerivedData. - Stage
.appand an/Applicationsalias in a temp dir; strip xattrs. - Package the DMG via
hdiutil create -format UDZO, namedLoom-<version>.dmg. - Compute SHA-256 of the DMG; write a
.sha256sidecar file. - Tag and push:
git tag -a v<version> -m "Loom <version> (<build>)",git push origin v<version>. - Create the GitHub release via
gh release createwith both the DMG and the.sha256sidecar attached.
Post-release¶
Every running Loom on every machine picks the new build up via the auto-update path within 60 seconds.
What can go wrong¶
- "tag vX.Y.Z already exists locally": you forgot to bump
MARKETING_VERSION. Bump it, commit, retry. - "built Release/Loom.app not found under DerivedData":
xcodebuildfailed silently. Re-run with-quietremoved from the script to see the actual compile errors. gh release create422: the release already exists on GitHub. Bump version, retry.- Local codesign fails: see Building from Source for the local-codesign cert setup.
Why ad-hoc signing?¶
Loom is a personal tool with no Apple Developer Program enrollment. Ad-hoc signing (with the local "Loom Local Codesign" cert) is enough for local distribution; users do the right-click -> Open dance once and macOS remembers.
18. Building from Source¶
brew install xcodegen # one-time
git clone https://github.com/BigBeardedMan/Loom.git
cd Loom
xcodegen generate
open Loom.xcodeproj
Then build & run from Xcode (Command R). macOS 14+.
Local codesign cert¶
project.yml declares CODE_SIGN_IDENTITY: "Loom Local Codesign". This is
a self-signed cert in the user's login keychain. It exists so granted
folder permissions and TCC grants persist across rebuilds (otherwise every
clean build invalidates the bundle's stable identity and macOS re-prompts
for every protected resource).
To create the cert (one time):
- Open Keychain Access.
- Choose Keychain Access -> Certificate Assistant -> Create a Certificate.
- Name:
Loom Local Codesign. Identity Type: Self Signed Root. Certificate Type: Code Signing. - Save into the login keychain.
Without this cert, the build will fall back to ad-hoc signing (-) which
also works but loses TCC grant stability across rebuilds.
Why xcodegen?¶
Loom.xcodeproj is a build artifact. The source of truth is project.yml.
Edit project.yml, run xcodegen generate, and the project regenerates
from scratch. The committed .xcodeproj exists for convenience (so
casual users can open Loom.xcodeproj without installing xcodegen first)
but should never be edited by hand.
Pre and post-build scripts¶
Defined in project.yml:
- Pre-build:
findand delete iCloud shadow duplicates (* 2.*,* 3.*) inside the source tree. Defensive cleanup before xcodegen sees the source list. - Post-build:
xattr -cr "$TARGET_BUILD_DIR/$WRAPPER_NAME"to strip Finder/iCloud xattrs that confuse Gatekeeper.
19. Troubleshooting¶
"Loom can't be opened because Apple cannot check it for malicious software"¶
Right-click /Applications/Loom.app in Finder, choose Open, confirm
the dialog. Subsequent launches behave normally.
Auto-update never lights up¶
- Confirm a newer release is actually published:
gh release view --repo BigBeardedMan/Loom. - Wait at least 60 seconds; the remote poll is on a one-minute cadence.
- Help -> Check for Updates... to force a remote check now and surface any error.
- Inspect
~/Library/Application Support/Loom/staging/last-apply.logfor any failed swap. - Check
~/Library/Application Support/Loom/staging/. If the staged bundle is present and the manifest is valid, the local 4 second poll should be lighting the pill. If the manifest is missing, the remote stage failed; check Console.app forcom.chasesims.Loomlog entries under theupdatescategory.
Update fails with "Release is missing the .sha256 checksum sidecar"¶
The release is published without a SHA-256 sidecar. Loom refuses to
install. Either re-run bin/release.sh (which now publishes the sidecar)
or manually upload Loom-<version>.dmg.sha256 to the existing release.
Agent pane returns "Failed to launch claude: ..."¶
claude is not on PATH for the user account that launched Loom.
Install via npm i -g @anthropic-ai/claude-code or the official
installer, then verify via which claude in a fresh terminal. The Agent
pane uses a cached interactive PATH (re-spawned via zsh -lic 'echo $PATH'
on first send), so newly-installed claude may need an app restart.
Agent pane returns "Cancelled" repeatedly¶
The Stop button bumps a generation counter. If a previous send is still in flight when you hit Stop, its response will arrive but be discarded as stale. Wait for "Cancelled" once and the next send should proceed normally.
Local LLM endpoint shows "HTTP 404" on every send¶
Check the Base URL. OpenAI-compatible servers expect the /v1 suffix
(e.g. http://localhost:1234/v1). Ollama does not (http://localhost:11434).
Local LLM endpoint shows "Could not connect to the server"¶
The daemon is not running, the port is wrong, or a firewall is blocking it. The Test connection button in the editor sheet isolates the network problem from the model problem.
Live agent tasks pane shows nothing¶
- Make sure a CLI agent is actually running in a Terminal pane.
- Check that
~/.claude/tasks/<session-id>/contains JSON files. - Check the stale window in Settings -> Tasks; if your last task update is older than the window, the session is hidden.
Usage dashboard's Year refresh hangs¶
Year-range refreshes can take roughly a minute on a large Claude Code log. The dashboard shows a giant centered throbber over the existing data while it computes; do not click the dashboard or switch workspaces during the refresh and it will complete on its own.
Editor pane refuses to open my file¶
The file extension is in the binary guard list. To force-open, rename
or copy the file to a non-binary extension. The binary list is in
EditorPaneView.swift's isBinary(_:) method.
Workspace folder doesn't update the terminal cwd¶
The terminal is only seeded with the workspace folder on launch. Close the Terminal pane (the x in its title bar) and re-add it to get a fresh shell rooted at the new folder.
iCloud is renaming my source files mid-build¶
The repo lives under an iCloud-synced location (commonly ~/Documents).
The repo's pre-build script defensively deletes * 2.* and * 3.*
shadow files before each build, but the cleanest fix is to move the
clone outside iCloud (~/code, ~/dev, etc.).
20. Roadmap¶
Items in active design, not promises. Order is rough priority.
| Item | Why |
|---|---|
| Command-block terminal history | Every shell command becomes its own scrollable, copyable card with exit code and timing. The terminal is Loom's product differentiator; structuring its output is the next step. |
| Multi-pane terminal layouts | Split panes inside one Terminal pane (without spinning up multiple Terminal blocks). |
| MCP server bridging | Native MCP support so Loom can expose its own state (kanban cards, workspace folder, layout) as MCP tools to the agents it hosts. |
| CodeEdit integration | Replace the plain TextEditor with CodeEdit's NSTextView-based editing surface. Syntax highlighting, save in-pane. |
| Persistent agent message history | Today the chat log is in-memory only. Persist per-workspace so a quit-relaunch does not lose context. |
| Codex live tasks | Mirror in-progress task state from Codex sessions the same way Claude Code is mirrored today. |
| Anthropic API model picker in Settings | Today the Anthropic API provider's model is hardcoded in AnthropicProvider. Surface it in Settings. |
Appendix: Versioning¶
Loom follows Semantic Versioning. The version is
bumped in project.yml (MARKETING_VERSION) on every meaningful build.
CURRENT_PROJECT_VERSION is the monotonic build number; bump it whenever
MARKETING_VERSION changes.
The Bundle reads both via CFBundleShortVersionString and
CFBundleVersion. The Usage dashboard, the auto-update flow, the release
script, and the GitHub release all key off MARKETING_VERSION.
Appendix: License¶
Apache License 2.0. See LICENSE.
Appendix: Credits¶
- SwiftTerm by Miguel de Icaza, the only third-party dependency.
- The Claude Code, Codex, and Gemini CLIs, whose on-disk logs make the Usage dashboard and live tasks reader possible.
- Apple's SwiftUI, SwiftData, AppKit, WebKit, and CryptoKit frameworks, on which the rest of the app is built.