melandlabs/openloomi
GitHub基于Rust的高性能浏览器自动化CLI,专为AI代理设计。支持导航、表单填写、点击、截图及数据提取。通过快照获取元素引用进行交互,适用于网页测试、登录及任意需程序化Web交互的任务。
Install All Skills
npx skills add melandlabs/openloomi --all -g -y
More Options
List skills in collection
npx skills add melandlabs/openloomi --list
Skills in Collection (15)
skills/agent-browser/SKILL.md
npx skills add melandlabs/openloomi --skill agent-browser -g -y
SKILL.md
Frontmatter
{
"name": "agent-browser",
"description": "Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to \"open a website\", \"fill out a form\", \"click a button\", \"take a screenshot\", \"scrape data from a page\", \"test this web app\", \"login to a site\", \"automate browser actions\", or any task requiring programmatic web interaction.",
"allowed-tools": "Bash(npx agent-browser:*), Bash(agent-browser:*)"
}
Browser Automation with agent-browser
Installation
Global Installation (recommended)
Installs the native Rust binary for maximum performance:
npm install -g agent-browser
agent-browser install # Download Chromium
This is the fastest option -- commands run through the native Rust CLI directly with sub-millisecond parsing overhead.
Quick Start (no install)
Run directly with npx if you want to try it without installing globally:
npx agent-browser install # Download Chromium (first time only)
npx agent-browser open example.com
Note:
npxroutes through Node.js before reaching the Rust CLI, so it is noticeably slower than a global install. For regular use, install globally.
Homebrew (macOS)
brew install agent-browser
agent-browser install # Download Chromium
Linux Dependencies
On Linux, install system dependencies:
agent-browser install --with-deps
# or manually: npx playwright install-deps chromium
Core Workflow
Every browser automation follows this pattern:
- Navigate:
agent-browser open <url> - Snapshot:
agent-browser snapshot -i(get element refs like@e1,@e2) - Interact: Use refs to click, fill, select
- Re-snapshot: After navigation or DOM changes, get fresh refs
agent-browser open https://example.com/form
agent-browser snapshot -i
# Output: @e1 [input type="email"], @e2 [input type="password"], @e3 [button] "Submit"
agent-browser fill @e1 "user@example.com"
agent-browser fill @e2 "password123"
agent-browser click @e3
agent-browser wait --load networkidle
agent-browser snapshot -i # Check result
Command Chaining
Commands can be chained with && in a single shell invocation. The browser persists between commands via a background daemon, so chaining is safe and more efficient than separate calls.
# Chain open + wait + snapshot in one call
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i
# Chain multiple interactions
agent-browser fill @e1 "user@example.com" && agent-browser fill @e2 "password123" && agent-browser click @e3
# Navigate and capture
agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png
When to chain: Use && when you don't need to read the output of an intermediate command before proceeding (e.g., open + wait + screenshot). Run commands separately when you need to parse the output first (e.g., snapshot to discover refs, then interact using those refs).
Essential Commands
# Navigation
agent-browser open <url> # Navigate (aliases: goto, navigate)
agent-browser close # Close browser
# Snapshot
agent-browser snapshot -i # Interactive elements with refs (recommended)
agent-browser snapshot -i -C # Include cursor-interactive elements (divs with onclick, cursor:pointer)
agent-browser snapshot -s "#selector" # Scope to CSS selector
# Interaction (use @refs from snapshot)
agent-browser click @e1 # Click element
agent-browser click @e1 --new-tab # Click and open in new tab
agent-browser fill @e2 "text" # Clear and type text
agent-browser type @e2 "text" # Type without clearing
agent-browser select @e1 "option" # Select dropdown option
agent-browser check @e1 # Check checkbox
agent-browser press Enter # Press key
agent-browser scroll down 500 # Scroll page
# Get information
agent-browser get text @e1 # Get element text
agent-browser get url # Get current URL
agent-browser get title # Get page title
# Wait
agent-browser wait @e1 # Wait for element
agent-browser wait --load networkidle # Wait for network idle
agent-browser wait --url "**/page" # Wait for URL pattern
agent-browser wait 2000 # Wait milliseconds
# Capture
agent-browser screenshot # Screenshot to temp dir
agent-browser screenshot --full # Full page screenshot
agent-browser screenshot --annotate # Annotated screenshot with numbered element labels
agent-browser pdf output.pdf # Save as PDF
# Diff (compare page states)
agent-browser diff snapshot # Compare current vs last snapshot
agent-browser diff snapshot --baseline before.txt # Compare current vs saved file
agent-browser diff screenshot --baseline before.png # Visual pixel diff
agent-browser diff url <url1> <url2> # Compare two pages
agent-browser diff url <url1> <url2> --wait-until networkidle # Custom wait strategy
agent-browser diff url <url1> <url2> --selector "#main" # Scope to element
Common Patterns
Form Submission
agent-browser open https://example.com/signup
agent-browser snapshot -i
agent-browser fill @e1 "Jane Doe"
agent-browser fill @e2 "jane@example.com"
agent-browser select @e3 "California"
agent-browser check @e4
agent-browser click @e5
agent-browser wait --load networkidle
Authentication with State Persistence
# Login once and save state
agent-browser open https://app.example.com/login
agent-browser snapshot -i
agent-browser fill @e1 "$USERNAME"
agent-browser fill @e2 "$PASSWORD"
agent-browser click @e3
agent-browser wait --url "**/dashboard"
agent-browser state save auth.json
# Reuse in future sessions
agent-browser state load auth.json
agent-browser open https://app.example.com/dashboard
Session Persistence
# Auto-save/restore cookies and localStorage across browser restarts
agent-browser --session-name myapp open https://app.example.com/login
# ... login flow ...
agent-browser close # State auto-saved to ~/.agent-browser/sessions/
# Next time, state is auto-loaded
agent-browser --session-name myapp open https://app.example.com/dashboard
# Encrypt state at rest
export AGENT_BROWSER_ENCRYPTION_KEY=$(openssl rand -hex 32)
agent-browser --session-name secure open https://app.example.com
# Manage saved states
agent-browser state list
agent-browser state show myapp-default.json
agent-browser state clear myapp
agent-browser state clean --older-than 7
Data Extraction
agent-browser open https://example.com/products
agent-browser snapshot -i
agent-browser get text @e5 # Get specific element text
agent-browser get text body > page.txt # Get all page text
# JSON output for parsing
agent-browser snapshot -i --json
agent-browser get text @e1 --json
Parallel Sessions
agent-browser --session site1 open https://site-a.com
agent-browser --session site2 open https://site-b.com
agent-browser --session site1 snapshot -i
agent-browser --session site2 snapshot -i
agent-browser session list
Connect to Existing Chrome
# Auto-discover running Chrome with remote debugging enabled
agent-browser --auto-connect open https://example.com
agent-browser --auto-connect snapshot
# Or with explicit CDP port
agent-browser --cdp 9222 snapshot
Color Scheme (Dark Mode)
# Persistent dark mode via flag (applies to all pages and new tabs)
agent-browser --color-scheme dark open https://example.com
# Or via environment variable
AGENT_BROWSER_COLOR_SCHEME=dark agent-browser open https://example.com
# Or set during session (persists for subsequent commands)
agent-browser set media dark
Visual Browser (Debugging)
agent-browser --headed open https://example.com
agent-browser highlight @e1 # Highlight element
agent-browser record start demo.webm # Record session
agent-browser profiler start # Start Chrome DevTools profiling
agent-browser profiler stop trace.json # Stop and save profile (path optional)
Local Files (PDFs, HTML)
# Open local files with file:// URLs
agent-browser --allow-file-access open file:///path/to/document.pdf
agent-browser --allow-file-access open file:///path/to/page.html
agent-browser screenshot output.png
iOS Simulator (Mobile Safari)
# List available iOS simulators
agent-browser device list
# Launch Safari on a specific device
agent-browser -p ios --device "iPhone 16 Pro" open https://example.com
# Same workflow as desktop - snapshot, interact, re-snapshot
agent-browser -p ios snapshot -i
agent-browser -p ios tap @e1 # Tap (alias for click)
agent-browser -p ios fill @e2 "text"
agent-browser -p ios swipe up # Mobile-specific gesture
# Take screenshot
agent-browser -p ios screenshot mobile.png
# Close session (shuts down simulator)
agent-browser -p ios close
Requirements: macOS with Xcode, Appium (npm install -g appium && appium driver install xcuitest)
Real devices: Works with physical iOS devices if pre-configured. Use --device "<UDID>" where UDID is from xcrun xctrace list devices.
Diffing (Verifying Changes)
Use diff snapshot after performing an action to verify it had the intended effect. This compares the current accessibility tree against the last snapshot taken in the session.
# Typical workflow: snapshot -> action -> diff
agent-browser snapshot -i # Take baseline snapshot
agent-browser click @e2 # Perform action
agent-browser diff snapshot # See what changed (auto-compares to last snapshot)
For visual regression testing or monitoring:
# Save a baseline screenshot, then compare later
agent-browser screenshot baseline.png
# ... time passes or changes are made ...
agent-browser diff screenshot --baseline baseline.png
# Compare staging vs production
agent-browser diff url https://staging.example.com https://prod.example.com --screenshot
diff snapshot output uses + for additions and - for removals, similar to git diff. diff screenshot produces a diff image with changed pixels highlighted in red, plus a mismatch percentage.
Timeouts and Slow Pages
The default Playwright timeout is 60 seconds for local browsers. For slow websites or large pages, use explicit waits instead of relying on the default timeout:
# Wait for network activity to settle (best for slow pages)
agent-browser wait --load networkidle
# Wait for a specific element to appear
agent-browser wait "#content"
agent-browser wait @e1
# Wait for a specific URL pattern (useful after redirects)
agent-browser wait --url "**/dashboard"
# Wait for a JavaScript condition
agent-browser wait --fn "document.readyState === 'complete'"
# Wait a fixed duration (milliseconds) as a last resort
agent-browser wait 5000
When dealing with consistently slow websites, use wait --load networkidle after open to ensure the page is fully loaded before taking a snapshot. If a specific element is slow to render, wait for it directly with wait <selector> or wait @ref.
Session Management and Cleanup
When running multiple agents or automations concurrently, always use named sessions to avoid conflicts:
# Each agent gets its own isolated session
agent-browser --session agent1 open site-a.com
agent-browser --session agent2 open site-b.com
# Check active sessions
agent-browser session list
Always close your browser session when done to avoid leaked processes:
agent-browser close # Close default session
agent-browser --session agent1 close # Close specific session
If a previous session was not closed properly, the daemon may still be running. Use agent-browser close to clean it up before starting new work.
Ref Lifecycle (Important)
Refs (@e1, @e2, etc.) are invalidated when the page changes. Always re-snapshot after:
- Clicking links or buttons that navigate
- Form submissions
- Dynamic content loading (dropdowns, modals)
agent-browser click @e5 # Navigates to new page
agent-browser snapshot -i # MUST re-snapshot
agent-browser click @e1 # Use new refs
Annotated Screenshots (Vision Mode)
Use --annotate to take a screenshot with numbered labels overlaid on interactive elements. Each label [N] maps to ref @eN. This also caches refs, so you can interact with elements immediately without a separate snapshot.
agent-browser screenshot --annotate
# Output includes the image path and a legend:
# [1] @e1 button "Submit"
# [2] @e2 link "Home"
# [3] @e3 textbox "Email"
agent-browser click @e2 # Click using ref from annotated screenshot
Use annotated screenshots when:
- The page has unlabeled icon buttons or visual-only elements
- You need to verify visual layout or styling
- Canvas or chart elements are present (invisible to text snapshots)
- You need spatial reasoning about element positions
Semantic Locators (Alternative to Refs)
When refs are unavailable or unreliable, use semantic locators:
agent-browser find text "Sign In" click
agent-browser find label "Email" fill "user@test.com"
agent-browser find role button click --name "Submit"
agent-browser find placeholder "Search" type "query"
agent-browser find testid "submit-btn" click
JavaScript Evaluation (eval)
Use eval to run JavaScript in the browser context. Shell quoting can corrupt complex expressions -- use --stdin or -b to avoid issues.
# Simple expressions work with regular quoting
agent-browser eval 'document.title'
agent-browser eval 'document.querySelectorAll("img").length'
# Complex JS: use --stdin with heredoc (RECOMMENDED)
agent-browser eval --stdin <<'EVALEOF'
JSON.stringify(
Array.from(document.querySelectorAll("img"))
.filter(i => !i.alt)
.map(i => ({ src: i.src.split("/").pop(), width: i.width }))
)
EVALEOF
# Alternative: base64 encoding (avoids all shell escaping issues)
agent-browser eval -b "$(echo -n 'Array.from(document.querySelectorAll("a")).map(a => a.href)' | base64)"
Why this matters: When the shell processes your command, inner double quotes, ! characters (history expansion), backticks, and $() can all corrupt the JavaScript before it reaches agent-browser. The --stdin and -b flags bypass shell interpretation entirely.
Rules of thumb:
- Single-line, no nested quotes -> regular
eval 'expression'with single quotes is fine - Nested quotes, arrow functions, template literals, or multiline -> use
eval --stdin <<'EVALEOF' - Programmatic/generated scripts -> use
eval -bwith base64
Configuration File
Create agent-browser.json in the project root for persistent settings:
{
"headed": true,
"proxy": "http://localhost:8080",
"profile": "./browser-data"
}
Priority (lowest to highest): ~/.agent-browser/config.json < ./agent-browser.json < env vars < CLI flags. Use --config <path> or AGENT_BROWSER_CONFIG env var for a custom config file (exits with error if missing/invalid). All CLI options map to camelCase keys (e.g., --executable-path -> "executablePath"). Boolean flags accept true/false values (e.g., --headed false overrides config). Extensions from user and project configs are merged, not replaced.
Deep-Dive Documentation
| Reference | When to Use |
|---|---|
| references/commands.md | Full command reference with all options |
| references/snapshot-refs.md | Ref lifecycle, invalidation rules, troubleshooting |
| references/session-management.md | Parallel sessions, state persistence, concurrent scraping |
| references/authentication.md | Login flows, OAuth, 2FA handling, state reuse |
| references/video-recording.md | Recording workflows for debugging and documentation |
| references/profiling.md | Chrome DevTools profiling for performance analysis |
| references/proxy-support.md | Proxy configuration, geo-testing, rotating proxies |
Ready-to-Use Templates
| Template | Description |
|---|---|
| templates/form-automation.sh | Form filling with validation |
| templates/authenticated-session.sh | Login once, reuse state |
| templates/capture-workflow.sh | Content extraction with screenshots |
./templates/form-automation.sh https://example.com/form
./templates/authenticated-session.sh https://app.example.com/login
./templates/capture-workflow.sh https://example.com ./output
skills/composio/SKILL.md
npx skills add melandlabs/openloomi --skill composio -g -y
SKILL.md
Frontmatter
{
"name": "composio",
"tags": [
"composio",
"tool-router",
"agents",
"mcp",
"tools",
"api",
"automation",
"cli"
],
"description": "Use 1000+ external apps via Composio - either directly through the CLI or by building AI agents and apps with the SDK"
}
When to Apply
- User wants to access or interact with external apps (Gmail, Slack, GitHub, Notion, etc.)
- User wants to automate a task using an external service (send email, create issue, post message)
- Building an AI agent or app that integrates with external tools
- Multi-user apps that need per-user connections to external services
Setup
Check if the CLI is installed; if not, install it:
curl -fsSL https://composio.dev/install | bash
After installation, restart your terminal or source your shell config, then authenticate:
composio login # OAuth; interactive org/project picker (use -y to skip)
composio whoami # verify org_id, project_id, user_id
For agents without direct browser access: composio login --no-wait | jq to get URL/key, share URL with user, then composio login --key <cli_key> --no-wait once they complete login.
1. Use Apps via Composio CLI
Use this when: The user wants to take action on an external app directly — no code writing needed. The agent uses the CLI to search, connect, and execute tools on behalf of the user.
Key commands (new top-level aliases):
composio search "<query>"— find tools by use casecomposio execute "<TOOL_SLUG>" -d '{...<input params>}'— execute a toolcomposio link [toolkit]— connect a user account to an app (agents: always use--no-waitfor non-interactive mode)composio listen— listen for real-time trigger events
Typical workflow: search → link (if needed) → execute
Full reference: Composio CLI Guide
2. Building Apps and Agents with Composio
Use this when: Writing code — an AI agent, app, or backend service that integrates with external tools via the Composio SDK.
Run this first inside the project directory to set up the API key:
composio init
Full reference: Building with Composio
skills/cua-driver/SKILL.md
npx skills add melandlabs/openloomi --skill cua-driver -g -y
SKILL.md
Frontmatter
{
"name": "cua-driver",
"description": "Drive a native macOS app via the cua-driver CLI (default) or MCP server — snapshot its AX tree, click\/type\/scroll by element_index, verify via re-snapshot. Use when the user asks you to operate, drive, automate, or perform a GUI task in a real macOS application on the host (e.g. \"open a file in TextEdit\", \"navigate to \/Applications in Finder\", \"click the Save button in Numbers\")."
}
cua-driver
Orchestrates macOS app automation via cua-driver. Whenever a user
asks to drive a native macOS app, follow the loop in this skill rather
than calling tools ad-hoc — the snapshot-before-action invariant is not
optional and silently breaks if you skip it.
The no-foreground contract — read this first
The user's frontmost app MUST NOT change. This is the whole reason cua-driver exists. Users pay for the right to keep typing in their editor while an agent drives another app in the background. Violate this rule and every other nice property the driver gives you (no cursor warp, no Space switch, no window raise) stops mattering — you just shipped the Accessibility Inspector with extra steps.
Before running any shell command, ask: "does this raise, activate, foreground, or make-key any app?" If yes, don't run it. Every one of the commands below activates the target on macOS and is therefore forbidden unless the user explicitly asked for frontmost state:
-
Every form of the
openCLI —open -a <App>,open -b <bundle-id>,open <file>,open <path-to-App.app>,open <url>— always activates. macOS routes all forms through LaunchServices, which unhides and foregrounds the target regardless of whether you passed an app name, a bundle id, a document, a URL, or the bundle path itself. The activation happens even when the only intent was "start the process." Never useopenfor any app launch. This includes launching a just-built .app from a local build dir (e.g.open build/Build/Products/Debug/MyApp.app) — resolve theCFBundleIdentifierfromInfo.plistand uselaunch_appwith that id. See "The narrow carve-out" below for whylaunch_appis safe even when the app internally callsNSApp.activate. -
osascript -e 'tell application "X" to activate'— activates by design. Same for... to open <file>,... to launch, and anything withactivatein the tell block. -
osascript -e 'tell application "System Events" to ... frontmost'in a mutating form (settingfrontmostrather than reading it). -
AppleScript files that invoke
activate,launch, oropenagainst the target app. -
cliclick(moves the user's real cursor to the target coords before clicking — a focus-steal-equivalent even if the app's window state is unchanged). -
CGEventPostwithcghidEventTaptargeting a coordinate over a different app's window (warps the cursor, possibly activates on hit). -
AppleScriptTask,NSAppleScript,Processwrappingosascriptthat contains any of the above. -
NSRunningApplication.activate(options:)called from your own helper binary — same class. -
Dock clicks and any
openinvocation (see the first bullet — every form ofopengoes through LaunchServices which activates, full stop). -
Keyboard shortcuts that semantically mean "focus here" — most notably Chrome / Safari / Arc's
⌘L(focus omnibox) and Finder's⌘⇧G(Go to Folder). These aren't pure key events — the receiving app interprets "user wants to type here" as activation intent and raises its window to be key. Even when delivered to a backgrounded pid viahotkey, the downstream app pulls focus. For omnibox navigation specifically, the correct path islaunch_app({bundle_id: "com.google.Chrome", urls: ["https://…"]})— no omnibox dance, no⌘L, no focus-steal. Do NOT tryset_valueon the omnibox: Chrome's commit logic requires a "user-typed" signal that neither an AX value write norCGEvent.postToPidkeystrokes supply from a backgrounded pid — the URL lands in the field but Return fires as a no-op. SeeWEB_APPS.md→ "Navigate to a URL" for the full pattern. The general principle: a shortcut that says "put my cursor inside this app" is a focus-steal; a shortcut that says "do this thing" (copy, save, quit) is fine. -
Tab-switching shortcuts in browsers (
⌘1..⌘9,⌘],⌘[,⌘⇧[,⌘⇧]) are visibly disruptive even when delivered to a backgrounded pid. The app's key handler processes the shortcut, the window re-renders the new tab's content, the user sees their tabs flipping. There is no AX-only workaround: page content (HTML, form state,AXWebArea) populates only for the focused tab; inspecting a background tab requires activating it, which is the visible flip. Observed with Dia; the same mechanic applies to every Chromium-family browser (Chrome, Arc, Brave, Edge).Prefer the windows-over-tabs pattern: for each URL you need to drive backgrounded, use
launch_app({bundle_id, urls: [url]})— browsers open each URL in a new window. Each window has its ownwindow_id, its own AX tree, and can be inspected / interacted with viaelement_indexwithout activating or switching anything. Tabs are a UX grouping for humans; cua-driver workflows should default to windows. SeeWEB_APPS.md→ "Tabs vs windows" for the full pattern.Tab-title enumeration (read-only) IS safe — walk a window's toolbar AX tree for
AXTab/AXRadioButtonchildren and read theirAXTitles. Tab switching (activating one) is not.
Reading frontmost state is fine (osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true'). Mutating it is not.
Corollary — the AXMenuBar rule. AXMenuBarItem + AXPick
dispatches at the AX layer regardless of which app is frontmost,
but macOS's on-screen menu bar always belongs to the frontmost
app. If you drive a backgrounded app's menu bar, the AX call
succeeds but the viewer sees the dispatch rendered over the
frontmost app's menu bar — confusing in any observed session and
routinely a silent no-op too, because action menu items go
DISABLED when their owning app isn't the key window. So: only
use menu-bar navigation when the target is already frontmost. For
backgrounded targets, read state via in-window AX (window title,
toolbar AXStaticText) and dispatch via in-window element_index
or pixel clicks — both paths are frontmost-insensitive. Full
rationale in "Navigating native menu bars" below.
"Open <app>" in user speech means launch, not activate.
cua-driver launch_app is the one correct path for process
startup — it's idempotent (no-op on a running app), returns the
pid, and has an internal FocusRestoreGuard that catches
NSApp.activate(ignoringOtherApps:) calls the target makes during
application(_:open:) and clobbers the frontmost back to what it
was before the launch. That guard is why launch_app with urls
(e.g. {"bundle_id": "com.colliderli.iina", "urls": ["~/video.mp4"]})
is safe even for apps that normally foreground on media-load
(Chrome, Electron, media players).
Defaults — always prefer cua-driver over shell shims
Default transport is the cua-driver CLI — Bash shelling out
to cua-driver <tool-name> '<JSON-args>'. MCP tools (prefix
mcp__cua-driver__*) only when the user explicitly asks for them.
CLI wins because it picks up rebuilds instantly, failures are
easier to diagnose, and there's no per-tool schema-load overhead.
Every reference to click(...), get_window_state(...) etc. in this
skill means cua-driver click '{...}' — translate to MCP form only
when MCP is requested.
Claude Code computer-use compatibility mode
For normal Claude Code use, keep the default CLI or cua-driver MCP server path above. If the user explicitly wants Claude Code's vision/computer-use-style flow, they can register:
claude mcp add --transport stdio cua-computer-use -- cua-driver mcp --claude-code-computer-use-compat
Observation: Claude Code vision flows appear to treat a screenshot MCP tool as the image-grounding anchor. This compatibility mode keeps the normal CuaDriver tools and changes only screenshot. The compatibility screenshot requires pid and window_id, captures only that target window, and returns the window-local pixel coordinate frame. Start with launch_app or list_windows, then call screenshot({pid, window_id}); do not assume desktop coordinates or a full-screen capture.
Use MCP for this Claude Code vision/computer-use-style path. Do not shell out to cua-driver screenshot as a substitute: CLI screenshots still work as CuaDriver calls, but they do not expose the mcp__cua-computer-use__screenshot tool name that Claude Code appears to use as the image-grounding cue.
Intent → tool mapping. If you find yourself reaching for the right column, something has gone wrong — re-read "The no-foreground contract" above:
| Intent | Use | Don't use |
|---|---|---|
| Open / launch an app | launch_app({bundle_id}) or launch_app({bundle_id, urls:[...]}) |
open -a, osascript 'tell app … to launch/activate/open' |
| Find a pid | list_apps or launch_app's return |
pgrep, ps, osascript frontmost |
| Enumerate an app's windows | list_windows({pid}) — or read the windows array launch_app already returns |
osascript 'every window of app …' |
| Click / type / scroll / keys | click, type_text, scroll, press_key, hotkey |
osascript, cliclick, raw CGEvent, open <url> |
| Drag / drag-and-drop / marquee select | drag({pid, from_x, from_y, to_x, to_y}) (pixel-only — macOS AX has no semantic drag) |
cliclick dd:, osascript drag |
| Screenshot | screenshot or the PNG in get_window_state |
screencapture |
| Quit an app | ask the user first, then hotkey({pid, keys:["cmd","q"]}) |
kill, killall, pkill |
| Hand a file/URL to an app | launch_app({bundle_id, urls:[<path>]}) |
open -a <App> <path>, open <url> |
The narrow carve-out
The only legitimate use of osascript -e 'tell app X to activate' is when the user explicitly asked for frontmost
state ("bring Chrome to the front", "make it frontmost", "I want
to see X"). Reaching for it because a tool call returned something
confusing is wrong — that's the skill's classic foot-in-the-door
failure mode and it steals focus every time.
When a cua-driver call surprises you, diagnose cua-driver first:
- Tiny screenshot / empty
tree_markdown? Checkcua-driver get_config→capture_mode. Default"som"returns both the AX tree and screenshot."vision"omits the AX tree (PNG only),"ax"omits the PNG. If a snapshot lacks a tree,capture_modeis almost certainly"vision"— either reason purely from the PNG or flip to"som"/"ax"viaset_config. has_screenshot: false? The window capture failed (transient race against a close, or the window has no backing store yet). Re-snapshot; if persistent, pick a differentwindow_idvialist_windows.Invalid element_index/No cached AX state? You either skippedget_window_statethis turn or passed a differentwindow_idthan the one the snapshot cached against. The cache is keyed on(pid, window_id)— indices don't carry across windows of the same app. Re-snapshot with the same window_id you're about to click in.- Sparse Chromium AX tree? Retry
get_window_stateonce — the tree populates on second call.
Only after those are ruled out, and only if the user's action genuinely needs frontmost state, fall through to the activate fallback. Always name the focus steal in your response ("I'll briefly bring Chrome to the front because …").
Self-check pattern
Before every Bash call whose command line touches any macOS app
(launching, opening, clicking, typing, scripting, screenshotting),
run the self-check:
- Does this command foreground the target? If yes — stop and translate to the cua-driver equivalent from the mapping table.
- Does this command move the user's real cursor? (
cliclick, anyCGEventPostatcghidEventTapover another app's window). If yes — stop; useclick({pid, x, y})which routes per-pid via SkyLight and never warps the cursor. - Does this command bypass cua-driver entirely? (
osascriptmutating GUI state, AppleScript files, external helpers.) If yes — stop; find the cua-driver tool that does the intent.
If all three are "no," the command is safe. If you can't answer,
default to stop and ask rather than proceed. A single open -a
run by accident kills the demo, the trust, and the user's in-flight
editor state.
Prerequisites — check before starting
cua-driveris on$PATH(which cua-driver). If not, point the user atscripts/install-local.shand stop.- Run
cua-driver check_permissions(with the daemon up — see step 3). The default behavior also raises the system permission dialogs for any missing grants, so the user can grant on the spot. If either grant still readsfalseafter that (user dismissed the dialog), tell them to open System Settings → Privacy & Security and grant Accessibility and Screen Recording toCuaDriver.app, then stop. Pass'{"prompt":false}'for a purely read-only status check that won't steal focus. - Start the daemon with
open -n -g -a CuaDriver --args serve(the recommended form — goes through LaunchServices so TCC attributes the process to CuaDriver.app).cua-driver serve &also works; the CLI auto-relaunches throughopen -n -g -a CuaDriverwhen it detects a wrong-TCC context (any IDE-spawned shell: Claude Code, Cursor, VS Code, Conductor). Verify withcua-driver status.
Using cua-driver from the shell
Tool names are snake_case, management subcommands are
kebab-case — no ambiguity. Tools invoked as cua-driver <tool-name> '<JSON-args>'. Management subcommands:
open -n -g -a CuaDriver --args serve— start persistent daemon (required forelement_indexworkflows; without it each CLI invocation spawns a fresh process and the per-pid element cache dies between calls).cua-driver serve &also works — the CLI auto-relaunches viaopenwhen the shell's TCC context is wrong. Pass--no-relaunch/CUA_DRIVER_NO_RELAUNCH=1to opt out.cua-driver stop/statuscua-driver list-tools,describe <tool>cua-driver recording start|stop|status— seeRECORDING.md
Canonical multi-step workflow:
open -n -g -a CuaDriver --args serve
cua-driver launch_app '{"bundle_id":"com.apple.calculator"}'
# → {pid: 844, windows: [{window_id: 10725, ...}]}
cua-driver get_window_state '{"pid":844,"window_id":10725}'
cua-driver click '{"pid":844,"window_id":10725,"element_index":14}'
cua-driver stop
Agent cursor overlay
Visual cursor overlay for demos and screen recordings. Default:
enabled. Toggle with cua-driver set_agent_cursor_enabled '{"enabled":true|false}'. A triangle pointer Bezier-glides to each
click target, ring-ripples on landing, idle-hides after ~1.5s.
Motion knobs: set_agent_cursor_motion takes any subset of
start_handle, end_handle, arc_size, arc_flow, spring —
tuneable at runtime, persisted to config.
Requires an AppKit runloop, which cua-driver serve / mcp
bootstraps. One-shot CLI invocations skip the overlay entirely.
The core invariant — snapshot before AND after every action
Every action MUST be bracketed by get_window_state(pid, window_id):
- Before — the pre-action snapshot resolves the
element_indexyou're about to use. Indices from previous turns are stale; the server replaces the element index map on every snapshot, keyed on(pid, window_id). Indices from turn N don't resolve in turn N+1, and indices from window A don't resolve against window B of the same app. Skip this and element-indexed actions fail withNo cached AX state. - After — the post-action snapshot verifies the action actually landed. Without it you can't tell a silent no-op from a real effect. The AX tree change (new value, new window, disappeared menu, disabled button, etc.) is your evidence that the action fired. If nothing changed, the action probably failed silently — say so, don't assume success.
This applies to pixel clicks too — re-snapshot after to confirm the click landed on the intended target.
Why window selection is the caller's job now
get_app_state used to pick a window for you via a max-area heuristic
that returned the wrong surface on apps with large off-screen utility
panels. Concrete reproducer: IINA's OpenSubtitles helper (600×432
off-screen) out-area'd the visible 320×240 player window, so
get_app_state(pid) screenshot'd the invisible panel and clicks landed
there silently. The new get_window_state(pid, window_id) makes the
caller name the window explicitly — the driver validates that the
window belongs to the pid and is on the current Space, then snapshots
exactly what was asked for. Enumerate candidates via list_windows or
read the windows array launch_app already returns.
Behavior matrix
Two orthogonal axes shape what the agent can do.
capture_mode → addressing mode
capture_mode |
get_window_state returns |
Use for actions |
|---|---|---|
som (default) |
tree + screenshot | element_index preferred; pixel fallback |
ax |
tree only (no PNG) | element_index only |
vision |
PNG only (no tree) | pixel only — see SCREENSHOT.md |
vision was renamed from screenshot — the old name still decodes
as a deprecated alias, so an on-disk "capture_mode": "screenshot"
keeps working. Default is som so element_index clicks work the
first time a user calls get_window_state; the other modes are
opt-in when the caller specifically doesn't want one half of the
work. Note the tool named screenshot is separate (raw PNG, no AX
walk) and unrelated to the capture mode.
When a snapshot looks wrong (tiny screenshot / empty tree), check
cua-driver get_config for capture_mode before anything else.
Pure-vision mode has its own caveats — Claude Code's vision pipeline downsamples dense text aggressively, so pixel grounding takes multiple correction cycles on text-heavy UIs. Read SCREENSHOT.md before driving anything in that mode; it documents the iterate/annotate/verify recipe plus the JPEG-over-PNG finding.
Window state → what works
| state | get_window_state |
click/set_value (AX) |
press_key commit (Return/Space/Tab) |
pixel click |
|---|---|---|---|---|
| frontmost | ✅ | ✅ | ✅ | ✅ |
| backgrounded / visible | ✅ | ✅ | ✅ | ✅ |
| minimized (Dock genie) | ✅ | ✅ (no deminiaturize — AX actions fire on the minimized window in place) | ❌ silent no-op / system beep — use set_value or click equivalent |
❌ no on-screen bounds |
hidden (hides=true / NSApp.hide) |
✅ | ✅ | depends | ❌ |
| on another Space | ⚠️ AX tree often stripped to menu-bar-only on SwiftUI apps (System Settings) — AppKit apps usually fine. Response carries off_space: true + window_space_ids so you can detect it |
✅ | ✅ | ❌ window not in current-Space list |
Critical cell — minimized + keyboard commit. The keystroke
reaches the app but AX focus doesn't propagate to renderer focus on
a minimized window. Workarounds in order of preference:
set_value to write the field's entire value directly, or AX-click
a commit-equivalent button (Go, Submit, checkbox). Tell the user
the window needs to un-minimize only as a last resort.
The canonical loop
launch_app(target)
→ pick window_id from the returned `windows` array
(or call list_windows(pid) separately)
→ get_window_state(pid, window_id)
→ [act] # every action also takes (pid, window_id)
→ get_window_state(pid, window_id) → verify
launch_app now returns a windows array alongside the pid, so the
common case collapses to two calls (launch_app → get_window_state)
without a separate list_windows hop.
1. Resolve target pid — always via launch_app
Always start with launch_app, whether or not the target is already
running. It's idempotent (relaunching returns the existing pid with no
side effects) and gives you the pid in one call — no list_apps hop.
launch_app({bundle_id: "com.apple.finder"})— preferred, unambiguous.launch_app({name: "Calculator"})— when bundle_id isn't known.
launch_app is a hidden-launch primitive by design — that's the
entire point of cua-driver: agents drive apps in the background while
the user keeps typing in their real foreground app. The target's
window is initialized (AX tree fully populated, clickable via
element_index, the pid appears in list_apps) but not drawn on
screen. The driver never activates or unhides apps on its own; that
would violate the no-foreground contract the whole driver exists to
protect.
If the user explicitly wants the window visible (usually for a demo
or recording), they unhide it themselves — Dock click, Cmd-Tab, or
Spotlight. Do not reach for open / osascript activate as a
shortcut to make the window visible; those paths break the backgrounded
invariant on every call, not just the call that "needed" the
foreground. Say out loud what the user needs to do ("click the
Todo app in your Dock to bring it forward") and let them do it.
Never shell out to any form of open (including open <path-to-App.app> for a just-built binary — resolve the bundle id
from Info.plist and use launch_app with that), osascript 'tell app … to launch/open', or similar. Those paths activate the target,
bypass the driver's focus-restore guard, and require a Bash
permission prompt the agent loop shouldn't be burning on app launch.
See "Prefer cua-driver tools over shell shims" above for the full
intent → tool mapping.
list_apps is for app-level discovery (answering "what's installed /
running / frontmost?") — not part of the core action loop. Skip it in
the loop. For window-level questions — "does this app have a
visible window?", "which Space is this window on?", "which of this
pid's windows is the main one?" — call list_windows instead; the
app record doesn't carry window state on purpose. In the common
single-window case you can skip list_windows entirely and read the
windows array that launch_app already returned.
2. Snapshot and act by element_index
Call get_window_state({pid, window_id}) with the window_id from
launch_app's windows array (or a fresh list_windows({pid}) if
you're interacting with a long-lived process). The default som
capture_mode returns both the AX tree and screenshot, so the
canonical loop works immediately without any config change. The rest
of this section walks through som mode. If you're in vision mode
(PNG only, no AX tree), flip back: cua-driver set_config '{"key": "capture_mode", "value": "som"}'.
In som mode (the default) the response carries:
tree_markdown— every actionable element tagged[N]. ThatNis theelement_index. The tree can be very large (Finder is ~1600 elements, ~190 KB); when it exceeds token limits the MCP harness saves it to a file and returns the path. UseBash+jq -r '.tree_markdown'+grepto pull the section you need.screenshot_file_path— absolute path to the saved screenshot whenscreenshot_out_filewas passed. Absent otherwise.screenshot_width/_height/_scale_factor— dimensions of the captured image. Present whenever a screenshot was taken. Getting the screenshot as a file (CLI and context-constrained agents):
# write to file — stdout stays readable (AX tree / summary only, no base64)
cua-driver get_window_state '{"pid":N,"window_id":W,"screenshot_out_file":"/tmp/shot.jpg"}'
# CLI --screenshot-out-file flag is equivalent and works for all capture modes
cua-driver get_window_state '{"pid":N,"window_id":W}' --screenshot-out-file /tmp/shot.jpg
Pass screenshot_out_file when using get_window_state via CLI or from an
agent whose context window can't absorb ~31 KB of inline base64 (e.g.
OpenCode with a local Ollama model). The MCP image content block is omitted
from the response when this param is set — the model receives only the AX
tree and screenshot_file_path, then reads the image from disk.
Reason over both the tree AND the screenshot — they're
complementary, not redundant. In som mode every
turn's get_window_state gives you both halves and you should pull
signal from each:
- The AX tree tells you what's clickable — roles, labels,
element_indexhandles, advertised actions, parent-child structure. This is the ground truth for dispatching. - The screenshot tells you which one — the tree often has
many buttons with similar or empty labels ("Delete", "OK",
anonymous UUID-labeled buttons, five
AXStaticText = " "), and visual context disambiguates. Captions, colors, layout relationships visible in pixels often don't show up in the AX tree at all (especially in Chromium / Electron / web content).
Canonical pattern: look at the screenshot to decide "the blue
Subscribe button on the top-right of the video card", then walk the
tree to find the matching AXButton and dispatch by its
element_index. Don't try to do it from just the tree — you'll
pick the wrong element when labels repeat. Don't try to do it from
just the screenshot — you lose the reliable AX-action path and the
safe backgrounded-dispatch.
Reach for pixel coordinates only when the target is a canvas / video / WebGL / custom-drawn surface that isn't in the AX tree (see Pixel-coordinate clicks below).
The actions=[...] list on each element is advisory, not
authoritative. cua-driver does not pre-flight check against it —
click({pid, element_index}) always attempts AXPress (or the
action you pass) and surfaces whatever the target returns. Many
apps accept AXPress on elements that don't advertise it — Chrome's
omnibox suggestion AXMenuItem is a live example. Try the click
first — pivot only on the returned AX error code.
Dispatch table (every row assumes a (pid, window_id) pair from the
last get_window_state; window_id is required alongside
element_index, ignored on pixel-only forms unless you want to
anchor the conversion against a specific window):
| Intent | Tool | Notes |
|---|---|---|
| List an app's windows | list_windows({pid}) |
returns window_id, title, bounds, z_index, is_on_screen, on_current_space. Already included in launch_app's response — only call this for long-lived pids |
| Snapshot a window | get_window_state({pid, window_id}) |
returns tree_markdown + screenshot_*; populates the (pid, window_id) element_index cache |
| Left click | click({pid, window_id, element_index}) |
default action: "press". Pixel form: click({pid, x, y}) (window_id optional — when supplied, pinpoints the anchor window) — modifier: ["cmd"] |
| Double-click / open | double_click({pid, window_id, element_index}) |
AXOpen when advertised (Finder items, openable rows); else stamped pixel double-click at the element's center. Pixel form: double_click({pid, x, y}) — primer-gated recipe lands on backgrounded Chromium web content (YouTube fullscreen, Finder open-on-dbl). click({..., count: 2}) still works and routes through the same recipe; double_click is the intent-first spelling |
| Right click / context menu | right_click({pid, window_id, element_index}) or click({pid, window_id, element_index, action: "show_menu"}) |
Chromium web-content coerces pixel right-click to left — see WEB_APPS.md |
| Type at cursor | type_text({pid, text, window_id, element_index}) |
AXSelectedText write; focuses first |
| Set whole field value | set_value({pid, window_id, element_index, value}) |
sliders, steppers, text fields; use for keyboard-commit workarounds on minimized windows |
| Scroll | scroll({pid, direction, amount, by, window_id, element_index}) |
synthesizes PageUp/PageDown/arrows via SLEventPostToPid |
| Focus + send key | press_key({pid, key, window_id, element_index, modifiers}) |
element_index sets AXFocused, then posts key |
| Send key to pid | press_key({pid, key, modifiers}) |
no focus change; key goes to pid's current focus |
| Modifier combo | hotkey({pid, keys}) |
e.g. ["cmd","c"]; posted per-pid, not HID tap |
| Unicode keystrokes | type_text({pid, text, delay_ms}) |
AX write with automatic CGEvent fallback; reaches Chromium/Electron inputs |
All keyboard/text primitives require pid. There is no
frontmost-routed variant — every key goes to the named target via
CGEvent.postToPid, so the driver cannot leak keystrokes into the
user's foreground app.
Why element_index is the primary path: works on hidden /
occluded / off-Space windows, no focus steal, stable across
rebuilds, labels tell you what you're clicking. Reach for pixel
coordinates only when AX can't.
Pixel-coordinate clicks
The pixel path (click({pid, x, y})) is for surfaces the AX tree
doesn't reach — canvases, video players, WebGL, custom-drawn controls.
Coords are window-local screenshot pixels (same space as the PNG
get_window_state returns). Top-left origin, y-down. The driver
handles screen-point conversion internally. Passing window_id
alongside x, y is optional but recommended — it pins the
coordinate conversion to the window whose screenshot produced the
pixel, rather than the driver's heuristic choice.
Reading coordinates from the PNG
PNGs returned by get_window_state are capped at 1568 px
long-side by default (max_image_dimension config), matching
Anthropic's multimodal-vision downsampling limit. That means the
image the model reasons over and the image the click tool's
coordinate system lives in are the same resolution — just look
at the PNG, pick a pixel, click at that pixel. No scaling math.
This is the default because the mismatch between "rendered
thumbnail" and "native PNG" was a recurring coord-estimation
footgun. If you opt out (explicit max_image_dimension=0 for
pixel-perfect verification flows), the old rule applies: don't
eyeball coords from whatever your client renders — it may be
2-4× smaller than the PNG on disk, and a 2% error in thumbnail
space becomes ~80 px in the real image. Use the crosshair recipe
below against the full-resolution file in that case.
get_window_state({pid, window_id})returns an image capped at 1568 long-side (default) plus its dimensions (screenshot_width/screenshot_height). Write the bytes to disk with--screenshot-out-file <path>in any capture mode — works identically invision(where it's the only way) andsom(where it sidesteps the jq + base64 dance on the splicedscreenshot_png_b64field).- You are a multimodal model — look at the PNG. Since the PNG matches what you see, pick the target pixel directly. No fractional math needed.
- When precision matters (small targets, dense UIs), draw a crosshair on the image (do not crop — cropping loses the coordinate system and requires error-prone offset math) and show it before clicking:
from PIL import Image, ImageDraw
img = Image.open('/tmp/shot.png')
draw = ImageDraw.Draw(img)
x, y = <your_coordinate>
r = 18
draw.ellipse([x-r, y-r, x+r, y+r], outline='red', width=4)
draw.line([x-30, y, x+30, y], fill='red', width=3)
draw.line([x, y-30, x, y+30], fill='red', width=3)
img.save('/tmp/shot_annotated.png')
- Only dispatch the click after the user (or your own re-read of the annotated image) confirms the crosshair is on target.
Addressing variants
click({pid, x, y})— single left-click.click({pid, x, y, count: 2})— double-click.click({pid, x, y, modifier: ["cmd"]})— cmd-click. Accepts any subset ofcmd/shift/option/ctrl.right_click({pid, x, y})— also takesmodifier.
The pixel path animates the agent cursor overlay but never warps
the real cursor. If the pid has no on-screen window the call errors
with pid X has no on-screen window — you need a visible window to
anchor the conversion.
How the pixel click is dispatched
The recipe is the backgrounded "noraise" sequence: yabai's
focus-without-raise SLPS event records followed by an off-screen
user-activation primer and the real click, all stamped via
SLEventPostToPid. The target app becomes AppKit-active for event
routing but its window does not rise to the front of the
z-stack, and macOS's "switch to Space with windows for app" follow
is suppressed. Full mechanics in
Sources/CuaDriverCore/Input/MouseInput.swift (clickViaAuthSignedPost)
and the companion FocusWithoutRaise.swift.
Known limits
- Chromium
<video>play/pause: pixel click is often rejected by HTML5's click-to-play handler on some builds. Use keyboard instead:press_key({pid, key: "k"})(YouTube) orpress_key({pid, key: "space"})(generic). Keyboard events travel through a different auth envelope. - Pixel right-click on Chromium web content coerces to a
left-click — a known Chromium renderer-IPC limitation that affects
every non-HID-tap synthesis path. For context menus on
AX-addressable elements (links, buttons, toolbar items), use
right_click({pid, element_index})instead.
Canvases, viewports, games (Blender, Unity, GHOST, Qt, wxWidgets)
Apps whose main surface is an OpenGL / Metal / Qt / wxWidgets
viewport expose no useful AX tree — the whole surface is one
opaque AXGroup or AXWindow from AX's perspective. Per-pid event
paths (SLEventPostToPid, CGEvent.postToPid) are filtered by the
viewport's own event-source check and silently dropped — the event
loop wants "real HID origin".
The working pattern:
- Bring the target frontmost (a brief
osascript activateis acceptable here — this is the carve-out the skill's osascript gate allows). CGEvent.post(tap: .cghidEventTap)with a leadingmouseMovedevent (~30 ms before the click).cua-driver clickwhen the target is frontmost automatically takes this path.- Accept that the real cursor visibly moves —
cghidEventTapis the system HID stream, the cursor warps to the click point.
There is no backgrounded path that reaches these apps today.
Navigating native menu bars (AXMenuBar)
Only drive the menu bar when the target app is frontmost. This
is the single most-misused cua-driver capability. If the target is
backgrounded, don't reach for AXMenuBarItem + AXPick — use
in-window element_index or pixel clicks instead. Two reasons, one
functional and one perceptual:
- Functional: menu items that touch document/playback/editor
state go
DISABLEDwhen their owning app isn't the key window (Preview rotate, IINA speed change, most editor commands). AXPick- AXPress will dispatch successfully from the driver's side but no-op at the target — you get a silent false-pass.
- Perceptual (matters for demos, screen recordings, and anything
the user watches live): macOS's screen-rendered menu bar
always belongs to the frontmost app. AXPick on a backgrounded
app's
AXMenuBarItemdispatches to that app's per-process menu at the AX layer, but any visible menu render happens over the frontmost app's menu bar — the viewer sees an IINA submenu flashing on top of Chrome's menus, which reads as "the agent clicked the wrong app." The AX call was correct; the frame the user sees is not. For recorded or observed sessions, this is an integrity bug even though it's not a correctness bug.
Good decision rule: if the target is not already frontmost, do
not use AXMenuBarItem at all. For reading in-window state,
snapshot the window AX tree — most apps expose the same state via
an in-window AXStaticText, title bar, or toolbar. For dispatching
actions, use in-window element_index (buttons, toolbar items) or
pixel clicks on in-window controls — both dispatch via AppKit's
window-under-pointer hit-test and are not frontmost-gated.
When the target IS frontmost, the menu-bar flow below is fine and the canonical path for menus.
The two-snapshot pattern (target frontmost only)
Menu contents are a two-snapshot flow. Closed AXMenu subtrees are
deliberately skipped during snapshot — otherwise every app's File /
Edit / View hierarchy plus every Recent Items macOS has ever seen
would inflate the tree 10-100x. But once a menu is open, its
AXMenuItem children do receive element_index values so you can
click them normally.
- Find the
[N] AXMenuBarItem "<Menu Name>"in the tree. click({pid, element_index: N, action: "pick"})— menu bar items implementAXPick("open my submenu"), notAXPress. Using the default action on an AXMenuBarItem is a no-op.- Re-snapshot. The expanded menu's items now appear under the bar
item as
[M] AXMenuItem "<Item Name>". - Click the target item — most items respond to
AXPress(default action). Submenus nest under the item and are walked the same way. - Re-snapshot and verify.
If you ever need to back out without selecting, press_key({pid, key: "escape"}) closes the open menu. Leaving a menu expanded between
turns poisons subsequent snapshots for that pid.
Commands gated on the target being frontmost
Some menu items and global shortcuts (Preview's Tools → Rotate
Right, ⌘R; anything in the View menu that manipulates the current
document; most editor commands) are disabled unless the target
app is the key / frontmost window. You'll see it in the AX tree
as DISABLED on the menu item even though the user's intent is
obviously valid.
Before activating, confirm you're in this narrow case — the menu
item still reads DISABLED after a fresh snapshot AND the action
the user requested genuinely requires frontmost (Preview rotate,
View menu document manipulation, editor commands). If either
check fails, don't activate.
When both checks pass, the driver has no activate tool
(deliberately — the whole point is backgroundable control), so
this is the one legitimate osascript fallback:
osascript -e 'tell application "<App Name>" to activate'
Then re-snapshot — the menu item loses its DISABLED tag — and
click({action: "pick"}) the item. Alternatively, a hotkey
call delivered to the now-frontmost app works for the shortcut
form (⌘R, ⌘+, etc.).
Always name the focus steal in your response so the user isn't surprised — "Briefly activating Preview to enable Tools → Rotate Right" or similar. Don't silently steal focus. You don't need to restore the previous frontmost afterwards unless the user asks — they can cmd-tab back.
Web-rendered apps (browsers, Electron, Tauri)
For Chrome / Edge / Brave / Arc / Safari, Electron apps (Slack,
VSCode, Notion, Discord), and Tauri apps — see WEB_APPS.md.
Covers: sparse AX tree population (retry-once pattern for Chromium),
URL navigation via omnibox suggestions, the set_value workaround
for keyboard commits on minimized windows (Return silently
no-ops — symptom is a macOS system beep; use set_value or click a
clickable equivalent), scrolling via synthetic PageUp/Down keystrokes,
in-page clicks, and typing into web inputs.
Chromium web content specifically also coerces right_click back to
left — use element_index for AX-addressable targets and accept the
limit otherwise.
Browser JS primitives — page tool and get_window_state(javascript=)
When the AX tree doesn't expose the data you need (common in
Chromium/Electron — the tree is sparse for web content), use the
page tool or the javascript param on get_window_state to query
the DOM directly via Apple Events. Requires "Allow JavaScript from
Apple Events" to be enabled — see WEB_APPS.md for the setup path.
Three actions on the page tool:
-
page({pid, window_id, action: "get_text"})— returnsdocument.body.innerText. Fastest way to read page content, prices, article text, or any raw text the AX tree truncates or omits. -
page({pid, window_id, action: "query_dom", css_selector: "a[href]", attributes: ["href"]})— runsquerySelectorAlland returns each match's tag, text, and requested attributes as a JSON array. Use for table rows, link hrefs, data attributes, structured page data. -
page({pid, window_id, action: "execute_javascript", javascript: "..."})— raw JS. Wrap in an IIFE with try-catch. Don't use this for elements already indexed byget_window_state—clickandset_valueare more reliable there.
Co-located read — get_window_state with javascript:
get_window_state({pid, window_id, javascript: "document.title"})
Runs the JS and appends the result as a ## JavaScript result section
alongside the AX snapshot — one round-trip instead of two. Use this
when you need both the element tree (for subsequent clicks) and some
page data in the same turn.
Decision rule — AX vs JS:
| Need | Use |
|---|---|
| Click / type into an element | get_window_state → click / set_value (AX, works backgrounded) |
| Read text the AX tree drops | page(get_text) or get_window_state(javascript=) |
| Scrape structured data (tables, hrefs) | page(query_dom) |
| Trigger JS events / mutations | page(execute_javascript) |
Supported backends:
| App type | How | Context |
|---|---|---|
| Chrome / Brave / Edge | Apple Events execute javascript |
Full DOM ✅ |
| Safari | Apple Events do JavaScript |
Full DOM ✅ |
| Electron (VS Code, Cursor…) | SIGUSR1 → V8 inspector → CDP | Main process only: process, Buffer — no document, no require in sandboxed apps |
Electron (with --remote-debugging-port) |
CDP page target | Full DOM ✅ |
Electron sandbox note: SIGUSR1 connects to the Node.js main process.
Sandboxed Electron apps (VS Code, Cursor) strip require and Electron
APIs there. Useful for: process.env, process.versions, process.cwd(),
process.pid. For full DOM/renderer access, launch the app with
--remote-debugging-port=9222 — cua-driver will detect and prefer the
page target automatically.
Arc returns no values; Firefox has no JS-via-AppleEvents support — see
WEB_APPS.md for the full matrix.
3. Re-snapshot and verify — mandatory
Always call get_window_state({pid, window_id}) after the action.
This isn't optional verification — it's the second half of the
snapshot invariant.
Check the AX tree diff: a changed value, a new element, a new window, or the disappearance of the thing you just clicked (menus collapse after selection, buttons may become disabled, etc.). If nothing changed, the action likely failed silently — tell the user what you attempted and what you observed, don't paper over with "done" language. Agents that skip this step report success on silently-dropped actions — the single most common failure mode.
Recording trajectories
Session-scoped action recording + replay, for demos, regressions, and
training data. Only invoke when the user explicitly asks to record a
session — the skill does not auto-enable this. CLI surface:
cua-driver recording start|stop|status; raw tool: set_recording.
See RECORDING.md for the full flow: enable/disable, turn folder
contents, replay via replay_trajectory, and the element_index
doesn't-survive-across-sessions caveat.
Common error patterns
| Error text | Meaning | Fix |
|---|---|---|
No cached AX state for pid X window_id W |
You either skipped get_window_state this turn, or passed a different window_id to the click than the one the snapshot cached against |
Call get_window_state({pid: X, window_id: W}) first — the same window_id you intend to click in |
Invalid element_index N for pid X window_id W |
Index is stale or out of range | Re-run get_window_state with the same window_id, pick a fresh index from the new tree |
window_id W belongs to pid P, not … |
Passed a window_id that's owned by a different process | Use list_windows({pid: X}) to enumerate this pid's own windows |
AX action AXPress failed with code … |
Element doesn't support AXPress | Try show_menu, confirm, cancel, or pick |
macOS system-alert beep on press_key with no visible change |
Target window is minimized; Return / Space / Tab commits don't establish real renderer focus on minimized windows | AX-click a clickable equivalent (Go button, Submit button, checkbox) instead of pressing the key; see "Keyboard commits on minimized windows" under the Browser section |
Accessibility permission not granted |
TCC not granted | Stop; tell user to grant in System Settings |
Screen Recording permission not granted |
TCC not granted for capture | Affects screenshot and get_window_state (which always captures). Grant in System Settings — the driver can't operate without it |
Things to avoid
- Never reuse an
element_indexacross a re-snapshot of the same pid. - Never translate screenshot pixels into a click — the screenshot
is for visual disambiguation, not coordinates. Use the
element_index. - Prefer AX over pixels.
click({pid, x, y})works for canvas / WebView regions, but it lands blindly and skips the agent-cursor overlay. Exhaust AX paths (menu bars, cmd-k palettes, toolbar items, keyboard shortcuts) before dropping to coordinates. - Never drive destructive actions (delete files, close unsaved documents, send messages, submit forms) without explicit user intent for that specific destructive step.
- Never launch apps autonomously; confirm with the user first unless their original request clearly implies the launch.
Example end-to-end task
User: "Open the Downloads folder in Finder."
launch_app({bundle_id: "com.apple.finder", urls: ["~/Downloads"]})→{pid: 844, windows: [{window_id: 6123, title: "Downloads", ...}]}. Idempotent launch; plus Finder opens a hidden window rooted at~/Downloadsviaapplication(_:open:)— zero activation, no focus steal. Thewindowsarray lets you skip alist_windowshop.get_window_state({pid: 844, window_id: 6123})→ verify anAXWindowwhose title contains "Downloads" is present with a populated AX subtree (sidebar, list view, files).- Done.
If the user instead asks to navigate within an already-open Finder window, use the menu-bar flow from the "Navigating native menu bars" section above (click Go → pick a menu item → re-snapshot → click it).
skills/frontend-design/SKILL.md
npx skills add melandlabs/openloomi --skill frontend-design -g -y
SKILL.md
Frontmatter
{
"name": "frontend-design",
"license": "Complete terms in LICENSE.txt",
"description": "Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML\/CSS layouts, or when styling\/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics."
}
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- Purpose: What problem does this interface solve? Who uses it?
- Tone: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- Constraints: Technical requirements (framework, performance, accessibility).
- Differentiation: What makes this UNFORGETTABLE? What's the one thing someone will remember?
CRITICAL: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
Frontend Aesthetics Guidelines
Focus on:
- Typography: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- Color & Theme: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- Motion: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- Spatial Composition: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- Backgrounds & Visual Details: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
IMPORTANT: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
skills/openloomi-api/SKILL.md
npx skills add melandlabs/openloomi --skill openloomi-api -g -y
SKILL.md
Frontmatter
{
"name": "openloomi-api",
"metadata": {
"version": "0.6.4"
},
"description": "openloomi API documentation and reference. Use when working with openloomi backend APIs, AI, authentication, characters, messages, files, integrations, billing, or any server-side functionality. Triggers: API endpoints, backend routes, authentication, cloud API, integrations"
}
Note: If you haven't downloaded or installed openloomi yet, please refer to Getting Started for installation instructions.
OpenLoomi API Documentation
API Modules
Module Overview
| Module | Base Path | Description |
|---|---|---|
| Auth | /api/auth/*, /api/remote-auth/* |
OAuth, login, register |
| User | /api/user/* |
User identity and entitlements |
| Chat | /api/chat/* (app routes) |
Chat/Character CRUD |
| Messages | /api/messages/* |
Message sending and sync |
| Files | /api/files/* |
File storage and upload |
| Storage | /api/storage/* |
Session and disk management |
| Integrations | /api/integrations/*, /api/*/callback |
Slack, Discord, X, etc. |
| RAG | /api/rag/* |
Retrieval-augmented generation |
| Workspace | /api/workspace/* |
Artifacts and skills |
| Native | /api/native/* |
Native agent operations |
| AI | /api/ai/* |
LLM, embeddings, images, audio |
| Insights | /api/insights/*, /api/chat-insights/* |
Analytics and insights |
| Billing | /api/billing/* |
Billing ledger |
Endpoints Reference
Auth Module (/api/auth/*, /api/remote-auth/*)
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/auth/poll-[provider] |
Poll OAuth status |
| POST | /api/auth/set-token |
Set auth token |
| POST | /api/auth/clear-auth-cookie |
Clear session |
| POST | /api/remote-auth/login |
Login with email/password |
| POST | /api/remote-auth/register |
Register new user |
| POST | /api/remote-auth/oauth/[provider] |
OAuth exchange |
| POST | /api/remote-auth/oauth/[provider]/exchange |
OAuth code exchange |
| POST | /api/remote-auth/refresh |
Refresh token |
| GET | /api/remote-auth/user |
Get current user |
| PUT | /api/remote-auth/user |
Update user info |
| GET | /api/remote-auth/subscription |
Get subscription info |
Login Example
curl -X POST https://app.alloomi.ai/api/remote-auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user@example.com","password":"password123"}'
Response:
{
"user": { "id": "user_xxx", "email": "user@example.com", "name": "User" },
"token": "eyJhbG..."
}
User Module (/api/user/*)
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/user/identity |
Get user identity |
| PUT | /api/user/identity |
Update identity |
| PUT | /api/user/password |
Change password |
| GET | /api/user/entitlements |
Get user entitlements |
Messages Module (/api/messages/*)
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/messages |
List messages |
| POST | /api/messages |
Send message |
| GET | /api/messages/sync |
Sync messages |
| GET | /api/messages/check |
Check message status |
| GET | /api/messages/raw |
Get raw message |
Files Module (/api/files/*)
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/files/list |
List files |
| GET | /api/files/[id] |
Get file by ID |
| POST | /api/files/upload |
Upload file |
| POST | /api/files/save |
Save file |
| GET | /api/files/usage |
Get storage usage |
| GET | /api/files/insights/download |
Download insights file |
| POST | /api/files/insights/save |
Save insights |
Storage Module (/api/storage/*)
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/storage/disk-usage |
Get disk usage |
| POST | /api/storage/cleanup |
Cleanup storage |
| GET | /api/storage/sessions |
List sessions |
| GET | /api/storage/sessions/[taskId] |
Get session by task ID |
| DELETE | /api/storage/sessions/[taskId] |
Delete session |
Integrations Module (/api/integrations/*)
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/integrations/accounts |
List connected accounts |
| GET | /api/integrations/slack/oauth/start |
Start Slack OAuth |
| GET | /api/integrations/slack/oauth/exchange |
Exchange Slack OAuth code |
| GET | /api/integrations/discord/oauth/start |
Start Discord OAuth |
| GET | /api/integrations/discord/oauth/exchange |
Exchange Discord OAuth code |
| GET | /api/integrations/x/oauth/start |
Start X OAuth |
OAuth Callbacks (Various Platforms)
| Method | Endpoint | Platform |
|---|---|---|
| GET | /api/slack/callback |
Slack |
| GET | /api/discord/callback |
Discord |
| GET | /api/auth/callback/github |
GitHub |
| GET | /api/auth/callback/google |
|
| POST | /api/feishu/listener/init |
Feishu |
| POST | /api/dingtalk/listener/init |
DingTalk |
| POST | /api/qqbot/listener/init |
QQ Bot |
| POST | /api/weixin/listener/init |
|
| POST | /api/telegram/user-listener/init |
Telegram |
| POST | /api/whatsapp/register-socket |
|
| POST | /api/imessage/init-self-listener |
iMessage |
RAG Module (/api/rag/*)
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/rag/search |
Search documents |
| GET | /api/rag/stats |
Get RAG statistics |
| GET | /api/rag/documents |
List documents |
| GET | /api/rag/documents/[documentId] |
Get document |
| GET | /api/rag/documents/[documentId]/binary |
Get document binary |
| DELETE | /api/rag/documents/[documentId] |
Delete document |
| POST | /api/rag/upload |
Upload document |
| POST | /api/rag/upload/init |
Initialize upload |
| POST | /api/rag/upload/chunk |
Upload chunk |
| POST | /api/rag/upload/complete |
Complete upload |
| POST | /api/rag/upload/async |
Async upload |
| GET | /api/rag/upload/async/status |
Check async upload status |
Workspace Module (/api/workspace/*)
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/workspace/artifacts |
List artifacts |
| GET | /api/workspace/files |
List files |
| GET | /api/workspace/file/[...path] |
Get file by path |
| GET | /api/workspace/preview |
Preview artifact |
| GET | /api/workspace/external-preview |
External preview |
| GET | /api/workspace/skills |
List skills |
| GET | /api/workspace/skills/[skillId] |
Get skill |
| POST | /api/workspace/skills |
Create skill |
| PUT | /api/workspace/skills/[skillId] |
Update skill |
| DELETE | /api/workspace/skills/[skillId] |
Delete skill |
| POST | /api/workspace/skills/toggle |
Toggle skill |
| POST | /api/workspace/skills/upload |
Upload skill |
| GET | /api/workspace/skills/metadata |
Get skill metadata |
AI Module (/api/ai/*)
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/ai/chat |
Chat completions (streaming) |
| GET | /api/ai/chat |
Check AI status |
| POST | /api/ai/v1/chat/completions |
V1 chat completions |
| POST | /api/ai/v1/embeddings |
Generate embeddings |
| POST | /api/ai/v1/images/generations |
Generate images |
| POST | /api/ai/v1/audio/speech |
Text-to-speech |
| POST | /api/ai/v1/audio/transcriptions |
Speech-to-text |
| POST | /api/ai/v1/messages/count_tokens |
Count tokens |
| POST | /api/ai/v1/upload |
Upload file for AI |
| GET | /api/ai/v1/models |
List available models |
Chat Example
curl -X POST https://app.alloomi.ai/api/ai/chat \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"messages": [
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Hello!"}
],
"stream": true
}'
Insights Module (/api/insights/*, /api/chat-insights/*)
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/chat-insights |
Get chat insights |
| GET | /api/insights/brief-categories |
List brief categories |
| POST | /api/insights/brief-categories/sync |
Sync categories |
| POST | /api/insights/brief-categories/overrides |
Override categories |
| POST | /api/insights/brief-categories/pinned |
Pin categories |
| POST | /api/insights/brief-categories/cleanup |
Cleanup categories |
| GET | /api/insight-tabs |
List insight tabs |
| POST | /api/insight-tabs |
Create insight tab |
| PUT | /api/insight-tabs/[tabId] |
Update tab |
| POST | /api/insight-tabs/reorder |
Reorder tabs |
Billing Module (/api/billing/*)
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/billing/ledger |
Get billing ledger |
Error Handling
Error Response Format
// API errors return standard HTTP status codes
{
error: string; // Error message
code?: string; // Error code for programmatic handling
cause?: string; // Additional context
}
Common Status Codes
| Code | Meaning |
|---|---|
| 200 | Success |
| 400 | Bad Request - Invalid input |
| 401 | Unauthorized - Not authenticated |
| 403 | Forbidden - Insufficient permissions |
| 404 | Not Found |
| 429 | Too Many Requests |
| 500 | Internal Server Error |
AI/Agent Usage
Local API Access
When running openloomi desktop app, the local API server runs on port 3414 (fallback: 3515):
| Environment | Base URL |
|---|---|
| User Local Desktop | http://localhost:3414 |
| User Local Desktop (fallback) | http://localhost:3515 |
Authentication Token
The auth token is stored at ~/.openloomi/token (base64 encoded JWT). You must decode it before use:
# Decode base64 to get JWT token
TOKEN=$(cat ~/.openloomi/token | base64 -d)
# Verify token contents (decodes JWT payload)
echo "$TOKEN" | cut -d'.' -f2 | base64 -d 2>/dev/null | python3 -m json.tool
curl Examples
Important: All authenticated requests require the token to be base64 decoded first.
# Helper: Get decoded token
TOKEN=$(cat ~/.openloomi/token | base64 -d)
# 1. Check AI API status (no auth required)
curl http://localhost:3414/api/ai/chat
# 2. Get current user info
TOKEN=$(cat ~/.openloomi/token | base64 -d)
curl http://localhost:3414/api/remote-auth/user \
-H "Authorization: Bearer $TOKEN"
# 3. Get subscription info
TOKEN=$(cat ~/.openloomi/token | base64 -d)
curl http://localhost:3414/api/remote-auth/subscription \
-H "Authorization: Bearer $TOKEN"
# 4. Chat with AI (streaming)
TOKEN=$(cat ~/.openloomi/token | base64 -d)
curl -X POST http://localhost:3414/api/ai/chat \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"Hello!"}],"stream":true}'
# 5. Get chat insights (requires chatId)
TOKEN=$(cat ~/.openloomi/token | base64 -d)
curl "http://localhost:3414/api/chat-insights?chatId=xxx" \
-H "Authorization: Bearer $TOKEN"
# 6. Search RAG documents
TOKEN=$(cat ~/.openloomi/token | base64 -d)
curl -X POST http://localhost:3414/api/rag/search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"search term","limit":5}'
# 7. List workspace skills
TOKEN=$(cat ~/.openloomi/token | base64 -d)
curl http://localhost:3414/api/workspace/skills \
-H "Authorization: Bearer $TOKEN"
# 8. Submit feedback
TOKEN=$(cat ~/.openloomi/token | base64 -d)
curl -X POST http://localhost:3414/api/remote-feedback \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"content":"Feedback message","email":"user@example.com"}'
Production API Access
# Using production API
export TOKEN="your_production_token"
curl https://app.alloomi.ai/api/remote-auth/user \
-H "Authorization: Bearer $TOKEN"
Summary
- 129+ API endpoints across 20+ functional modules
- Dual authentication: Session cookies (web) and Bearer tokens (Tauri)
- RESTful JSON APIs with Zod validation
- CloudApiClient for desktop/Tauri integration
- SWR utilities for client-side data fetching
- OAuth support for Google, GitHub, Slack, Discord, X
- RAG for document retrieval and search
- AI endpoints for chat, embeddings, images, audio
skills/openloomi-connectors/SKILL.md
npx skills add melandlabs/openloomi --skill openloomi-connectors -g -y
SKILL.md
Frontmatter
{
"name": "openloomi-connectors",
"metadata": {
"version": "0.6.4"
},
"description": "openloomi Connectors tools - manage platform integrations (OAuth connections, list accounts, check status). Triggers: connect platform, integration status, list accounts, disconnect",
"allowed-tools": "Bash(node $SKILL_DIR\/scripts\/openloomi-connectors.cjs *)"
}
Note: If you haven't downloaded or installed openloomi yet, please refer to Getting Started for installation instructions.
OpenLoomi Connectors Skill
OpenLoomi Connectors provides access to 26 messaging and productivity platform integrations. It allows AI agents to manage OAuth connections, list connected accounts, check connection status, and disconnect platforms on behalf of the user.
What is openloomi?
Most AI assistants function as workflow tools—users give commands, they execute tasks, with no persistent knowledge of who you are or what matters to you.
openloomi takes a fundamentally different approach: it operates as a proactive digital partner that watches, learns, remembers, and acts on your behalf. The difference is architectural.
How It Works
When users connect messaging platforms and integrations to openloomi, they sync with permission:
- Raw messages and communications
- Meetings and calendar events
- Emails and tweets
- Voice calls
- Notes and captured ideas
This aggregated data becomes "the single source of truth for openloomi's brain."
The Continuous Sync Loop
openloomi runs a background agent on a continuous sync loop, actively gathering information from all connected sources. An agent without this loop can only respond based on stale context. With it, every conversation—and every moment—makes openloomi smarter and more aligned with you.
Supported Platforms (26)
| ID | Display Name | Aliases |
|---|---|---|
telegram |
Telegram | tg |
whatsapp |
||
slack |
Slack | |
discord |
Discord | |
gmail |
Gmail | google_mail |
outlook |
Outlook | outlook_mail |
linkedin |
||
instagram |
||
twitter |
X/Twitter | x, tweet, tweets, 推特 |
google_calendar |
Google Calendar | gcal |
outlook_calendar |
Outlook Calendar | |
teams |
Microsoft Teams | microsoft_teams |
facebook_messenger |
Facebook Messenger | messenger |
google_drive |
Google Drive | gdrive |
google_docs |
Google Docs | gdocs |
hubspot |
HubSpot | |
notion |
Notion | |
github |
GitHub | gh |
asana |
Asana | |
jira |
Jira | |
linear |
Linear | |
imessage |
iMessage | |
feishu |
Lark/Feishu | lark, 飞书 |
dingtalk |
DingTalk | 钉钉 |
qqbot |
qq, qq_bot | |
weixin |
wechat, 微信, wechat_work, wecom, 企业微信 |
Authentication
The CLI auto-reads your token from ~/.openloomi/token (base64 encoded JWT).
Local API Access
The local API server runs on port 3414 (fallback: 3515). If 3414 is unavailable, try 3515.
API Endpoints
Integration Accounts
GET /api/integrations/accounts - List Connected Accounts
Returns all connected platform accounts for the authenticated user.
curl http://localhost:3414/api/integrations/accounts \
-H "Authorization: Bearer $TOKEN"
Response:
{
"accounts": [
{
"id": "int_xxx",
"platform": "gmail",
"externalId": "user@gmail.com",
"displayName": "My Gmail",
"status": "active",
"metadata": {},
"createdAt": "2024-01-01T00:00:00Z",
"botId": "bot_xxx"
}
]
}
Note: Each account includes a botId field which is used for send-reply and other bot operations.
OAuth Start Endpoints
GET /api/integrations/slack/oauth/start?userId=<userId> - Start Slack OAuth
Returns the Slack OAuth authorization URL. The CLI opens this URL in the browser for the user to complete authorization.
curl "http://localhost:3414/api/integrations/slack/oauth/start?userId=<userId>"
Response:
{
"authorizationUrl": "https://slack.com/oauth/v2/authorize?...",
"state": "userId:uuid"
}
GET /api/integrations/discord/oauth/start?userId=<userId> - Start Discord OAuth
Returns the Discord OAuth authorization URL.
GET /api/integrations/x/oauth/start?userId=<userId> - Start X OAuth
Returns the X/Twitter OAuth authorization URL.
OAuth Exchange Endpoints
GET /api/integrations/slack/oauth/exchange?code=<code>&state=<state> - Exchange Slack Code
Exchange OAuth code for Slack access.
GET /api/integrations/discord/oauth/exchange?code=<code>&state=<state> - Exchange Discord Code
Exchange OAuth code for Discord access.
OAuth Callbacks
| Platform | Endpoint |
|---|---|
| GitHub | GET /api/auth/callback/github |
GET /api/auth/callback/google |
|
| Feishu | POST /api/feishu/listener/init |
| DingTalk | POST /api/dingtalk/listener/init |
| QQ Bot | POST /api/qqbot/listener/init |
POST /api/weixin/listener/init |
|
| Telegram | POST /api/telegram/user-listener/init |
POST /api/whatsapp/register-socket |
|
| iMessage | POST /api/imessage/init-self-listener |
DELETE /api/integrations/:id - Disconnect Account
Delete a connected integration account.
curl -X DELETE http://localhost:3414/api/integrations/int_xxx \
-H "Authorization: Bearer $TOKEN"
Response:
{
"success": true,
"deletedAccountId": "int_xxx",
"deletedBotIds": ["bot_xxx"]
}
GET /api/contacts - Query Contacts
Query user contacts with optional filtering and pagination.
curl "http://localhost:3414/api/contacts?name=John&page=1&pageSize=10" \
-H "Authorization: Bearer $TOKEN"
Parameters:
name(string, optional) - Filter contacts by name (partial match)page(number, default 1) - Page numberpageSize(number, default 10) - Items per page (max 100)
Response:
{
"success": true,
"contacts": [
{
"id": "contact_xxx",
"name": "John Doe",
"type": "email",
"botId": "bot_xxx",
"platform": "gmail"
}
],
"pagination": {
"page": 1,
"pageSize": 10,
"totalCount": 50,
"totalPages": 5,
"hasMore": true,
"hasPrevious": false
}
}
POST /api/messages - Send Message
Send a message via a connected platform bot.
curl -X POST http://localhost:3414/api/messages \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"botId": "bot_xxx",
"recipients": ["John"],
"message": "Hello!",
"subject": "Optional subject"
}'
Parameters:
botId(string, required) - The bot ID to send fromrecipients(array, required) - List of recipient namesmessage(string, required) - Message contentsubject(string, optional) - Email subject linecc(array, optional) - CC recipientsbcc(array, optional) - BCC recipients
Note: botId is returned by list-accounts in the botId field (different from account id).
Platform Aliases Reference
Aliases are case-insensitive and support both English and Chinese:
| Alias | Platform |
|---|---|
tg |
telegram |
gh |
github |
gc |
gmail |
x |
|
tweet, tweets, 推特 |
|
gcal |
google_calendar |
gdrive |
google_drive |
gdocs |
google_docs |
wechat, 微信 |
weixin |
lark, 飞书 |
feishu |
钉钉 |
dingtalk |
qq, qq_bot |
qqbot |
Desktop UI
Users can also authorize accounts directly through the openloomi desktop application without using CLI commands.
Adding Account Authorization via Desktop UI
-
Open openloomi desktop app on your computer
-
Navigate to Settings (gear icon in the sidebar or top-right menu)
-
Go to Integrations tab/section
-
Click on the platform you want to connect (e.g., Telegram, Slack, Discord, Gmail, etc.)
-
Follow the platform-specific authorization flow:
- OAuth platforms (Slack, Discord, X/Twitter): Click "Connect" → you'll be redirected to the platform's authorization page in your browser → Approve the permissions → you'll be redirected back
- App Password platforms (Gmail, Outlook): Enter your email and app password
- App Credentials platforms (DingTalk, Feishu, QQ): Enter your appId and appSecret
- QR/Interactive platforms (WhatsApp, Telegram, iMessage): Scan the QR code or follow the in-app instructions
-
Verify connection — once authorized, the platform will show as "Connected" with a green checkmark
Managing Connected Accounts
- List connected accounts: Settings → Integrations → shows all connected platforms with status
- Disconnect account: Settings → Integrations → click on connected platform → "Disconnect" or remove
- Check status: Connected platforms show green "Active" badge; expired/disconnected shows red "Inactive" badge
CLI Script
Quick Start
# List all supported platforms
node $SKILL_DIR/scripts/openloomi-connectors.cjs list-platforms
# List all connected accounts (includes botId for send-reply)
node $SKILL_DIR/scripts/openloomi-connectors.cjs list-accounts
# Check connection status for a platform
node $SKILL_DIR/scripts/openloomi-connectors.cjs status telegram
# Connect a platform (opens browser for OAuth)
node $SKILL_DIR/scripts/openloomi-connectors.cjs connect slack
# Disconnect an account by ID
node $SKILL_DIR/scripts/openloomi-connectors.cjs disconnect int_xxx
# Query contacts
node $SKILL_DIR/scripts/openloomi-connectors.cjs query-contacts --name=John --page=1 --pageSize=10
# Send a message (requires botId from list-accounts)
node $SKILL_DIR/scripts/openloomi-connectors.cjs send-reply --botId=bot_xxx --recipients=John --message="Hello!"
Commands
| Command | Description |
|---|---|
list-platforms |
List all 26 supported platforms with IDs and aliases |
list-accounts |
List all connected integration accounts (includes botId) |
status <platform> |
Check if a platform is connected (e.g., telegram, slack) |
connect <platform> [options] |
Connect a platform (OAuth, App Password, or App Credentials) |
disconnect <accountId> |
Disconnect a specific account by ID |
query-contacts [options] |
Query contacts (--name=, --page=, --pageSize=) |
send-reply --botId= --recipients= --message= |
Send a message via REST API |
Platform Connection Methods
| Method | Platforms |
|---|---|
| OAuth (auto-opens browser) | slack, discord, x |
| App Password | gmail --email=x --password=xxxx, outlook --email=x --password=xxxx |
| App Credentials | dingtalk --clientId=x --clientSecret=x, feishu --appId=x --appSecret=x, qq --appId=x --appSecret=x |
| iLink Token | wechat --token=x |
| Browser Required (QR/interactive) | whatsapp, telegram, imessage |
AI Agent Workflow
Triggered when the user asks about:
- Connecting a platform - "connect telegram", "link my slack"
- Listing integrations - "show my connected accounts", "what platforms am I connected to"
- Checking status - "is my github connected?", "telegram status"
- Disconnecting - "disconnect my discord", "remove whatsapp"
- Querying contacts - "show my contacts", "find John in contacts"
- Sending messages - "send email to John", "reply to that message"
Execution Flow:
- Identify intent - connect / list / status / disconnect / query-contacts / send-reply
- Resolve platform - use alias normalization (e.g.,
gh->github) - Execute command - use Bash tool
- Format output - report results naturally in user's language
Note on send-reply: The botId is returned by list-accounts in the botId field.
skills/openloomi-memory/SKILL.md
npx skills add melandlabs/openloomi --skill openloomi-memory -g -y
SKILL.md
Frontmatter
{
"name": "openloomi-memory",
"metadata": {
"version": "0.5.1"
},
"description": "openloomi Memory tools - search memory files, knowledge base, and chat insights. Triggers: memory search, knowledge base, documents, insights",
"allowed-tools": "Bash(node $SKILL_DIR\/scripts\/openloomi-memory.cjs *)"
}
Note: If you haven't downloaded or installed openloomi yet, please refer to Getting Started for installation instructions.
OpenLoomi Memory Skill
OpenLoomi Memory is a personal knowledge management tool that searches and manages three types of information:
| Type | Description | Data Location |
|---|---|---|
| Memory Files | Personal memory files (markdown/json) | ~/.openloomi/data/memory/ |
| Knowledge Base | Uploaded documents via RAG/embeddings | openloomi server |
| Insights | Structured info extracted from chat history, with usage tracking and automatic maintenance | openloomi server |
Overview
openloomi Memory is built on a unique architectural principle: instead of treating memory as an afterthought, it's the foundation.
How it works with Connectors: Before memory can search your data, you need to connect your platforms using the openloomi-connectors skill. Connectors handles OAuth authentication and integration setup for 26 platforms (Telegram, WhatsApp, Slack, Discord, Gmail, Outlook, Twitter/X, WeChat, and more). Once connected, openloomi continuously syncs everything with your permission—raw messages, meetings, emails, tweets, calendar events, voice calls, and any notes or ideas you've captured. This aggregated data becomes the single source of truth that powers openloomi's brain.
The memory is layered:
- Raw information: Original messages, files, transcripts
- Information insights: Extracted entities, decisions, key events
- Contextual memory: Recent conversation state
- Knowledge-base memory: Long-term people/projects/preferences knowledge graph
This enables reasoning across both immediate context and deep historical knowledge simultaneously. When you create custom agent roles to handle tasks, this memory acts as the orchestrator—dramatically improving execution quality.
Inside openloomi, memory is layered into multiple levels:
- Raw information: Original messages, files, transcripts
- Information insights: Extracted entities, decisions, key events
- Contextual memory: Recent conversation state
- Knowledge-base memory: Long-term people/projects/preferences knowledge graph
This enables reasoning across both immediate context and deep historical knowledge simultaneously.
Insights include automatic usage tracking—recording view frequency, sources, and calculating value scores to surface the most relevant information. A periodic maintenance system (daily analytics refresh, weekly compaction) keeps insight retrieval accurate and prevents context decay by archiving or removing stale, low-value content.
Authentication
The CLI auto-reads your token from ~/.openloomi/token (base64 encoded JWT).
Local Memory Filesystem
Overview
Memory files are stored locally at ~/.openloomi/data/memory/ and searched via direct filesystem access. This is a read-only operation that performs case-insensitive text search across .md and .json files.
Directory Structure
~/.openloomi/data/memory/
├── chats/ # Chat conversation exports
├── channels/ # Channel memory exports e.g., weixin, telegram, etc.
├── people/ # Person profiles
├── projects/ # Project notes
├── notes/ # General notes
└── strategy/ # Strategy documents
Write Operations
Memory files are plain markdown or JSON stored locally. You can add or delete files directly.
Adding a memory file:
node $SKILL_DIR/scripts/openloomi-memory.cjs add-memory "Content to remember" --file=filename.md --directory=notes
--file(optional): Filename. If not provided, auto-generated from first line of content.--directory(optional): Subdirectory under~/.openloomi/data/memory/. Created if doesn't exist.
Deleting a memory file:
node $SKILL_DIR/scripts/openloomi-memory.cjs delete-memory filename.md --directory=notes
How search-memory Works
- Path:
~/.openloomi/data/memory/(or subdirectory if specified) - Search Type: Case-insensitive full-text search
- Files: Scans
.mdand.jsonfiles recursively (max depth 5) - Matching: Each line is searched; returns first match per file
- Output: File path, line number, and line preview (first 200 chars)
Example Output
{
"results": [
{
"file": "people/boss.md",
"line": 42,
"preview": "My boss John mentioned the deadline is next Friday"
},
{
"file": "projects/app/notes.md",
"line": 10,
"preview": "Boss wants the app launched by end of month"
}
],
"total": 2
}
API Endpoints
Knowledge Base (RAG)
POST /api/rag/search - Search Documents
Semantic search of uploaded documents using embeddings.
curl -X POST http://localhost:3414/api/rag/search \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"query": "project plan", "limit": 5}'
Parameters:
query(string, required) - Search querylimit(number, default 5) - Max results to return
Response:
{
"results": [
{
"id": "doc_xxx",
"title": "Project Document",
"content": "...",
"score": 0.95
}
]
}
GET /api/rag/documents - List Documents
List all documents in the knowledge base.
curl http://localhost:3414/api/rag/documents?limit=50 \
-H "Authorization: Bearer $TOKEN"
Parameters:
limit(number, default 50) - Max results to return
Response:
{
"documents": [
{
"id": "doc_xxx",
"name": "document.pdf",
"type": "pdf",
"size": 102400,
"createdAt": "2024-01-01T00:00:00Z"
}
],
"total": 10
}
GET /api/rag/documents/[id] - Get Document
Get a single document by ID.
curl http://localhost:3414/api/rag/documents/doc_xxx \
-H "Authorization: Bearer $TOKEN"
Response:
{
"id": "doc_xxx",
"name": "Project Document.pdf",
"type": "pdf",
"size": 102400,
"content": "Document text content...",
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-01T00:00:00Z"
}
Insights
Insights are structured information extracted from chat history, such as key decisions, action items, and relationship notes. Each insight belongs to one or more groups (channels/platforms) like gmail, telegram, whatsapp, slack, discord, linkedin, twitter, etc.
GET /api/insights - List Insights
List all insights from a time period.
curl "http://localhost:3414/api/insights?days=7&limit=50" \
-H "Authorization: Bearer $TOKEN"
Parameters:
days(number, default 7) - Look back period in dayslimit(number, default 50) - Max results to return
Insight Structure:
Each insight contains a groups field—an array of channel identifiers indicating which platform(s) the insight came from:
{
"id": "insight_xxx",
"chatId": "chat_xxx",
"type": "decision",
"content": "John sent an email about the project deadline",
"groups": ["gmail"],
"people": ["John"],
"time": "2024-01-01T00:00:00Z",
"createdAt": "2024-01-01T00:00:00Z"
}
Insight Types:
| Type | Description |
|---|---|
decision |
Key decisions made |
action_item |
Tasks or follow-ups |
note |
General notes |
preference |
User preferences |
relationship |
Notes about people |
event |
Important events |
Common Channel Groups:
| Channel | Group Value | Description |
|---|---|---|
| Gmail | "gmail" |
Google Mail messages |
| Outlook | "outlook" |
Microsoft Outlook emails |
| Telegram | "telegram" |
Telegram chats |
"whatsapp" |
WhatsApp messages | |
| Slack | "slack" |
Slack messages |
| Discord | "discord" |
Discord messages |
"linkedin" |
LinkedIn messages | |
| Twitter/X | "twitter" |
Twitter posts |
"weixin" |
WeChat messages | |
| RSS | "rss" |
RSS feed items |
POST /api/insights - Create Insight
Create a new insight manually.
curl -X POST http://localhost:3414/api/insights \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"type": "preference", "content": "I prefer Americano coffee", "groups": ["whatsapp"]}'
Parameters:
type(string, required) - Insight type (decision, action_item, note, preference, relationship, event)content(string, required) - The insight textgroups(array, optional) - Channel groups to associate withpeople(array, optional) - People mentioned in the insight
Response:
{
"id": "insight_xxx",
"type": "preference",
"content": "I prefer Americano coffee",
"groups": ["whatsapp"],
"createdAt": "2024-01-01T00:00:00Z"
}
PUT /api/insights/[id] - Update Insight
Partial update an existing insight. Arrays (details, timeline, insights) are appended to, not replaced.
curl -X PUT http://localhost:3414/api/insights/insight_xxx \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"updates": {
"description": "Updated description",
"details": [{"content": "User mentioned new preference", "person": "User"}],
"timeline": [{"summary": "Progress update", "label": "Update"}]
}
}'
Update Fields:
title- New titledescription- New descriptionimportance- Important, General, Not Importanturgency- As soon as possible, Within 24 hours, Not urgent, Generaldetails- Array of detail objects (appended to existing)timeline- Array of timeline events (appended to existing)myTasks- Array of task objectsgroups- Array of group tags (replaced)categories- Array of categories (replaced)people- Array of people names (replaced)
Response:
{
"message": "Insight updated successfully",
"id": "insight_xxx"
}
GET /api/insights/[id]?fetch=true - Get Insight
Get a single insight by ID, including associated chat.
curl "http://localhost:3414/api/insights/insight_xxx?fetch=true" \
-H "Authorization: Bearer $TOKEN"
Response:
{
"id": "insight_xxx",
"chatId": "chat_xxx",
"type": "decision",
"content": "User decided to start new project next month",
"chat": {
"id": "chat_xxx",
"title": "Chat with John",
"messages": [...]
},
"createdAt": "2024-01-01T00:00:00Z"
}
DELETE /api/insights/[id] - Delete Insight
Delete a specific insight.
curl -X DELETE http://localhost:3414/api/insights/insight_xxx \
-H "Authorization: Bearer $TOKEN"
Response:
{
"success": true
}
GET /api/chat-insights?chatId=xxx - Get Chat Insights
Get all insights for a specific chat.
curl "http://localhost:3414/api/chat-insights?chatId=chat_xxx" \
-H "Authorization: Bearer $TOKEN"
Insight Usage Analytics & Maintenance
openloomi tracks insight usage and performs periodic maintenance to preserve retrieval quality, avoiding context decay.
Usage Tracking
Each insight view is recorded with:
- Access timestamp
- Access source (
list,detail,search,favorite) - Cumulative access counts (7-day / 30-day / total)
Data is stored in the insightWeights table:
accessCountTotal- Total access countaccessCount7d- Access count in last 7 daysaccessCount30d- Access count in last 30 dayslastAccessedAt- Last access timestamp
Analysis Dimensions
Each insight is scored on trend and value:
| Metric | Weight | Description |
|---|---|---|
| Frequency | 45% | Based on 7-day / 30-day access frequency |
| Freshness | 25% | Last access time |
| Relevance | 20% | Importance (70%) + Urgency (30%) |
| Favorites | 10% | Whether the insight is favorited |
Trend Indicators:
rising- Access frequency increasingfalling- Access frequency decreasingstable- Frequency stable
Periodic Maintenance
System automatically runs two maintenance tasks:
| Task | Frequency | Purpose |
|---|---|---|
| Daily analytics refresh | 24 hours | Refresh access stats, recalculate trends and scores |
| Weekly compaction | 7 days | Merge similar insights, prune low-value content |
Retention Policy:
delete: 90 days no access + low score + not high importance → soft delete, hard delete after 180 daysarchive: 30 days no access + low score OR falling trend + low score → archived
GET /api/insights/analytics - Get Insight Usage Analytics
curl "http://localhost:3414/api/insights/analytics" \
-H "Authorization: Bearer $TOKEN"
Response:
{
"generatedAt": "2024-01-15T10:30:00Z",
"summary": {
"totalInsights": 150,
"activeInsights": 45,
"dormantInsights": 105,
"totalAccesses30d": 230,
"averageValueScore": 42,
"risingInsights": 12,
"fallingInsights": 8,
"stableInsights": 25
},
"topInsights": [...],
"bottomInsights": [...],
"relationships": [...],
"insights": [
{
"id": "insight_xxx",
"title": "User decided to start new project",
"description": "",
"taskLabel": "",
"platform": "gmail",
"account": "user@gmail.com",
"importance": "general",
"urgency": "not_urgent",
"isFavorited": false,
"isArchived": false,
"createdAt": "2024-01-01T00:00:00Z",
"updatedAt": "2024-01-10T00:00:00Z",
"time": "2024-01-01T00:00:00Z",
"accessCountTotal": 15,
"accessCount7d": 3,
"accessCount30d": 8,
"lastAccessedAt": "2024-01-15T10:30:00Z",
"trend": "rising",
"recent7dAccessCount": 3,
"previous7dAccessCount": 1,
"valueScore": 58,
"recommendation": {
"action": "keep",
"reason": "Usage, freshness, or relevance still supports keeping it active."
}
}
]
}
POST /api/insights/[id]/view - Record Insight View
curl -X POST "http://localhost:3414/api/insights/insight_xxx/view" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"viewSource": "search"}'
Parameters:
viewSource(string) - Source type:list,detail,search,favorite
Response:
{
"ok": true
}
CLI Script
Quick Start
# Search ALL memory sources at once (recommended for comprehensive search)
node $SKILL_DIR/scripts/openloomi-memory.cjs search-all "query"
# Search local memory files (full-text, case-insensitive)
node $SKILL_DIR/scripts/openloomi-memory.cjs search-memory "boss"
# Search local memory files in specific subdirectory
node $SKILL_DIR/scripts/openloomi-memory.cjs search-memory "project" --directory=projects
# Search knowledge base (RAG, semantic search)
node $SKILL_DIR/scripts/openloomi-memory.cjs search-knowledge "project plan"
# List knowledge base documents
node $SKILL_DIR/scripts/openloomi-memory.cjs list-documents
# Get document content
node $SKILL_DIR/scripts/openloomi-memory.cjs get-document doc_xxx
# List recent insights (last 7 days)
node $SKILL_DIR/scripts/openloomi-memory.cjs list-insights --days=7
# List insights from a specific channel (e.g., Gmail, Telegram, WhatsApp)
node $SKILL_DIR/scripts/openloomi-memory.cjs list-insights --channel=gmail --days=7
node $SKILL_DIR/scripts/openloomi-memory.cjs list-insights --channel=telegram --days=30
node $SKILL_DIR/scripts/openloomi-memory.cjs list-insights --channel=whatsapp
# Filter insights by keyword (supports multiple keywords - OR logic)
node $SKILL_DIR/scripts/openloomi-memory.cjs list-insights --keyword=screen --keyword=linkedin --days=30
# Get single insight
node $SKILL_DIR/scripts/openloomi-memory.cjs get-insight insight_xxx
# Create a new insight
node $SKILL_DIR/scripts/openloomi-memory.cjs add-insight --title="Coffee preference" --description="I prefer Americano coffee" --importance=General
# Update an insight (partial update with array append)
node $SKILL_DIR/scripts/openloomi-memory.cjs update-insight insight_xxx --description="Updated description" --detail="User mentioned new preference"
# Delete insight
node $SKILL_DIR/scripts/openloomi-memory.cjs delete-insight insight_xxx
# Add a memory file
node $SKILL_DIR/scripts/openloomi-memory.cjs add-memory "My boss John likes Monday project discussions" --file=people/boss.md
# Delete a memory file
node $SKILL_DIR/scripts/openloomi-memory.cjs delete-memory people/boss.md
Command Reference
| Command | Description | Target |
|---|---|---|
search-all |
Search all memory sources simultaneously | Local files + Knowledge base + Insights |
search-memory |
Full-text search in local .md/.json files |
~/.openloomi/data/memory/ |
search-knowledge |
Semantic search via embeddings | openloomi server (RAG) |
list-documents |
List uploaded documents | Knowledge base |
get-document |
Get document content by ID | Knowledge base |
list-insights |
List extracted insights (supports --channel filter) |
Insights API |
get-insight |
Get single insight by ID | Insights API |
delete-insight |
Delete an insight | Insights API |
add-insight |
Create a new insight (title, description, importance, urgency, groups, people) | Insights API |
update-insight |
Update an insight (partial update with array append logic) | Insights API |
add-memory |
Add a memory file (auto-generates filename from content) | Local filesystem |
delete-memory |
Delete a memory file | Local filesystem |
AI Agent Workflow
Triggered when the user asks about memory, knowledge, or past information:
- Memory file search - "search my memory", "find what I said about..."
- Knowledge base search - "search uploaded documents", "find in knowledge base"
- Insights management - "list insights", "delete an insight"
- Channel insights - "what messages on Gmail?", "show me Telegram chats", "any WhatsApp messages?"
- Comprehensive search - "search everything", "find in all my memory", "build relationship graph"
Execution Flow:
- Identify intent - determine if user wants comprehensive search or specific source
- Prefer
search-all- for general memory queries, always usesearch-allfirst to get comprehensive results across all sources - Execute in parallel - when specific sources are needed, run multiple searches simultaneously:
search-memoryfor local filessearch-knowledgefor uploaded documentslist-insightsfor extracted insights
- For channel queries - use
list-insightswith--channelparameter:"gmail"- Email messages via Gmail"outlook"- Email messages via Outlook"telegram"- Telegram chats"whatsapp"- WhatsApp messages"slack"- Slack messages"discord"- Discord messages"linkedin"- LinkedIn messages"twitter"- Twitter/X posts"weixin"- WeChat messages"rss"- RSS feed items
- Format output - aggregate and present results in user's language
Best Practice for Comprehensive Queries:
# When user asks about relationships, people, or general memory:
node $SKILL_DIR/scripts/openloomi-memory.cjs search-all "person/project/topic"
# Then optionally get details from specific sources
node $SKILL_DIR/scripts/openloomi-memory.cjs search-memory "person" --directory=people
node $SKILL_DIR/scripts/openloomi-memory.cjs list-insights --days=30 --keyword=<keyword>
Channel-Based Message Queries:
# User asks "what emails did I receive?" or "show me Gmail messages"
node $SKILL_DIR/scripts/openloomi-memory.cjs list-insights --channel=gmail --days=7
# User asks "any Telegram messages about project X"?
node $SKILL_DIR/scripts/openloomi-memory.cjs list-insights --channel=telegram --days=30
# User asks "recent WhatsApp messages"?
node $SKILL_DIR/scripts/openloomi-memory.cjs list-insights --channel=whatsapp
Living Connections (Hebbian Potentiation)
Living Connections track relationships between insights that strengthen when they're accessed together. This implements Hebbian learning: "insights that fire together, wire together."
Commands
# Get related insights - "users who viewed X also viewed Y"
node $SKILL_DIR/scripts/openloomi-memory.cjs get-related-insights <insightId>
# Get related insights with filters
node $SKILL_DIR/scripts/openloomi-memory.cjs get-related-insights insight_xxx --limit=10 --minStrength=0.3
# Get connection statistics
node $SKILL_DIR/scripts/openloomi-memory.cjs get-connection-stats <insightId>
How It Works
- When you view an insight, connections to other insights viewed within 5 minutes are strengthened
- Connection strength decays over time using Ebbinghaus-style forgetting curve
- Strong connections (strength > 0.5) are considered "living" - actively referenced
Response Format
{
"insightId": "insight_xxx",
"connections": [...],
"relatedInsights": [
{
"insightId": "insight_yyy",
"strength": 0.72,
"coAccessCount": 5
}
],
"total": 5
}
Temporal Queries (Time-Travel)
Temporal validity enables "time-travel" queries - seeing what insights were relevant at a specific point in time.
Commands
# Get insights valid at a specific point in time (time-travel query)
node $SKILL_DIR/scripts/openloomi-memory.cjs get-insights-as-of 2026-01-01
# Get currently valid insights (no expiration or future expiration)
node $SKILL_DIR/scripts/openloomi-memory.cjs get-current-insights
# Get insights overlapping a time interval
node $SKILL_DIR/scripts/openloomi-memory.cjs get-insights-in-interval 2026-01-01 2026-06-01
Use Cases
- "What did I know about Project X on March 1st?"
- "What insights were valid during my vacation last July?"
- "Show me only currently relevant insights (hide expired ones)"
Entity Registry
Entity Registry tracks people, groups, concepts, projects, and companies as first-class entities with disambiguation support.
Commands
# List all entities of a specific type
node $SKILL_DIR/scripts/openloomi-memory.cjs list-entities --type=person
# Search entities by name
node $SKILL_DIR/scripts/openloomi-memory.cjs list-entities --search=John
# Get entity details with linked insights
node $SKILL_DIR/scripts/openloomi-memory.cjs get-entity <entityId> --insights
Entity Types
| Type | Description |
|---|---|
person |
People (contacts, colleagues, friends) |
group |
Groups (teams, organizations) |
concept |
Abstract concepts (ideas, methodologies) |
project |
Projects (initiatives, deliverables) |
company |
Companies (clients, vendors, employers) |
Search with Connections
Combined search that returns matching insights along with their Living Connections, providing a richer context.
# Search insights and include related insights
node $SKILL_DIR/scripts/openloomi-memory.cjs search-with-connections "project deadline"
# With custom limit
node $SKILL_DIR/scripts/openloomi-memory.cjs search-with-connections "project deadline" --limit=5
skills/pdf/SKILL.md
npx skills add melandlabs/openloomi --skill pdf -g -y
SKILL.md
Frontmatter
{
"name": "pdf",
"license": "Proprietary. LICENSE.txt has complete terms",
"description": "Use this skill whenever the user wants to do anything with PDF files. This includes reading or extracting text\/tables from PDFs, combining or merging multiple PDFs into one, splitting PDFs apart, rotating pages, adding watermarks, creating new PDFs, filling PDF forms, encrypting\/decrypting PDFs, extracting images, and OCR on scanned PDFs to make them searchable. If the user mentions a .pdf file or asks to produce one, use this skill."
}
PDF Processing Guide
Overview
This guide covers essential PDF processing operations using Python libraries and command-line tools. For advanced features, JavaScript libraries, and detailed examples, see REFERENCE.md. If you need to fill out a PDF form, read FORMS.md and follow its instructions.
Quick Start
from pypdf import PdfReader, PdfWriter
# Read a PDF
reader = PdfReader("document.pdf")
print(f"Pages: {len(reader.pages)}")
# Extract text
text = ""
for page in reader.pages:
text += page.extract_text()
Python Libraries
pypdf - Basic Operations
Merge PDFs
from pypdf import PdfWriter, PdfReader
writer = PdfWriter()
for pdf_file in ["doc1.pdf", "doc2.pdf", "doc3.pdf"]:
reader = PdfReader(pdf_file)
for page in reader.pages:
writer.add_page(page)
with open("merged.pdf", "wb") as output:
writer.write(output)
Split PDF
reader = PdfReader("input.pdf")
for i, page in enumerate(reader.pages):
writer = PdfWriter()
writer.add_page(page)
with open(f"page_{i+1}.pdf", "wb") as output:
writer.write(output)
Extract Metadata
reader = PdfReader("document.pdf")
meta = reader.metadata
print(f"Title: {meta.title}")
print(f"Author: {meta.author}")
print(f"Subject: {meta.subject}")
print(f"Creator: {meta.creator}")
Rotate Pages
reader = PdfReader("input.pdf")
writer = PdfWriter()
page = reader.pages[0]
page.rotate(90) # Rotate 90 degrees clockwise
writer.add_page(page)
with open("rotated.pdf", "wb") as output:
writer.write(output)
pdfplumber - Text and Table Extraction
Extract Text with Layout
import pdfplumber
with pdfplumber.open("document.pdf") as pdf:
for page in pdf.pages:
text = page.extract_text()
print(text)
Extract Tables
with pdfplumber.open("document.pdf") as pdf:
for i, page in enumerate(pdf.pages):
tables = page.extract_tables()
for j, table in enumerate(tables):
print(f"Table {j+1} on page {i+1}:")
for row in table:
print(row)
Advanced Table Extraction
import pandas as pd
with pdfplumber.open("document.pdf") as pdf:
all_tables = []
for page in pdf.pages:
tables = page.extract_tables()
for table in tables:
if table: # Check if table is not empty
df = pd.DataFrame(table[1:], columns=table[0])
all_tables.append(df)
# Combine all tables
if all_tables:
combined_df = pd.concat(all_tables, ignore_index=True)
combined_df.to_excel("extracted_tables.xlsx", index=False)
reportlab - Create PDFs
Basic PDF Creation
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
c = canvas.Canvas("hello.pdf", pagesize=letter)
width, height = letter
# Add text
c.drawString(100, height - 100, "Hello World!")
c.drawString(100, height - 120, "This is a PDF created with reportlab")
# Add a line
c.line(100, height - 140, 400, height - 140)
# Save
c.save()
Create PDF with Multiple Pages
from reportlab.lib.pagesizes import letter
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
from reportlab.lib.styles import getSampleStyleSheet
doc = SimpleDocTemplate("report.pdf", pagesize=letter)
styles = getSampleStyleSheet()
story = []
# Add content
title = Paragraph("Report Title", styles['Title'])
story.append(title)
story.append(Spacer(1, 12))
body = Paragraph("This is the body of the report. " * 20, styles['Normal'])
story.append(body)
story.append(PageBreak())
# Page 2
story.append(Paragraph("Page 2", styles['Heading1']))
story.append(Paragraph("Content for page 2", styles['Normal']))
# Build PDF
doc.build(story)
Subscripts and Superscripts
IMPORTANT: Never use Unicode subscript/superscript characters (₀₁₂₃₄₅₆₇₈₉, ⁰¹²³⁴⁵⁶⁷⁸⁹) in ReportLab PDFs. The built-in fonts do not include these glyphs, causing them to render as solid black boxes.
Instead, use ReportLab's XML markup tags in Paragraph objects:
from reportlab.platypus import Paragraph
from reportlab.lib.styles import getSampleStyleSheet
styles = getSampleStyleSheet()
# Subscripts: use <sub> tag
chemical = Paragraph("H<sub>2</sub>O", styles['Normal'])
# Superscripts: use <super> tag
squared = Paragraph("x<super>2</super> + y<super>2</super>", styles['Normal'])
For canvas-drawn text (not Paragraph objects), manually adjust font the size and position rather than using Unicode subscripts/superscripts.
Command-Line Tools
pdftotext (poppler-utils)
# Extract text
pdftotext input.pdf output.txt
# Extract text preserving layout
pdftotext -layout input.pdf output.txt
# Extract specific pages
pdftotext -f 1 -l 5 input.pdf output.txt # Pages 1-5
qpdf
# Merge PDFs
qpdf --empty --pages file1.pdf file2.pdf -- merged.pdf
# Split pages
qpdf input.pdf --pages . 1-5 -- pages1-5.pdf
qpdf input.pdf --pages . 6-10 -- pages6-10.pdf
# Rotate pages
qpdf input.pdf output.pdf --rotate=+90:1 # Rotate page 1 by 90 degrees
# Remove password
qpdf --password=mypassword --decrypt encrypted.pdf decrypted.pdf
pdftk (if available)
# Merge
pdftk file1.pdf file2.pdf cat output merged.pdf
# Split
pdftk input.pdf burst
# Rotate
pdftk input.pdf rotate 1east output rotated.pdf
Common Tasks
Extract Text from Scanned PDFs
# Requires: pip install pytesseract pdf2image
import pytesseract
from pdf2image import convert_from_path
# Convert PDF to images
images = convert_from_path('scanned.pdf')
# OCR each page
text = ""
for i, image in enumerate(images):
text += f"Page {i+1}:\n"
text += pytesseract.image_to_string(image)
text += "\n\n"
print(text)
Add Watermark
from pypdf import PdfReader, PdfWriter
# Create watermark (or load existing)
watermark = PdfReader("watermark.pdf").pages[0]
# Apply to all pages
reader = PdfReader("document.pdf")
writer = PdfWriter()
for page in reader.pages:
page.merge_page(watermark)
writer.add_page(page)
with open("watermarked.pdf", "wb") as output:
writer.write(output)
Extract Images
# Using pdfimages (poppler-utils)
pdfimages -j input.pdf output_prefix
# This extracts all images as output_prefix-000.jpg, output_prefix-001.jpg, etc.
Password Protection
from pypdf import PdfReader, PdfWriter
reader = PdfReader("input.pdf")
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
# Add password
writer.encrypt("userpassword", "ownerpassword")
with open("encrypted.pdf", "wb") as output:
writer.write(output)
Quick Reference
| Task | Best Tool | Command/Code |
|---|---|---|
| Merge PDFs | pypdf | writer.add_page(page) |
| Split PDFs | pypdf | One page per file |
| Extract text | pdfplumber | page.extract_text() |
| Extract tables | pdfplumber | page.extract_tables() |
| Create PDFs | reportlab | Canvas or Platypus |
| Command line merge | qpdf | qpdf --empty --pages ... |
| OCR scanned PDFs | pytesseract | Convert to image first |
| Fill PDF forms | pdf-lib or pypdf (see FORMS.md) | See FORMS.md |
Next Steps
- For advanced pypdfium2 usage, see REFERENCE.md
- For JavaScript libraries (pdf-lib), see REFERENCE.md
- If you need to fill out a PDF form, follow the instructions in FORMS.md
- For troubleshooting guides, see REFERENCE.md
skills/skill-creator/SKILL.md
npx skills add melandlabs/openloomi --skill skill-creator -g -y
SKILL.md
Frontmatter
{
"name": "skill-creator",
"license": "Complete terms in LICENSE.txt",
"description": "Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations."
}
Skill Creator
This skill provides guidance for creating effective skills.
About Skills
Skills are modular, self-contained packages that extend Claude's capabilities by providing specialized knowledge, workflows, and tools. Think of them as "onboarding guides" for specific domains or tasks—they transform Claude from a general-purpose agent into a specialized agent equipped with procedural knowledge that no model can fully possess.
What Skills Provide
- Specialized workflows - Multi-step procedures for specific domains
- Tool integrations - Instructions for working with specific file formats or APIs
- Domain expertise - Company-specific knowledge, schemas, business logic
- Bundled resources - Scripts, references, and assets for complex and repetitive tasks
Core Principles
Concise is Key
The context window is a public good. Skills share the context window with everything else Claude needs: system prompt, conversation history, other Skills' metadata, and the actual user request.
Default assumption: Claude is already very smart. Only add context Claude doesn't already have. Challenge each piece of information: "Does Claude really need this explanation?" and "Does this paragraph justify its token cost?"
Prefer concise examples over verbose explanations.
Set Appropriate Degrees of Freedom
Match the level of specificity to the task's fragility and variability:
High freedom (text-based instructions): Use when multiple approaches are valid, decisions depend on context, or heuristics guide the approach.
Medium freedom (pseudocode or scripts with parameters): Use when a preferred pattern exists, some variation is acceptable, or configuration affects behavior.
Low freedom (specific scripts, few parameters): Use when operations are fragile and error-prone, consistency is critical, or a specific sequence must be followed.
Think of Claude as exploring a path: a narrow bridge with cliffs needs specific guardrails (low freedom), while an open field allows many routes (high freedom).
Anatomy of a Skill
Every skill consists of a required SKILL.md file and optional bundled resources:
skill-name/
├── SKILL.md (required)
│ ├── YAML frontmatter metadata (required)
│ │ ├── name: (required)
│ │ └── description: (required)
│ └── Markdown instructions (required)
└── Bundled Resources (optional)
├── scripts/ - Executable code (Python/Bash/etc.)
├── references/ - Documentation intended to be loaded into context as needed
└── assets/ - Files used in output (templates, icons, fonts, etc.)
SKILL.md (required)
Every SKILL.md consists of:
- Frontmatter (YAML): Contains
nameanddescriptionfields. These are the only fields that Claude reads to determine when the skill gets used, thus it is very important to be clear and comprehensive in describing what the skill is, and when it should be used. - Body (Markdown): Instructions and guidance for using the skill. Only loaded AFTER the skill triggers (if at all).
Bundled Resources (optional)
Scripts (scripts/)
Executable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten.
- When to include: When the same code is being rewritten repeatedly or deterministic reliability is needed
- Example:
scripts/rotate_pdf.pyfor PDF rotation tasks - Benefits: Token efficient, deterministic, may be executed without loading into context
- Note: Scripts may still need to be read by Claude for patching or environment-specific adjustments
References (references/)
Documentation and reference material intended to be loaded as needed into context to inform Claude's process and thinking.
- When to include: For documentation that Claude should reference while working
- Examples:
references/finance.mdfor financial schemas,references/mnda.mdfor company NDA template,references/policies.mdfor company policies,references/api_docs.mdfor API specifications - Use cases: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides
- Benefits: Keeps SKILL.md lean, loaded only when Claude determines it's needed
- Best practice: If files are large (>10k words), include grep search patterns in SKILL.md
- Avoid duplication: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files.
Assets (assets/)
Files not intended to be loaded into context, but rather used within the output Claude produces.
- When to include: When the skill needs files that will be used in the final output
- Examples:
assets/logo.pngfor brand assets,assets/slides.pptxfor PowerPoint templates,assets/frontend-template/for HTML/React boilerplate,assets/font.ttffor typography - Use cases: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified
- Benefits: Separates output resources from documentation, enables Claude to use files without loading them into context
What to Not Include in a Skill
A skill should only contain essential files that directly support its functionality. Do NOT create extraneous documentation or auxiliary files, including:
- README.md
- INSTALLATION_GUIDE.md
- QUICK_REFERENCE.md
- CHANGELOG.md
- etc.
The skill should only contain the information needed for an AI agent to do the job at hand. It should not contain auxilary context about the process that went into creating it, setup and testing procedures, user-facing documentation, etc. Creating additional documentation files just adds clutter and confusion.
Progressive Disclosure Design Principle
Skills use a three-level loading system to manage context efficiently:
- Metadata (name + description) - Always in context (~100 words)
- SKILL.md body - When skill triggers (<5k words)
- Bundled resources - As needed by Claude (Unlimited because scripts can be executed without reading into context window)
Progressive Disclosure Patterns
Keep SKILL.md body to the essentials and under 500 lines to minimize context bloat. Split content into separate files when approaching this limit. When splitting out content into other files, it is very important to reference them from SKILL.md and describe clearly when to read them, to ensure the reader of the skill knows they exist and when to use them.
Key principle: When a skill supports multiple variations, frameworks, or options, keep only the core workflow and selection guidance in SKILL.md. Move variant-specific details (patterns, examples, configuration) into separate reference files.
Pattern 1: High-level guide with references
# PDF Processing
## Quick start
Extract text with pdfplumber:
[code example]
## Advanced features
- **Form filling**: See [FORMS.md](FORMS.md) for complete guide
- **API reference**: See [REFERENCE.md](REFERENCE.md) for all methods
- **Examples**: See [EXAMPLES.md](EXAMPLES.md) for common patterns
Claude loads FORMS.md, REFERENCE.md, or EXAMPLES.md only when needed.
Pattern 2: Domain-specific organization
For Skills with multiple domains, organize content by domain to avoid loading irrelevant context:
bigquery-skill/
├── SKILL.md (overview and navigation)
└── reference/
├── finance.md (revenue, billing metrics)
├── sales.md (opportunities, pipeline)
├── product.md (API usage, features)
└── marketing.md (campaigns, attribution)
When a user asks about sales metrics, Claude only reads sales.md.
Similarly, for skills supporting multiple frameworks or variants, organize by variant:
cloud-deploy/
├── SKILL.md (workflow + provider selection)
└── references/
├── aws.md (AWS deployment patterns)
├── gcp.md (GCP deployment patterns)
└── azure.md (Azure deployment patterns)
When the user chooses AWS, Claude only reads aws.md.
Pattern 3: Conditional details
Show basic content, link to advanced content:
# DOCX Processing
## Creating documents
Use docx-js for new documents. See [DOCX-JS.md](DOCX-JS.md).
## Editing documents
For simple edits, modify the XML directly.
**For tracked changes**: See [REDLINING.md](REDLINING.md)
**For OOXML details**: See [OOXML.md](OOXML.md)
Claude reads REDLINING.md or OOXML.md only when the user needs those features.
Important guidelines:
- Avoid deeply nested references - Keep references one level deep from SKILL.md. All reference files should link directly from SKILL.md.
- Structure longer reference files - For files longer than 100 lines, include a table of contents at the top so Claude can see the full scope when previewing.
Skill Creation Process
Skill creation involves these steps:
- Understand the skill with concrete examples
- Plan reusable skill contents (scripts, references, assets)
- Initialize the skill (run init_skill.py)
- Edit the skill (implement resources and write SKILL.md)
- Package the skill (run package_skill.py)
- Iterate based on real usage
Follow these steps in order, skipping only if there is a clear reason why they are not applicable.
Step 1: Understanding the Skill with Concrete Examples
Skip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill.
To create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback.
For example, when building an image-editor skill, relevant questions include:
- "What functionality should the image-editor skill support? Editing, rotating, anything else?"
- "Can you give some examples of how this skill would be used?"
- "I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?"
- "What would a user say that should trigger this skill?"
To avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness.
Conclude this step when there is a clear sense of the functionality the skill should support.
Step 2: Planning the Reusable Skill Contents
To turn concrete examples into an effective skill, analyze each example by:
- Considering how to execute on the example from scratch
- Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly
Example: When building a pdf-editor skill to handle queries like "Help me rotate this PDF," the analysis shows:
- Rotating a PDF requires re-writing the same code each time
- A
scripts/rotate_pdf.pyscript would be helpful to store in the skill
Example: When designing a frontend-webapp-builder skill for queries like "Build me a todo app" or "Build me a dashboard to track my steps," the analysis shows:
- Writing a frontend webapp requires the same boilerplate HTML/React each time
- An
assets/hello-world/template containing the boilerplate HTML/React project files would be helpful to store in the skill
Example: When building a big-query skill to handle queries like "How many users have logged in today?" the analysis shows:
- Querying BigQuery requires re-discovering the table schemas and relationships each time
- A
references/schema.mdfile documenting the table schemas would be helpful to store in the skill
To establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets.
Step 3: Initializing the Skill
At this point, it is time to actually create the skill.
Skip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step.
When creating a new skill from scratch, always run the init_skill.py script. The script conveniently generates a new template skill directory that automatically includes everything a skill requires, making the skill creation process much more efficient and reliable.
Usage:
scripts/init_skill.py <skill-name> --path <output-directory>
The script:
- Creates the skill directory at the specified path
- Generates a SKILL.md template with proper frontmatter and TODO placeholders
- Creates example resource directories:
scripts/,references/, andassets/ - Adds example files in each directory that can be customized or deleted
After initialization, customize or remove the generated SKILL.md and example files as needed.
Step 4: Edit the Skill
When editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of Claude to use. Include information that would be beneficial and non-obvious to Claude. Consider what procedural knowledge, domain-specific details, or reusable assets would help another Claude instance execute these tasks more effectively.
Learn Proven Design Patterns
Consult these helpful guides based on your skill's needs:
- Multi-step processes: See references/workflows.md for sequential workflows and conditional logic
- Specific output formats or quality standards: See references/output-patterns.md for template and example patterns
These files contain established best practices for effective skill design.
Start with Reusable Skill Contents
To begin implementation, start with the reusable resources identified above: scripts/, references/, and assets/ files. Note that this step may require user input. For example, when implementing a brand-guidelines skill, the user may need to provide brand assets or templates to store in assets/, or documentation to store in references/.
Added scripts must be tested by actually running them to ensure there are no bugs and that the output matches what is expected. If there are many similar scripts, only a representative sample needs to be tested to ensure confidence that they all work while balancing time to completion.
Any example files and directories not needed for the skill should be deleted. The initialization script creates example files in scripts/, references/, and assets/ to demonstrate structure, but most skills won't need all of them.
Update SKILL.md
Writing Guidelines: Always use imperative/infinitive form.
Frontmatter
Write the YAML frontmatter with name and description:
name: The skill namedescription: This is the primary triggering mechanism for your skill, and helps Claude understand when to use the skill.- Include both what the Skill does and specific triggers/contexts for when to use it.
- Include all "when to use" information here - Not in the body. The body is only loaded after triggering, so "When to Use This Skill" sections in the body are not helpful to Claude.
- Example description for a
docxskill: "Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. Use when Claude needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks"
Do not include any other fields in YAML frontmatter.
Body
Write instructions for using the skill and its bundled resources.
Step 5: Packaging a Skill
Once development of the skill is complete, it must be packaged into a distributable .skill file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements:
scripts/package_skill.py <path/to/skill-folder>
Optional output directory specification:
scripts/package_skill.py <path/to/skill-folder> ./dist
The packaging script will:
-
Validate the skill automatically, checking:
- YAML frontmatter format and required fields
- Skill naming conventions and directory structure
- Description completeness and quality
- File organization and resource references
-
Package the skill if validation passes, creating a .skill file named after the skill (e.g.,
my-skill.skill) that includes all files and maintains the proper directory structure for distribution. The .skill file is a zip file with a .skill extension.
If validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again.
Step 6: Iterate
After testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed.
Iteration workflow:
- Use the skill on real tasks
- Notice struggles or inefficiencies
- Identify how SKILL.md or bundled resources should be updated
- Implement changes and test again
skills/weather/SKILL.md
npx skills add melandlabs/openloomi --skill weather -g -y
SKILL.md
Frontmatter
{
"name": "weather",
"homepage": "https:\/\/wttr.in\/:help",
"description": "Get current weather and forecasts via wttr.in or Open-Meteo. Use when: user asks about weather, temperature, or forecasts for any location. NOT for: historical weather data, severe weather alerts, or detailed meteorological analysis. No API key needed."
}
Weather Skill
Get current weather conditions and forecasts.
When to Use
✅ USE this skill when:
- "What's the weather?"
- "Will it rain today/tomorrow?"
- "Temperature in [city]"
- "Weather forecast for the week"
- Travel planning weather checks
When NOT to Use
❌ DON'T use this skill when:
- Historical weather data → use weather archives/APIs
- Climate analysis or trends → use specialized data sources
- Hyper-local microclimate data → use local sensors
- Severe weather alerts → check official NWS sources
- Aviation/marine weather → use specialized services (METAR, etc.)
Location
Always include a city, region, or airport code in weather queries.
Commands
Current Weather
# One-line summary
curl "wttr.in/London?format=3"
# Detailed current conditions
curl "wttr.in/London?0"
# Specific city
curl "wttr.in/New+York?format=3"
Forecasts
# 3-day forecast
curl "wttr.in/London"
# Week forecast
curl "wttr.in/London?format=v2"
# Specific day (0=today, 1=tomorrow, 2=day after)
curl "wttr.in/London?1"
Format Options
# One-liner
curl "wttr.in/London?format=%l:+%c+%t+%w"
# JSON output
curl "wttr.in/London?format=j1"
# PNG image
curl "wttr.in/London.png"
Format Codes
%c— Weather condition emoji%t— Temperature%f— "Feels like"%w— Wind%h— Humidity%p— Precipitation%l— Location
Quick Responses
"What's the weather?"
curl -s "wttr.in/London?format=%l:+%c+%t+(feels+like+%f),+%w+wind,+%h+humidity"
"Will it rain?"
curl -s "wttr.in/London?format=%l:+%c+%p"
"Weekend forecast"
curl "wttr.in/London?format=v2"
Notes
- No API key needed (uses wttr.in)
- Rate limited; don't spam requests
- Works for most global cities
- Supports airport codes:
curl wttr.in/ORD
skills/docx/SKILL.md
npx skills add melandlabs/openloomi --skill docx -g -y
SKILL.md
Frontmatter
{
"name": "docx",
"license": "Proprietary. LICENSE.txt has complete terms",
"description": "Use this skill whenever the user wants to create, read, edit, or manipulate Word documents (.docx files). Triggers include: any mention of \"Word doc\", \"word document\", \".docx\", or requests to produce professional documents with formatting like tables of contents, headings, page numbers, or letterheads. Also use when extracting or reorganizing content from .docx files, inserting or replacing images in documents, performing find-and-replace in Word files, working with tracked changes or comments, or converting content into a polished Word document. If the user asks for a \"report\", \"memo\", \"letter\", \"template\", or similar deliverable as a Word or .docx file, use this skill. Do NOT use for PDFs, spreadsheets, Google Docs, or general coding tasks unrelated to document generation."
}
DOCX creation, editing, and analysis
Overview
A .docx file is a ZIP archive containing XML files.
Quick Reference
| Task | Approach |
|---|---|
| Read/analyze content | pandoc or unpack for raw XML |
| Create new document | Use docx-js - see Creating New Documents below |
| Edit existing document | Unpack → edit XML → repack - see Editing Existing Documents below |
Converting .doc to .docx
Legacy .doc files are auto-converted on read and write — no manual step needed:
# Reading .doc (auto-converted to .docx internally)
python scripts/office/unpack.py legacy.doc unpacked/
# Writing .doc (auto-converted from .docx)
python scripts/office/pack.py unpacked/ output.doc
LibreOffice is used for the conversion, with automatic sandbox-friendly socket handling.
Reading Content
# Text extraction with tracked changes
pandoc --track-changes=all document.docx -o output.md
# Raw XML access
python scripts/office/unpack.py document.docx unpacked/
Converting to Images
python scripts/office/soffice.py --headless --convert-to pdf document.docx
pdftoppm -jpeg -r 150 document.pdf page
Accepting Tracked Changes
To produce a clean document with all tracked changes accepted (requires LibreOffice):
python scripts/accept_changes.py input.docx output.docx
Creating New Documents
Generate .docx files with JavaScript, then validate. Install: npm install -g docx
Setup
const {
Document,
Packer,
Paragraph,
TextRun,
Table,
TableRow,
TableCell,
ImageRun,
Header,
Footer,
AlignmentType,
PageOrientation,
LevelFormat,
ExternalHyperlink,
TableOfContents,
HeadingLevel,
BorderStyle,
WidthType,
ShadingType,
VerticalAlign,
PageNumber,
PageBreak,
} = require("docx");
const doc = new Document({
sections: [
{
children: [
/* content */
],
},
],
});
Packer.toBuffer(doc).then((buffer) => fs.writeFileSync("doc.docx", buffer));
Validation
After creating the file, validate it. If validation fails, unpack, fix the XML, and repack.
python scripts/office/validate.py doc.docx
Page Size
// CRITICAL: docx-js defaults to A4, not US Letter
// Always set page size explicitly for consistent results
sections: [
{
properties: {
page: {
size: {
width: 12240, // 8.5 inches in DXA
height: 15840, // 11 inches in DXA
},
margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 }, // 1 inch margins
},
},
children: [
/* content */
],
},
];
Common page sizes (DXA units, 1440 DXA = 1 inch):
| Paper | Width | Height | Content Width (1" margins) |
|---|---|---|---|
| US Letter | 12,240 | 15,840 | 9,360 |
| A4 (default) | 11,906 | 16,838 | 9,026 |
Landscape orientation: docx-js swaps width/height internally, so pass portrait dimensions and let it handle the swap:
size: {
width: 12240, // Pass SHORT edge as width
height: 15840, // Pass LONG edge as height
orientation: PageOrientation.LANDSCAPE // docx-js swaps them in the XML
},
// Content width = 15840 - left margin - right margin (uses the long edge)
Styles (Override Built-in Headings)
Use Arial as the default font (universally supported). Keep titles black for readability.
const doc = new Document({
styles: {
default: { document: { run: { font: "Arial", size: 24 } } }, // 12pt default
paragraphStyles: [
// IMPORTANT: Use exact IDs to override built-in styles
{
id: "Heading1",
name: "Heading 1",
basedOn: "Normal",
next: "Normal",
quickFormat: true,
run: { size: 32, bold: true, font: "Arial" },
paragraph: { spacing: { before: 240, after: 240 }, outlineLevel: 0 },
}, // outlineLevel required for TOC
{
id: "Heading2",
name: "Heading 2",
basedOn: "Normal",
next: "Normal",
quickFormat: true,
run: { size: 28, bold: true, font: "Arial" },
paragraph: { spacing: { before: 180, after: 180 }, outlineLevel: 1 },
},
],
},
sections: [
{
children: [
new Paragraph({
heading: HeadingLevel.HEADING_1,
children: [new TextRun("Title")],
}),
],
},
],
});
Lists (NEVER use unicode bullets)
// ❌ WRONG - never manually insert bullet characters
new Paragraph({ children: [new TextRun("• Item")] }); // BAD
new Paragraph({ children: [new TextRun("\u2022 Item")] }); // BAD
// ✅ CORRECT - use numbering config with LevelFormat.BULLET
const doc = new Document({
numbering: {
config: [
{
reference: "bullets",
levels: [
{
level: 0,
format: LevelFormat.BULLET,
text: "•",
alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 720, hanging: 360 } } },
},
],
},
{
reference: "numbers",
levels: [
{
level: 0,
format: LevelFormat.DECIMAL,
text: "%1.",
alignment: AlignmentType.LEFT,
style: { paragraph: { indent: { left: 720, hanging: 360 } } },
},
],
},
],
},
sections: [
{
children: [
new Paragraph({
numbering: { reference: "bullets", level: 0 },
children: [new TextRun("Bullet item")],
}),
new Paragraph({
numbering: { reference: "numbers", level: 0 },
children: [new TextRun("Numbered item")],
}),
],
},
],
});
// ⚠️ Each reference creates INDEPENDENT numbering
// Same reference = continues (1,2,3 then 4,5,6)
// Different reference = restarts (1,2,3 then 1,2,3)
Tables
CRITICAL: Tables need dual widths - set both columnWidths on the table AND width on each cell. Without both, tables render incorrectly on some platforms.
// CRITICAL: Always set table width for consistent rendering
// CRITICAL: Use ShadingType.CLEAR (not SOLID) to prevent black backgrounds
const border = { style: BorderStyle.SINGLE, size: 1, color: "CCCCCC" };
const borders = { top: border, bottom: border, left: border, right: border };
new Table({
width: { size: 9360, type: WidthType.DXA }, // Always use DXA (percentages break in Google Docs)
columnWidths: [4680, 4680], // Must sum to table width (DXA: 1440 = 1 inch)
rows: [
new TableRow({
children: [
new TableCell({
borders,
width: { size: 4680, type: WidthType.DXA }, // Also set on each cell
shading: { fill: "D5E8F0", type: ShadingType.CLEAR }, // CLEAR not SOLID
margins: { top: 80, bottom: 80, left: 120, right: 120 }, // Cell padding (internal, not added to width)
children: [new Paragraph({ children: [new TextRun("Cell")] })],
}),
],
}),
],
});
Table width calculation:
Always use WidthType.DXA — WidthType.PERCENTAGE breaks in Google Docs.
// Table width = sum of columnWidths = content width
// US Letter with 1" margins: 12240 - 2880 = 9360 DXA
width: { size: 9360, type: WidthType.DXA },
columnWidths: [7000, 2360] // Must sum to table width
Width rules:
- Always use
WidthType.DXA— neverWidthType.PERCENTAGE(incompatible with Google Docs) - Table width must equal the sum of
columnWidths - Cell
widthmust match correspondingcolumnWidth - Cell
marginsare internal padding - they reduce content area, not add to cell width - For full-width tables: use content width (page width minus left and right margins)
Images
// CRITICAL: type parameter is REQUIRED
new Paragraph({
children: [
new ImageRun({
type: "png", // Required: png, jpg, jpeg, gif, bmp, svg
data: fs.readFileSync("image.png"),
transformation: { width: 200, height: 150 },
altText: { title: "Title", description: "Desc", name: "Name" }, // All three required
}),
],
});
Page Breaks
// CRITICAL: PageBreak must be inside a Paragraph
new Paragraph({ children: [new PageBreak()] });
// Or use pageBreakBefore
new Paragraph({ pageBreakBefore: true, children: [new TextRun("New page")] });
Table of Contents
// CRITICAL: Headings must use HeadingLevel ONLY - no custom styles
new TableOfContents("Table of Contents", {
hyperlink: true,
headingStyleRange: "1-3",
});
Headers/Footers
sections: [
{
properties: {
page: { margin: { top: 1440, right: 1440, bottom: 1440, left: 1440 } }, // 1440 = 1 inch
},
headers: {
default: new Header({
children: [new Paragraph({ children: [new TextRun("Header")] })],
}),
},
footers: {
default: new Footer({
children: [
new Paragraph({
children: [
new TextRun("Page "),
new TextRun({ children: [PageNumber.CURRENT] }),
],
}),
],
}),
},
children: [
/* content */
],
},
];
Critical Rules for docx-js
- Set page size explicitly - docx-js defaults to A4; use US Letter (12240 x 15840 DXA) for US documents
- Landscape: pass portrait dimensions - docx-js swaps width/height internally; pass short edge as
width, long edge asheight, and setorientation: PageOrientation.LANDSCAPE - Never use
\n- use separate Paragraph elements - Never use unicode bullets - use
LevelFormat.BULLETwith numbering config - PageBreak must be in Paragraph - standalone creates invalid XML
- ImageRun requires
type- always specify png/jpg/etc - Always set table
widthwith DXA - never useWidthType.PERCENTAGE(breaks in Google Docs) - Tables need dual widths -
columnWidthsarray AND cellwidth, both must match - Table width = sum of columnWidths - for DXA, ensure they add up exactly
- Always add cell margins - use
margins: { top: 80, bottom: 80, left: 120, right: 120 }for readable padding - Use
ShadingType.CLEAR- never SOLID for table shading - TOC requires HeadingLevel only - no custom styles on heading paragraphs
- Override built-in styles - use exact IDs: "Heading1", "Heading2", etc.
- Include
outlineLevel- required for TOC (0 for H1, 1 for H2, etc.)
Editing Existing Documents
Follow all 3 steps in order.
Step 1: Unpack
python scripts/office/unpack.py document.docx unpacked/
Extracts XML, pretty-prints, merges adjacent runs, and converts smart quotes to XML entities (“ etc.) so they survive editing. Use --merge-runs false to skip run merging.
Step 2: Edit XML
Edit files in unpacked/word/. See XML Reference below for patterns.
Use "Claude" as the author for tracked changes and comments, unless the user explicitly requests use of a different name.
Use the Edit tool directly for string replacement. Do not write Python scripts. Scripts introduce unnecessary complexity. The Edit tool shows exactly what is being replaced.
CRITICAL: Use smart quotes for new content. When adding text with apostrophes or quotes, use XML entities to produce smart quotes:
<!-- Use these entities for professional typography -->
<w:t>Here’s a quote: “Hello”</w:t>
| Entity | Character |
|---|---|
‘ |
‘ (left single) |
’ |
’ (right single / apostrophe) |
“ |
“ (left double) |
” |
” (right double) |
Adding comments: Use comment.py to handle boilerplate across multiple XML files (text must be pre-escaped XML):
python scripts/comment.py unpacked/ 0 "Comment text with & and ’"
python scripts/comment.py unpacked/ 1 "Reply text" --parent 0 # reply to comment 0
python scripts/comment.py unpacked/ 0 "Text" --author "Custom Author" # custom author name
Then add markers to document.xml (see Comments in XML Reference).
Step 3: Pack
python scripts/office/pack.py unpacked/ output.docx --original document.docx
Validates with auto-repair, condenses XML, and creates DOCX. Use --validate false to skip.
Auto-repair will fix:
durableId>= 0x7FFFFFFF (regenerates valid ID)- Missing
xml:space="preserve"on<w:t>with whitespace
Auto-repair won't fix:
- Malformed XML, invalid element nesting, missing relationships, schema violations
Common Pitfalls
- Replace entire
<w:r>elements: When adding tracked changes, replace the whole<w:r>...</w:r>block with<w:del>...<w:ins>...as siblings. Don't inject tracked change tags inside a run. - Preserve
<w:rPr>formatting: Copy the original run's<w:rPr>block into your tracked change runs to maintain bold, font size, etc.
XML Reference
Schema Compliance
- Element order in
<w:pPr>:<w:pStyle>,<w:numPr>,<w:spacing>,<w:ind>,<w:jc>,<w:rPr>last - Whitespace: Add
xml:space="preserve"to<w:t>with leading/trailing spaces - RSIDs: Must be 8-digit hex (e.g.,
00AB1234)
Tracked Changes
Insertion:
<w:ins w:id="1" w:author="Claude" w:date="2025-01-01T00:00:00Z">
<w:r><w:t>inserted text</w:t></w:r>
</w:ins>
Deletion:
<w:del w:id="2" w:author="Claude" w:date="2025-01-01T00:00:00Z">
<w:r><w:delText>deleted text</w:delText></w:r>
</w:del>
Inside <w:del>: Use <w:delText> instead of <w:t>, and <w:delInstrText> instead of <w:instrText>.
Minimal edits - only mark what changes:
<!-- Change "30 days" to "60 days" -->
<w:r><w:t>The term is </w:t></w:r>
<w:del w:id="1" w:author="Claude" w:date="...">
<w:r><w:delText>30</w:delText></w:r>
</w:del>
<w:ins w:id="2" w:author="Claude" w:date="...">
<w:r><w:t>60</w:t></w:r>
</w:ins>
<w:r><w:t> days.</w:t></w:r>
Deleting entire paragraphs/list items - when removing ALL content from a paragraph, also mark the paragraph mark as deleted so it merges with the next paragraph. Add <w:del/> inside <w:pPr><w:rPr>:
<w:p>
<w:pPr>
<w:numPr>...</w:numPr> <!-- list numbering if present -->
<w:rPr>
<w:del w:id="1" w:author="Claude" w:date="2025-01-01T00:00:00Z"/>
</w:rPr>
</w:pPr>
<w:del w:id="2" w:author="Claude" w:date="2025-01-01T00:00:00Z">
<w:r><w:delText>Entire paragraph content being deleted...</w:delText></w:r>
</w:del>
</w:p>
Without the <w:del/> in <w:pPr><w:rPr>, accepting changes leaves an empty paragraph/list item.
Rejecting another author's insertion - nest deletion inside their insertion:
<w:ins w:author="Jane" w:id="5">
<w:del w:author="Claude" w:id="10">
<w:r><w:delText>their inserted text</w:delText></w:r>
</w:del>
</w:ins>
Restoring another author's deletion - add insertion after (don't modify their deletion):
<w:del w:author="Jane" w:id="5">
<w:r><w:delText>deleted text</w:delText></w:r>
</w:del>
<w:ins w:author="Claude" w:id="10">
<w:r><w:t>deleted text</w:t></w:r>
</w:ins>
Comments
After running comment.py (see Step 2), add markers to document.xml. For replies, use --parent flag and nest markers inside the parent's.
CRITICAL: <w:commentRangeStart> and <w:commentRangeEnd> are siblings of <w:r>, never inside <w:r>.
<!-- Comment markers are direct children of w:p, never inside w:r -->
<w:commentRangeStart w:id="0"/>
<w:del w:id="1" w:author="Claude" w:date="2025-01-01T00:00:00Z">
<w:r><w:delText>deleted</w:delText></w:r>
</w:del>
<w:r><w:t> more text</w:t></w:r>
<w:commentRangeEnd w:id="0"/>
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="0"/></w:r>
<!-- Comment 0 with reply 1 nested inside -->
<w:commentRangeStart w:id="0"/>
<w:commentRangeStart w:id="1"/>
<w:r><w:t>text</w:t></w:r>
<w:commentRangeEnd w:id="1"/>
<w:commentRangeEnd w:id="0"/>
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="0"/></w:r>
<w:r><w:rPr><w:rStyle w:val="CommentReference"/></w:rPr><w:commentReference w:id="1"/></w:r>
Images
- Add image file to
word/media/ - Add relationship to
word/_rels/document.xml.rels:
<Relationship Id="rId5" Type=".../image" Target="media/image1.png"/>
- Add content type to
[Content_Types].xml:
<Default Extension="png" ContentType="image/png"/>
- Reference in document.xml:
<w:drawing>
<wp:inline>
<wp:extent cx="914400" cy="914400"/> <!-- EMUs: 914400 = 1 inch -->
<a:graphic>
<a:graphicData uri=".../picture">
<pic:pic>
<pic:blipFill><a:blip r:embed="rId5"/></pic:blipFill>
</pic:pic>
</a:graphicData>
</a:graphic>
</wp:inline>
</w:drawing>
Dependencies
- pandoc: Text extraction
- docx:
npm install -g docx(new documents) - LibreOffice: PDF conversion (auto-configured for sandboxed environments via
scripts/office/soffice.py) - Poppler:
pdftoppmfor images
skills/openloomi-loop/SKILL.md
npx skills add melandlabs/openloomi --skill openloomi-loop -g -y
SKILL.md
Frontmatter
{
"name": "openloomi-loop",
"metadata": {
"version": "0.6.4"
},
"description": "Use this when the user asks about openloomi's Loop — openloomi's proactive execution brain. It actively and continuously pulls external signals (Gmail, Calendar, GitHub, Slack) via any of three Composio surfaces — **MCP** (`mcp__composio__*`), the **`composio-cli` skill**, or the **`composio` CLI** — enriches them through openloomi-memory, **converts every actionable signal into a typed decision** (`rsvp` \/ `draft_reply` \/ `review_pr` \/ `todo` \/ `slack_reply` \/ …), queues it in `data\/decisions.json`, and **executes via the openloomi native agent API by default** (`POST http:\/\/127.0.0.1:3414\/api\/native\/agent`, the same agentic endpoint the locomo benchmark uses — supports tool use, memory writes, multi-round reasoning; no agent install needed) — with a pluggable spawned-CLI fallback (`claude -p` \/ `codex` \/ `aider` \/ anything via `LOOP_AGENT_BIN`). Triggers: 'openloomi loop', 'loop tick', 'loop schedule', 'loop inbox', 'loop run', 'proactive decisions', 'signal → decision → execute', 'pull signals', 'decision queue', 'loop serve'",
"allowed-tools": "Bash(node $SKILL_DIR\/scripts\/openloomi-loop.cjs *), Bash(node $SKILL_DIR\/scripts\/loop-tick.cjs *), Bash(node $SKILL_DIR\/scripts\/obsidian-scan.cjs *), Bash(node ..\/..\/openloomi-memory\/scripts\/openloomi-memory.cjs *), Bash(curl *), Bash(claude *), Bash(codex *), Bash(aider *), Bash(tail -f $SKILL_DIR\/data\/daemon.log), Bash(cat >> $SKILL_DIR\/data\/signals.jsonl), Bash(echo *), Bash(ls *)"
}
Note: If you haven't downloaded or installed openloomi yet, please refer to Getting Started for installation instructions.
OpenLoomi Loop — The Proactive Execution Brain
Proactive & Continuous — watches external signals, thinks via openloomi-memory, and acts via the openloomi AI API (or any agent runtime you choose). Proactive Execution Brain — the always-on execution layer of openloomi.
A Claude Code skill that runs proactively and continuously, turning ambient signals from your connected tools into finished work. The Loop is openloomi's proactive execution brain — a vigilant teammate that watches, thinks, and acts without you having to ask. Three layers, all agentic:
- Pull — Claude fetches fresh signals (Gmail, Calendar, GitHub, Slack) into a local signal store (
data/signals.jsonl) via any of three Composio surfaces: the Composio MCP (mcp__composio__*tools), thecomposio-cliskill (Claude callsSkill composio-cli …), or thecomposioCLI (Claude shells out withBash(composio …)). The Loop doesn't care which surface — only the resulting signal envelope matters. - Signal → Decision — Claude calls the openloomi-memory skill to enrich every signal with sender / project context, then converts every actionable signal into a typed decision (
rsvp,draft_reply,review_pr,todo,slack_reply, …) and appends it todata/decisions.json. Signals that survive the hard-rule filters are never left raw — they are always either classified into a decision or explicitly dropped with a reason. The queue is the single source of truth for "what's next." - Execute — You browse the decision queue and pick. Picking hands the built prompt to an agent runtime — by default the openloomi native agent API (
POST http://127.0.0.1:3414/api/native/agent, the same agentic endpoint the locomo benchmark uses; supports tool use, memory writes, multi-round reasoning; authenticated with~/.openloomi/token; no agent install required). The runtime can be swapped to any spawned CLI agent (claude -p/codex/aider/ custom binary, picked viaLOOP_AGENT_BIN) or, when already inside a Claude Code session, executed in-session via direct tool calls. All three surfaces read the same prompt, dispatch the sameaction.kind, and write memory back via openloomi-memory.
No background daemon. No subprocess hacks. No local memory cache. The Loop is Claude pulling signals, Claude enriching with memory, Claude acting — every layer is agentic. The brain never sleeps: it ticks, watches, and remembers.
Proactive & Continuous
The Loop is not a one-shot tool you invoke. It is a continuously running execution brain with two complementary properties:
- Proactive — The Loop watches Gmail, Calendar, GitHub, and Slack in the background (via any of the three Composio surfaces — MCP,
composio-cliskill, orcomposioCLI). It surfaces decisions before you ask: a meeting invitation becomes anrsvpsuggestion, an unread email from a known person becomes adraft_replycard, a PR where you're a reviewer becomes areview_prtask. Nothing fires automatically — but everything is queued and waiting the moment you look. - Continuous —
loop schedule --interval Nruns an infinite tick loop in the background. Each tick: pull new signals → enrich with memory → classify → queue. State persists indata/decisions.json, so the queue survives restarts, and each new signal joins the same ongoing conversation.loop watchkeeps emitting desktop notifications on fresh entries. - Proactive Execution Brain — Openloomi's memory (
openloomi-memory) stores what you know; the Loop is the brain that decides what to do about it. Itself not a daemon, not a script, not a cron — the Loop is an agent runtime, looping. Each tick is one call to whichever runtime is configured (openloomi AI API by default, or a spawned CLI agent); each executed decision is one more call. No Claude install required. Composability over persistence.
Together, Proactive and Continuous turns Claude into a teammate that never sleeps and never loses context: it remembers people (via openloomi-memory), watches the world (via any of the three Composio surfaces — MCP, composio-cli skill, or composio CLI), and prepares the next move (via the decision queue). You stay in control of execution; the Loop stays in control of awareness.
Quick Start
# 1. Ask your agent runtime to do one tick (prints the prompt it should run).
# Default = openloomi native agent API (no install needed, just ~/.openloomi/token):
PROMPT=$(node $SKILL_DIR/scripts/openloomi-loop.cjs tick --json | jq -r .prompt)
curl -sX POST http://127.0.0.1:3414/api/native/agent \
-H "Authorization: Bearer $(cat ~/.openloomi/token | base64 -d)" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg p "$PROMPT" '{prompt: $p, provider: "claude"}')"
# Or, if you have the `claude` CLI installed and want a spawned agent:
node $SKILL_DIR/scripts/openloomi-loop.cjs tick --compact | claude -p
# Or run the tick from inside a Claude Code session — Claude will see the
# "Run openloomi-loop tick" prompt in its context and execute it directly.
# 2. Drop a signal into the queue (for testing without Composio connected)
echo '{"source":"gmail","type":"email","payload":{"from":"Sarah <sarah@acme.com>","subject":"Q2 review tomorrow","labels":["INBOX"]}}' \
| node $SKILL_DIR/scripts/openloomi-loop.cjs inject -
# 3. Run the lib-level analyze (ingest inbox → classify → decisions)
node $SKILL_DIR/scripts/openloomi-loop.cjs analyze
# 4. Browse the decision queue
node $SKILL_DIR/scripts/openloomi-loop.cjs inbox # plain list
node $SKILL_DIR/scripts/openloomi-loop.cjs inbox --pick # arrow-key picker
# 5. Run a decision (spawns a new claude code session with full context)
node $SKILL_DIR/scripts/openloomi-loop.cjs run dec_xxx
# 6. Optional: schedule ticks in the background every N seconds
node $SKILL_DIR/scripts/openloomi-loop.cjs schedule --interval 600
# 7. Memory operations go through the openloomi-memory skill
node $SKILL_DIR/scripts/openloomi-loop.cjs memory search-all "Sarah"
Quick start/stop with loop-ctl.sh
For day-to-day use, prefer the bundled loop-ctl.sh helper over running the CLI directly. It manages both the schedule background loop and the web UI as a pair, writes PID files for clean shutdown, and self-heals the data/ directory on first run.
# Start schedule + web (defaults: INTERVAL=600s, LOOP_WEB_PORT=3614)
$SKILL_DIR/loop-ctl.sh start
# Check what's running
$SKILL_DIR/loop-ctl.sh status
# schedule: pid=6948 uptime=18m05s
# web: pid=6949 http://127.0.0.1:3614/
# Restart (e.g. after editing scripts/)
$SKILL_DIR/loop-ctl.sh restart
# Stop both
$SKILL_DIR/loop-ctl.sh stop
# Override defaults
LOOP_WEB_PORT=4000 INTERVAL=300 $SKILL_DIR/loop-ctl.sh start
What it does:
start— runsopenloomi-loop schedule --interval ${INTERVAL:-600}andopenloomi-loop web --port ${LOOP_WEB_PORT:-3614}in the background.schedulewrites its own PID todata/daemon.pid; the web PID is written todata/web.pid. Both stdout/stderr are redirected todata/schedule.loganddata/web.log. Auto-mkdirofdata/so first-run after a git-clean works. Skips if either is already alive (no double-start).stop—SIGTERMeach PID recorded indata/daemon.pid/data/web.pid, plus apkill -fbelt-and-suspenders for any orphan. Removes the PID files. No SIGKILL grace — spawned-agent children are expected to terminate cleanly on parent exit.status— prints theloop statussnapshot, checks that the web port is bound vialsof, and lists each PID file as alive / stale / not present.restart—stopthenstart.
It does not start a tick on its own — schedule spawns ticks every INTERVAL seconds, independent of any manual invocation. Pair with loop analyze or loop inject if you want to feed it ad-hoc.
Architecture
┌───────────────────────────────────────────────┐
│ │
▼ │
┌──────────────┐ ┌──────────────┐ ┌───────────────┴──┐ ┌──────────────┐
│ External │───▶│ Context │───▶│ Decision │───▶│ Execute │───▶ Output
│ Environment │ │ Layer │ │ Layer │ │ Layer │
│ │ │ │ │ │ │ │
│ Composio (3 │ │ signals.jsonl│ │ openloomi-memory │ │ Agent │
│ surfaces): │ │ (raw sigs) │ │ enrichment │ │ runtime (3 │
│ • MCP │ │ │ │ │ │ │ │ surfaces): │
│ mcp__compos│ │ ▼ │ │ ▼ │ │ • openloomi │
│ io__* │ │ signal → │ │ signal → │ │ AI API │
│ • composio- │ │ decision │ │ decision │ │ (default, │
│ cli Skill │ │ conversion │ │ classification │ │ no install)│
│ • composio │ │ │ │ (typed actions) │ │ • spawned │
│ CLI │ │ │ │ decisions.json │ │ CLI agent │
│ ↘ (no │ │ │ │ │ │ (claude -p,│
│ composio →) │ │ │ │ │ │ codex, │
│ list-insights │ │ │ │ │ │ aider, …) │
│ (openloomi- │ │ │ │ │ │ • in-session │
│ memory) │ │ │ │ │ │ (direct │
│ + data/inbox │ │ │ │ │ │ tool calls)│
└──────────────┘ └──────┬───────┘ └────────┬─────────┘ └──────┬───────┘
│ │ │
│ ┌─────────────┘ │
│ │ │
▼ ▼ ▼
openloomi-memory (single source of truth:
people, projects, insights,
entities, RAG, temporal)
Execute-layer pick order (configurable; default shown):
- openloomi native agent API (
POST http://127.0.0.1:3414/api/native/agent) —Authorization: Bearer $(cat ~/.openloomi/token | base64 -d). Default, no install. The same agentic endpoint the locomo benchmark uses; supports tool use, memory writes, multi-round reasoning, SSE streaming. - Spawned CLI agent (
claude -p/codex/aider/ custom, picked viaLOOP_AGENT_BIN) — only when the runtime needs features the native agent API can't drive (e.g. a different provider, a custom local toolset, or a CLI already wired into the user's shell). Costs a binary install and per-tick spawn. - In-session — when the user is already inside a parent agent session (Claude Code, Cursor, etc.) and wants zero spawn cost. Uses the parent's tools directly. No API key or child process.
Signal → Decision is a hard contract, not a suggestion. Every signal that survives the hard-rule filters in step 5 must be turned into a queued decision (step 6) before the tick returns. If classify() cannot map a signal to a known decision type, the tick queues a {type: "unknown"} decision with reason: "no_matching_action" rather than dropping it silently — so a signal either becomes an actionable decision or becomes a visible queue item the user can act on. No raw signals linger past a tick.
Data flow per tick (agentic)
- Pull — Claude fetches fresh data through whichever Composio surface is available. The default is the Composio MCP (
mcp__composio__COMPOSIO_MANAGE_CONNECTIONS+mcp__composio__COMPOSIO_MULTI_EXECUTE_TOOLin parallel for each connected toolkit). If MCP isn't loaded, Claude falls back to thecomposio-cliskill (Skill composio-clito discover and execute tools), or shells out to thecomposioCLI directly (Bash(composio …)). All three surfaces are equivalent — pick whichever the runtime supports. For gmail and slack, if no Composio surface is connected for that channel, the tick falls back toopenloomi-memory list-insights --channel=<gmail|slack> --days=N. For googlecalendar and github (no dedicated insight channel), the tick falls back to unfilteredlist-insights --days=Nand lets the existing classifier drop non-matching channels.data/inbox/*.jsonis always available as a manual path and is ingested by the lib-level tick. See "Synthesizing signals from insights" below for the payload mapping. - Persist — Each new signal is appended to
data/signals.jsonl(deduped bymessageId/eventId/ts). - Enrich — For every signal, Claude calls
openloomi-memoryto look up the sender / organizer / channel:search-all <name-or-email>across local files + knowledge base + insightssearch-memory <name> --directory=peoplefor direct hitslist-entities --type=person --search=<name>for the entity registrylist-insights --channel=<gmail|slack|...> --days=7for recent context
- Remember — New senders get
add-memorycalls into~/.openloomi/data/memory/people/. Recurring calendar event titles get project notes under~/.openloomi/data/memory/projects/. - Filter — Hard rules skip
noreply@*senders, GmailPromotions/Social/Forums/Updates/Spamlabels, already-accepted calendar events, already-replied emails. - Convert signal → decision — Every survivor must be converted into a typed decision. Survivors get a typed action:
rsvp,draft_reply,review_pr,todo,slack_reply, … A signal that cannot be classified is not silently dropped — it is queued as{type: "unknown", reason: "no_matching_action"}so the human can decide, or the classifier / tick prompt can be extended. The Loop never holds a raw signal in memory that hasn't been turned into a queued decision. - Queue — Decisions are added to
data/decisions.jsonvialoop ingest-decision '<json>'withmemory_refslisting the openloomi-memory entries cited. Confidence is 0.85 when the sender is in openloomi-memory, else 0.60. - Status — A snapshot is written to
data/status.jsonforloop status.
The tick is strictly read/derive. No external destructive action runs during a tick — execution is always on user request via loop run <id>.
Synthesizing signals from insights
When the composio toolkit is not registered for a channel, the tick prompt instructs Claude
to fall back to openloomi-memory list-insights and map each returned insight into the same
data/signals.jsonl payload shape that the composio path produces. The mapping is:
| Insight channel | Synthesized signal |
|---|---|
gmail |
type: "email" with payload.messageId = insight.id, payload.from = insight.people[0], payload.subject = insight.title, etc. |
slack |
type: "slack_message" with payload.channel, payload.ts, payload.user, payload.text. mentions_me = false (insights don't carry this flag — conservative). |
google_calendar, github |
Pull unfiltered insights; synthesize based on insight.groups[0]; signals whose type is not recognized by classify() are safely dropped. |
Other (telegram, whatsapp, discord, linkedin, twitter, weixin, rss, …) |
Synthesize as type: "<channel>_message"; classifier returns null and the signal is dropped. |
Each synthesized signal carries _origin: "insights" in its envelope so data/daemon.log
can account for which path produced it. Dedup uses both the existing
messageId / eventId / ts keys and a new _insightId = insight.id key, so toggling
composio on/off between ticks does not double-insert.
Commands
| Command | Purpose |
|---|---|
tick [--compact] [--json] [--config k=v] |
Print the prompt Claude runs for one Loop tick. --compact for cron; --json for structured output. |
schedule [--interval N] [--watch-interval N] |
Loop: call the agent runtime with loop tick --compact every N seconds and watch for new decisions (desktop notifications). The tick and watch run on independent timers — a hung tick never blocks notifications. Tick is hard-killed after LOOP_AGENT_TIMEOUT_MS (default 15 min). Writes its own PID for stop. |
watch [--interval N] |
Poll decisions.json every N seconds and fire desktop notifications on new pending decisions. Pair with external ticks (cron, another loop schedule) to feed it. |
notify [--all] [--webhook URL] |
Manually fire notifications. --all notifies every current pending; default notifies only new (unseen) ones. Webhook (Slack-compatible JSON) optional via --webhook or env LOOP_NOTIFY_WEBHOOK. |
ingest-decision <json|- or file> |
Append a decision to decisions.json. Called by the Claude tick agent. |
analyze [--seen-init] |
Lib-level tick: ingest data/inbox/ → classify → decisions. Memory enrichment is skipped (the agentic tick handles that). --seen-init also clears data/notifications.seen.json so a running watch will re-fire notifications for all current pending on its next poll. |
pull |
Alias for analyze (kept for backwards compat). |
status |
Show last-tick snapshot + counts + config + current watch session (pid, started_at, host). |
summary [--since=ISO] |
Activity report from notifications.log. Default = current watch session window. --since=<ISO> = everything from that timestamp. Batches tagged [pid=X] (this session) vs [pid=?] (pre-session, historical). Use to answer "what did I receive this session?" without conflating historical log entries. |
inbox [--pick] [--limit N] |
List pending decisions (interactive picker). |
decisions [--status pending|done|dismissed|all] |
List decisions by bucket. |
decision <id> |
Show full JSON for one decision. |
run <id> [--dry] |
Hand the built prompt to the configured agent runtime (openloomi AI API by default; spawned CLI agent fallback). |
dismiss <id> [--reason ...] |
Mark as dismissed. |
inject <file|-> |
Drop a signal JSON into data/inbox/. |
memory <subcommand> [args...] |
Delegate to the openloomi-memory CLI: search-all, search-memory, list-insights, add-memory, add-insight, etc. |
config [get|set k v] |
Read/edit config. |
logs [-n N] |
Tail the loop log. |
serve |
REPL: list, run <id>, dismiss <id>, analyze, status, quit. |
web [--port N] [--no-open] |
Start HTTP server with REST API + Ink & Circuit style UI at http://127.0.0.1:N/. Auto-opens browser. CLI default port 3614 — collides with the openloomi desktop app, which binds 3614. When the app is running, use --port 3614 (or any other free port), or run via $SKILL_DIR/loop-ctl.sh start which defaults to 3614 to avoid the clash. |
Notification channels
Every fired notification is written to three places:
data/notifications.log— append-only log of all notifications- macOS desktop notification — via
osascript(no extra deps); auto-suppressed on other platforms - Webhook — Slack-compatible JSON POST, if
LOOP_NOTIFY_WEBHOOKenv var is set or--webhookis passed tonotify
The watcher maintains data/notifications.seen.json to ensure each decision fires exactly once.
All commands operate on $SKILL_DIR/data/ for the signal/decision store. Memory is delegated entirely to openloomi-memory.
Web UI — loop web
loop web (or node scripts/loop-web.cjs <port>) starts an HTTP server (override with --port N or LOOP_WEB_PORT). The CLI default is 3614, but the openloomi desktop app also binds 3614 — if both run on the same machine, the second one to start will fail with EADDRINUSE. The bundled $SKILL_DIR/loop-ctl.sh start defaults to 3614 to sidestep the conflict. Auto-opens the default browser.
Ink & Circuit themed UI (amber/dark, Syne + Space Grotesk + JetBrains Mono, hex markers, circuit corners) with three views:
| View | What it shows |
|---|---|
| Q Queue | 3-column kanban: PENDING / DONE / DISMISSED. Each card shows type badge, confidence bar, triggering context (⏰/✉️/🔀/💬), 👤 person, 🧠 memory refs, action line. Click → detail panel. |
| T Timeline | Canvas graph — decisions as hex nodes positioned by time, grouped by type. Pan/zoom. Click → detail. |
| A Activity | Split view: live notifications.log feed (left) + recent decisions (right). Auto-refreshes every 4s. |
Detail panel (slide-in from right):
- Why-this-surfaced trail, triggering context (organizer/sender/time/labels/snippet/branch)
- 👤 Known contact · 🧠 memory refs (click to inline-load file content)
- Suggested action JSON · raw source signal (click to view original
inbox/.processed/*.json) - Action buttons: ▶ RUN (hands prompt to agent runtime), DRY RUN (shows full prompt), ✓ MARK DONE, ✕ DISMISS
Keyboard: Q / T / A switch views · / open search · ↑↓ navigate · Enter run selected · Esc close.
REST API (CORS-enabled, returns JSON):
GET /api/state counts, last tick, status
GET /api/decisions { pending, done, dismissed }
GET /api/decision/:id full decision + bucket
GET /api/signals?limit=50 tail of signals.jsonl
GET /api/notifications?limit=50 tail of notifications.log
GET /api/memory?path=<rel> read ~/.openloomi/data/memory/<rel> (path-traversal safe)
GET /api/source?path=<rel> read data/inbox/<rel> (path-traversal safe)
POST /api/run/:id[?dry=1] hand prompt to agent runtime (or return prompt if dry=1)
POST /api/dismiss/:id move pending → dismissed
POST /api/done/:id move pending → done
POST /api/notify fire macOS desktop test notification
Static UI files served from web/. No external deps; no auth (binds 127.0.0.1 only).
Design system — Ink & Circuit
The web UI is built on the Ink & Circuit visual language. Reference files in references/:
| File | What |
|---|---|
references/DESIGN.md |
Canonical design tokens (colors, type, layout), component patterns, animation rules, keyboard map, "how to adapt to a new domain" guide. Update first when extending the visual language. |
references/index.html |
The design source this UI was adapted from. Keep untouched as a visual reference. |
web/index.html |
The openloomi-loop implementation. Has a header comment linking to ../references/DESIGN.md. |
The design system maps 5 decision types to the 5 knowledge-graph categories: rsvp (amber), draft_reply (green), review_pr (blue), slack_reply (purple), todo (red). When adding a new decision type, add a CSS variable, a .t-<type> card class, a hex color in JS TC, and a label in TL.
Decision Types
| Type | Trigger | Action |
|---|---|---|
rsvp |
Calendar event with my_response: needsAction |
calendar_rsvp |
draft_reply |
Email matching meeting/RSVP/invite patterns, or with action verbs (please/could you/need/urgent/…) from a known person | email_reply |
review_pr |
GitHub PR where you're a reviewer | github_review |
todo |
GitHub issue assigned to you, open | todo |
slack_reply |
Slack message that mentions you | slack_reply |
Confidence is 0.85 when sender is in openloomi-memory (known contact), else 0.60.
Running a Decision → Agent Runtime
run <id> builds a prompt like:
You are executing an openloomi Loop decision. The user picked this from a proactive suggestion list.
DECISION TYPE: draft_reply
TITLE: Reply: Q2 Roadmap Review tomorrow - please RSVP
CONFIDENCE: 0.85
WHY THIS SURFACED:
- Source: gmail:email
- Subject: Q2 Roadmap Review tomorrow - please RSVP
MEMORY REFS (openloomi-memory):
- people/sarah_chen.md (3 prior interactions)
- insights/insight_abc (related deadline discussion from last week)
SOURCE SIGNAL (gmail:email):
{ ...payload... }
SUGGESTED ACTION:
{ "kind": "email_reply", "params": { ... } }
Execute this action now. Steps:
1. Confirm what you're about to do in one line.
2. Take the action (read files, draft replies, update tasks — whatever the action calls for).
3. When done, summarize in 3 bullets: what changed, what was written to memory, follow-ups.
4. If any step is destructive or sends externally, STOP and ask the user to confirm before continuing.
For any new people or insights discovered, use the openloomi-memory skill:
node $OPENLOOMI_MEMORY_DIR/scripts/openloomi-memory.cjs add-memory ...
node $OPENLOOMI_MEMORY_DIR/scripts/openloomi-memory.cjs add-insight ...
…then hands that prompt to whichever agent runtime is configured. There are three interchangeable surfaces — pick based on what you have installed and where the loop is running:
Surface A — openloomi native agent API (default, no install required)
The Loop's default execution surface is the native agent API served by the same openloomi desktop app / local server that the locomo benchmark hits: POST http://127.0.0.1:3414/api/native/agent (cloud override: https://app.alloomi.ai/api/native/agent). It accepts a prompt, drives the underlying model with tool use + memory reads/writes + multi-round reasoning, and streams the answer back as Server-Sent Events.
Authentication uses the same JWT the desktop app stores locally:
TOKEN=$(cat ~/.openloomi/token | base64 -d)
PROMPT='You are executing an openloomi Loop decision …'
curl -sNX POST http://127.0.0.1:3414/api/native/agent \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg p "$PROMPT" '{prompt: $p, provider: "claude"}')"
Response is an SSE stream, one data: {…} event per line. Loop the response, pick the events you care about:
Event type |
Meaning |
|---|---|
session |
First event — carries sessionId and messageId. Save it for tracing. |
reasoning |
Model's chain-of-thought / scratchpad (optional, can be filtered out). |
text |
The model's user-visible reply (content is the chunk). Concatenate all text chunks. |
tool_use |
Model invoked a tool (the agent API drives tool calls server-side, you don't have to handle them). |
tool_result |
Tool returned. Loop can ignore — the agent API chains it back into the model. |
done |
Final event — model finished, stream complete. |
A minimal parser that just collects the user-visible answer:
const lines = responseText.split('\n');
let answer = '';
for (const line of lines) {
if (!line.startsWith('data:')) continue;
const raw = line.slice(5).trim();
if (!raw || raw === '[DONE]') continue;
const evt = JSON.parse(raw);
if (evt.type === 'text' && evt.content) answer += evt.content;
}
Why this is the default: zero install, identical auth to the desktop app, true agentic behavior (tools + memory + multi-round), SSE streaming, works inside any sandbox, runs from cron / CI without extra config. The Loop's run <id> defaults to this surface and uses the same provider/model the rest of openloomi picks.
Limitations: the model and tool policy are server-side; you don't get to swap them mid-request. If you need a different provider (Anthropic direct, OpenAI, Gemini), use Surface B with a spawned CLI agent that already speaks that provider's API.
Surface B — Spawned CLI agent (configurable via LOOP_AGENT_BIN)
When a decision genuinely needs a stateful, tool-using agent — long-running shell sessions, persistent file edits, multi-round MCP — the Loop can shell out to any CLI that accepts a prompt on stdin / -p:
LOOP_AGENT_BIN=claude node $SKILL_DIR/scripts/openloomi-loop.cjs run dec_abc # default
LOOP_AGENT_BIN=codex node $SKILL_DIR/scripts/openloomi-loop.cjs run dec_abc # OpenAI Codex
LOOP_AGENT_BIN=aider node $SKILL_DIR/scripts/openloomi-loop.cjs run dec_abc # Aider
LOOP_AGENT_BIN=/opt/my-agent/bin/mybot node …/run dec_abc # any custom binary
The spawned binary's stdio is inherited so you see its output directly. On exit, the decision is moved to done (exit 0) or dismissed (non-zero). Memory writeback happens inside the spawned agent itself via openloomi-memory.
This surface costs a binary install and per-tick spawn overhead. Use it only when Surface A can't drive the decision (e.g. the action requires terminal commands the API doesn't expose).
Surface C — In-session (the parent does the work)
When run <id> is invoked from inside an agent session that already has tools loaded (Claude Code, Cursor, etc.), the cleanest path is to not spawn anything — read the built prompt with --dry, then have the parent session call the tools itself. See the Recommended pattern (no bypass needed) section below.
This surface has no install requirement, no API key, and no spawn cost — the user is already paying for the parent's context.
Choosing a runtime
| Concern | Pick |
|---|---|
| No agent CLI installed; user only has openloomi | Surface A (openloomi AI API) |
| Decision needs terminal / shell / file-system access | Surface B (spawned agent with shell tools) |
| Decision needs persistent multi-round MCP tool use | Surface B (spawned Claude/codex/aider) |
| User is already inside Claude Code / Cursor / another agent | Surface C (in-session) |
| Running headless (cron, CI, container) with no agent installed | Surface A (openloomi AI API) |
| Need full transparency / want to see the agent "think" | Surface B with --verbose |
| Need to handle destructive actions safely (extra confirm gate) | Surface A (cleanest for a single confirmation round) |
On exit, the decision moves to done or dismissed. Any memory writeback happens in whatever runtime handled the call — for Surface A, the Loop itself POSTs back to openloomi-memory after the AI returns; for Surface B/C, the runtime does the writeback.
Use run <id> --dry to print the prompt without invoking any runtime.
⚠️ Known issue: Surface B (spawned agent) refuses to nest
When the Loop is being driven from inside another agent session that uses the same LOOP_AGENT_BIN (e.g. the user invokes loop run <id> from inside Claude Code, or the Web UI's ▶ RUN button is clicked while Claude Code is the parent), Surface B aborts with the agent's own nested-session error. For Claude Code that looks like:
Error: Claude Code cannot be launched inside another Claude Code session.
Nested sessions share runtime resources and will crash all active sessions.
To bypass this check, unset the CLAUDECODE environment variable.
Two caveats even with the bypass:
- Do NOT bypass blindly. If the inner agent exits, errors, or hangs, it can corrupt the parent's stdio / shared resources. The CLI's own check is correct.
- Failed spawn is recorded against the decision. The loop marks the decision as
dismissedwithresult=nulland increments the failure counter (failed (1/null)in the run log), even though no action was actually attempted.
Recommended pattern (no bypass needed):
Switch surfaces instead of bypassing. If you're already inside an agent session, you don't need Surface B at all — use Surface C (in-session) or Surface A (openloomi AI API) instead.
-
loop run <id> --dry→ read the built prompt and the suggestedaction.kind/action.params. -
Take the action in the parent agent session itself (Surface C), by calling Composio via whichever surface is loaded. Pick one — don't try all three:
action.kindMCP call (if loaded) composio-cliSkill (if no MCP)composioCLI (if neither)calendar_rsvpmcp__composio__COMPOSIO_MULTI_EXECUTE_TOOL→GOOGLECALENDAR_PATCH_EVENT(event_id,calendar_id="primary",rsvp_response="accepted|declined|tentative",send_updates="none"to skip attendee spam)Skill composio-cli→ "execute GOOGLECALENDAR_PATCH_EVENT on googlecalendar with …"Bash(composio googlecalendar patch_event --json '{…}')email_replymcp__composio__COMPOSIO_MULTI_EXECUTE_TOOL→GMAIL_SEND_EMAIL(orGMAIL_CREATE_DRAFTfor review-first)Skill composio-cli→ "execute GMAIL_SEND_EMAIL on gmail with …"Bash(composio gmail send_email --json '{…}')github_reviewmcp__composio__COMPOSIO_MULTI_EXECUTE_TOOL→GITHUB_CREATE_REVIEW/GITHUB_ADD_REVIEW_COMMENTSkill composio-cli→ "execute GITHUB_CREATE_REVIEW on github with …"Bash(composio github create_review --json '{…}')slack_replymcp__composio__COMPOSIO_MULTI_EXECUTE_TOOL→SLACK_SEND_MESSAGE(channel,text,thread_ts)Skill composio-cli→ "execute SLACK_SEND_MESSAGE on slack with …"Bash(composio slack send_message --json '{…}')todomcp__composio__COMPOSIO_MULTI_EXECUTE_TOOL→GITHUB_UPDATE_ISSUE(assign / label)Skill composio-cli→ "execute GITHUB_UPDATE_ISSUE on github with …"Bash(composio github update_issue --json '{…}')Tag the result with
via: '<mcp|skill|cli>_in_session_api_call'so the decision log captures which surface executed it. -
Confirm the action landed (e.g. fetch the event / PR / message again and check the new state).
-
Move the decision to
donemanually — there's no CLI subcommand for it, but it's onenodecall againstdecisions.json:const fs = require('fs'); const p = '/path/to/openloomi-loop/data/decisions.json'; // the path `loop status` prints const d = JSON.parse(fs.readFileSync(p, 'utf8')); const id = 'dec_xxxxxxxxx'; const item = (d.dismissed || []).splice( (d.dismissed || []).findIndex(x => x.id === id), 1 )[0] || (d.pending || []).splice( (d.pending || []).findIndex(x => x.id === id), 1 )[0]; item.status = 'done'; item.result = { action: '<kind>', ...item.action.params, via: 'in_session_api_call' }; item.completed_at = new Date().toISOString(); d.done.unshift(item); fs.writeFileSync(p, JSON.stringify(d, null, 2));
When forcing Surface B IS acceptable: only when the parent agent session is throwaway (e.g. a claude -p "…" one-shot from a shell, or a CI job that doesn't need the parent anymore). Not acceptable from inside an interactive Claude Code session that the user wants to keep.
Sources
The Loop accepts signals from four surfaces — three Composio-shaped (pick whichever the runtime supports) plus a manual drop folder. They're tried in priority order and produce the same signal envelope, so downstream code is identical.
| # | Source | What it pulls | When enabled | Transport |
|---|---|---|---|---|
| 1 | Composio MCP | mcp__composio__COMPOSIO_MANAGE_CONNECTIONS lists registered toolkits; mcp__composio__COMPOSIO_MULTI_EXECUTE_TOOL calls them in parallel. Pulls connected toolkits: gmail, googlecalendar, github, slack, etc. |
Whenever the user has connected toolkits via Composio and the MCP server is loaded in this Claude Code session. | Structured args, no shell — fastest. |
| 2 | Composio Skill (composio-cli) |
Claude invokes Skill composio-cli … to discover tools, connect accounts, and execute actions. Same toolkit coverage as MCP. |
When MCP isn't loaded but the composio-cli skill is installed in the Claude Code session. |
Skill-mediated — loads schemas on demand. |
| 3 | Composio CLI | Bash(composio <toolkit> <action> --json …) shells out to the installed composio binary. Same toolkit coverage. |
Headless contexts (cron, Surface A scheduled runs, CI, container) where neither MCP nor the skill is available. Most portable. | Subprocess spawn + JSON parse per call. |
| 4 | openloomi-memory insights (list-insights) |
Pre-extracted summaries for channels synced by openloomi-connectors (gmail, slack, telegram, whatsapp, etc.). Synthesized into signal-shape payloads; the existing classifier handles gmail/slack and drops everything else. |
Always on. Kicks in only when no Composio surface is connected for that channel. | File-backed — no external auth needed. |
| 5 | Obsidian vault (OBSIDIAN_VAULT env) |
Recursively scans a local Obsidian vault for .md files whose mtime changed since the last tick. Emits one obsidian_note_changed signal per changed file (capped at OBSIDIAN_VAULT_CAP, default 50). |
When OBSIDIAN_VAULT is set. Tauri / desktop reads the path directly; browser web view reads from a FileSystemDirectoryHandle persisted in IndexedDB. |
Filesystem — desktop: @tauri-apps/plugin-fs; web: File System Access API; headless: node:fs. |
| — | openloomi-memory (mcp__* or CLI) |
Memory reads/writes for enrichment. | Always — required for proper context. | Skill / CLI. |
| — | data/inbox/*.json | Manual drop folder (or any non-Composio bridge script). | Default enableSources.file: true. |
Filesystem. |
The Loop tries sources in the order 1 → 2 → 3 → 4 for each requested channel and uses the first one that returns data. This means: if MCP works, it always wins; if MCP is missing, composio-cli Skill picks up; if both are missing (e.g. cron), composio CLI runs; if no Composio surface is connected at all, the openloomi-memory insights fallback kicks in.
Inject format
{
"source": "gmail",
"type": "email",
"payload": {
"from": "Sarah Chen <sarah@acme.com>",
"subject": "Q2 Roadmap Review",
"snippet": "...",
"threadId": "t1",
"labels": ["INBOX", "IMPORTANT"]
}
}
Or for calendar:
{
"source": "google_calendar",
"type": "calendar_event",
"payload": {
"eventId": "evt1",
"title": "Design review with Jamie",
"start": "2026-06-26T15:00:00Z",
"my_response": "needsAction"
}
}
Configuration
loop config get
loop config set intervalSec 600 # for `loop schedule` (10 min)
loop config set noReplySkip false
loop config set enableSources.file false
Defaults:
{
"intervalSec": 600,
"maxSignals": 5000,
"maxDecisions": 500,
"autoRun": false,
"enableSources": { "composio": true, "openloomi": true, "file": true },
"noReplySkip": true,
"promotionSkip": true
}
Environment variables
| Var | Default | Effect |
|---|---|---|
LOOP_AGENT_RUNTIME |
api |
Which Execute surface to use. api = Surface A (openloomi AI API). cli = Surface B (spawned CLI agent from LOOP_AGENT_BIN). auto = Surface A if reachable, else Surface B. |
LOOP_AGENT_BIN |
claude |
Binary invoked for Surface B (schedule, run, tick). Any CLI that accepts a prompt on -p works — claude, codex, aider, custom. |
LOOP_AGENT_TIMEOUT_MS |
900000 (15 min) |
Hard timeout for one Surface B child. On timeout: SIGTERM → 5s grace → SIGKILL. Prevents a hung tick from blocking notifications. |
LOOP_AGENT_SAFE_PERMISSIONS |
(unset) | Set to 1 to opt out of --dangerously-skip-permissions for the spawned Claude child. Default adds it so the tick can call mcp__composio__* and the openloomi CLIs without per-call prompts. Ticks are read/derive only — no email sends, no RSVPs, no dismisses — so the flag is safe. Ignored on non-Claude agents. |
LOOP_NATIVE_AGENT_URL |
http://127.0.0.1:3414 |
Base URL for Surface A (openloomi native agent API). Loop appends /api/native/agent. Override to https://app.alloomi.ai for cloud. |
LOOP_NATIVE_AGENT_PROVIDER |
claude |
provider field sent in the Surface A request body. The server picks the matching model + auth. |
LOOP_NATIVE_AGENT_TIMEOUT_MS |
2400000 (40 min) |
HTTP timeout for one Surface A request. The native agent is multi-round; 40 min mirrors the locomo benchmark default so long tool-use chains don't get cut off. Drop to 60–120 s if you want fail-fast on stuck decisions. |
LOOP_OPENLOOMI_TOKEN |
~/.openloomi/token |
Path to the base64-encoded JWT used to authenticate Surface A. Override only for testing / multi-account setups. The desktop app writes this on login; the Loop reads it on each request. |
LOOP_WEB_PORT |
3614 | Default port for loop web. CLI / LOOP_WEB_PORT defaults to 3614, which conflicts with the openloomi desktop app's Next.js server on the same port. loop-ctl.sh defaults to 3614 to avoid that clash; override per-call with --port N or this env var. |
LOOP_NOTIFY_WEBHOOK |
(unset) | If set, every notification also POSTs a Slack-compatible JSON payload to this URL. |
OBSIDIAN_VAULT |
(unset) | Absolute path to the user's local Obsidian vault. When set, each tick scans the vault for .md files whose mtime changed since the last scan and emits one obsidian_note_changed signal per change into data/signals.jsonl. The Tauri desktop app reads the path directly; the browser web view uses a FileSystemDirectoryHandle persisted in IndexedDB (picked via Settings → Obsidian vault). Skip the step entirely by leaving this unset. |
OBSIDIAN_VAULT_EXT |
.md |
Comma-separated extensions the scanner treats as Obsidian notes. Change to .md,.markdown if you also keep raw .markdown files. |
OBSIDIAN_VAULT_CAP |
50 |
Max obsidian_note_changed signals per tick. Beyond this the scanner emits a single obsidian_scan_overflow signal and drops the rest. Prevents a fresh clone of the vault from swamping the queue. |
OBSIDIAN_VAULT_RECURSIVE |
1 |
Set to 0 to scan only the vault root (skip subdirectories). Default recurses while skipping hidden and node_modules directories. |
Watch independence + --seen-init
loop schedule runs two independent timers: one for ticks (--interval, default 600s) and one for watching (--watch-interval, default 5s). A hung tick can never block notifications — the watch loop polls data/decisions.json every 5s regardless.
If you want to re-fire notifications for everything currently pending (e.g. after fixing a bug in the notification path, or to demo it), run loop analyze --seen-init. This clears data/notifications.seen.json, and the next watch poll — even on a running loop schedule / loop watch — will treat every current pending decision as new.
Data Layout
$SKILL_DIR/data/ # managed by the loop skill
├── daemon.pid # current `loop schedule` PID (if running)
├── daemon.log # append-only log
├── watch.session.json # { pid, started_at, host } — written by `loop watch`/`schedule` on start; used by `loop summary` to scope "this session" reports
├── notifications.log # append-only notification audit trail; lines tagged `[pid=X started=Y]` from the current watch
├── notifications.seen.json # dedupe: which decision IDs have already fired
├── status.json # last tick snapshot
├── config.json # config
├── decisions.json # { pending: [], done: [], dismissed: [] }
├── signals.jsonl # append-only signal log (capped at maxSignals)
└── inbox/ # drop folder for manual signal injection
├── *.json # new signals to ingest
├── .processed/ # ingested files
└── .failed/ # malformed files
~/.openloomi/data/memory/ # managed by openloomi-memory
├── people/ # { email-sanitized }.md (auto-grown by tick)
├── projects/ # { title-sanitized }.md
├── chats/, channels/, notes/, strategy/
└── ... (full set in openloomi-memory SKILL.md)
The loop skill does not write to ~/.openloomi/data/memory/ directly — it delegates to the openloomi-memory CLI which handles filesystem layout, naming, and idempotency.
Examples
End-to-end demo (no Composio needed)
# Clean state
rm -f $SKILL_DIR/data/decisions.json $SKILL_DIR/data/signals.jsonl
# Drop 3 signals
for s in \
'{"source":"gmail","type":"email","payload":{"from":"Sarah <sarah@acme.com>","subject":"Q2 review tomorrow please RSVP","labels":["INBOX"]}}' \
'{"source":"google_calendar","type":"calendar_event","payload":{"eventId":"e1","title":"Design review with Jamie","my_response":"needsAction"}}' \
'{"source":"github","type":"github_pr","payload":{"repo":"x/y","number":42,"title":"Refactor auth","state":"open","user_is_reviewer":true}}'
; do echo "$s" | loop inject -; done
# Analyze (lib-level, no memory enrichment)
loop analyze
# Browse queue
loop inbox
# Pick the first one
loop run $(loop inbox | grep -oE 'dec_[a-z0-9]+' | head -1)
# Memory peek (delegates to openloomi-memory)
loop memory search-all "Sarah"
Periodic background ticks (cron / launchd)
# Foreground loop (Ctrl+C to stop). Defaults to Surface A (openloomi native agent API).
loop schedule --interval 600 # tick every 10 minutes
# Or one-shot via launchd / cron, every 10 min — Surface A, no agent install needed:
*/10 * * * * /usr/local/bin/node $SKILL_DIR/scripts/openloomi-loop.cjs tick --json \
| xargs -I{} curl -sNX POST $LOOP_NATIVE_AGENT_URL/api/native/agent \
-H "Authorization: Bearer $(cat ~/.openloomi/token | base64 -d)" \
-H "Content-Type: application/json" -d '{}'
# Or, if you have the `claude` CLI installed and want Surface B:
LOOP_AGENT_RUNTIME=cli LOOP_AGENT_BIN=claude loop schedule --interval 600
REPL session
loop serve
# loop> list
# loop> run dec_xxx
# loop> dismiss dec_yyy
# loop> analyze
# loop> status
# loop> quit
Activity report — "what did I receive this session?"
# Default = current watch session window (uses data/watch.session.json)
loop summary
# scope: current session (pid=26402 started=2026-06-25T11:50:22.290Z)
# batches: 1
# notified: 1 decisions
# types:
# review_pr 1
#
# batches:
# 2026-06-25T11:50:43.304Z [pid=26402] 1 new
# Custom window — everything since 09:00 today
loop summary --since=2026-06-25T09:00:00Z
# Batches tagged [pid=X] are from a known watch process; [pid=?]
# marks pre-session (historical) entries from before session tracking existed.
Adding a new tool's signals
- Append a normalized payload to
data/signals.jsonl(one JSON object per line). - Add a classifier branch in
loop-lib.cjs → classify()if the new signal type warrants a new decisiontype. - Extend the tick prompt in
loop-tick.cjsto teach Claude how to fetch from the new toolkit via the available Composio surface (MCP →composio-cliskill →composioCLI → openloomi-memory insights fallback, in that order).
Extending Signals, Decisions, and Actions
The Loop has three independent extension axes. You can add any one of them without touching the other two — but a new signal usually needs all three wired up before it can reach a user-actionable button.
Mental model
raw event (Gmail / Calendar / GitHub / your-own-bridge)
│
▼ normalize
┌─────────┐
│ signal │ ← what the world said
└────┬────┘
│ classifier (loop-lib.cjs → classify())
▼ hard contract: every survivor becomes a decision
┌──────────┐
│ decision │ ← what we should do
└────┬─────┘
│ executor (run <id> → buildPrompt() → agent runtime: API | CLI | in-session)
▼ action.kind tells the executor which Composio tool to call
┌────────┐
│ action │ ← what we actually did
└────────┘
- Signal = a normalized JSON envelope on
data/signals.jsonl. Source-agnostic. - Decision =
{ type, title, action: { kind, params }, memory_refs, confidence, ... }queued indata/decisions.json. Always derived from a signal. - Action =
{ kind, params }— the typed instruction the executor runs. Decoupled from the decisiontype(one decision type can map to several actions over time).
1. Add a new signal source (channel)
A "signal source" is any path that emits a normalized JSON envelope into data/signals.jsonl (or data/inbox/*.json). Four concrete paths — three are Composio surfaces (pick whichever the runtime supports), plus a manual escape hatch:
| Path | Surface | Where to wire it |
|---|---|---|
| Composio MCP | mcp__composio__* tools (Claude Code MCP client) |
Teach the agentic tick prompt in loop-tick.cjs to call mcp__composio__COMPOSIO_MULTI_EXECUTE_TOOL for the new toolkit, and synthesize the result into the signal shape below. |
| Composio Skill | composio-cli skill (Claude invokes Skill composio-cli …) |
Same destination — different transport. The tick prompt should treat Skill composio-cli <action> as equivalent to the MCP COMPOSIO_MULTI_EXECUTE_TOOL call. Use this when the MCP server isn't loaded but the composio-cli skill is installed. |
| Composio CLI | composio binary in $PATH |
The tick prompt runs Bash(composio <toolkit> <action> --json …) and parses stdout. Useful when neither MCP nor the skill is available — e.g. headless CI, Surface A scheduled cron runs, or a stripped-down container. |
| openloomi-memory insights | list-insights CLI |
No code change needed — list-insights --channel=<name> --days=N is already a fallback. Just make sure the channel is synced via openloomi-connectors. The fallback synthesizer in loop-tick.cjs will map insights to <channel>_message signals automatically. |
| Manual / bridge script | file drop or loop inject - |
Drop a .json file into data/inbox/ (or loop inject - on stdin). Anything that writes to data/signals.jsonl directly is also fine — dedupe keys (messageId / eventId / ts / _insightId) prevent double-insert. |
Choosing between the three Composio surfaces
All three return the same Composio tool result — they differ only in transport:
| Surface | Best when… | Cost / latency |
|---|---|---|
MCP (mcp__composio__*) |
MCP server is loaded in the current Claude Code session. Fastest (no shell, structured args). | One MCP handshake per session. |
composio-cli Skill |
MCP server is not loaded but the skill is installed. Useful for portable / project-local setups. | Slightly slower — skill loads schemas on demand. |
composio CLI |
Headless contexts (cron, Surface A scheduled runs, CI), no MCP server, no skill installed. Most portable. | Slowest — subprocess spawn + JSON parse per call. Prefer parallel batches. |
The tick prompt should try them in this order: MCP → composio-cli skill → composio CLI → openloomi-memory insights fallback, stopping at the first that returns data. Don't try all four blindly — pick the highest-fidelity one available.
Required envelope shape — every signal must have at minimum:
{
"source": "gmail", // channel id (free-form, used for log grouping)
"type": "email", // semantic type the classifier branches on
"payload": { "from": "...", "subject": "..." /* channel-specific */ },
"ts": "2026-06-30T08:00:00Z", // ISO timestamp, used for ordering + dedupe
"messageId": "gmail:abc123", // dedupe key (or eventId / ts)
"_origin": "composio" // "composio" | "insights" | "inbox" (optional)
}
Conventions:
sourceis the channel (toolkit / provider).typeis the semantic kind within that channel. A new source can reuse an existingtype(e.g. atrellosource withtype: "trello_message"); the classifier only branches ontype.- If a dedupe key isn't natural (RSS, scrape, manual drop), set
_insightIdto a stable hash of the payload — the dedupe code accepts it as a fallback. - The hard-rule filters in
loop-lib.cjs → isHardSkipped()are currently Gmail-flavored. New channels should extend that function (or run their own pre-filter before appending) sonoreply@*/mailer-daemon/ etc. are skipped at the signal level, not the decision level.
Obsidian vault (optional)
When OBSIDIAN_VAULT is set, each tick runs scripts/obsidian-scan.cjs as a fifth signal source (after the Composio path runs, before classify). The scanner:
- Recursively walks the vault (Tauri:
@tauri-apps/plugin-fs readDir+stat; browser:FileSystemDirectoryHandle.entries(); headless:node:fs) and filters to the configured extensions (default.md). - Diffs each entry's
mtimeMsagainstdata/obsidian.state.json. The state file is rewritten every tick with the new mtime map so the next tick only emits signals for files that actually changed. - For each change, appends one NDJSON line to
data/signals.jsonlwithsource: "obsidian",type: "obsidian_note_changed", andpayload: { path, mtime_ms, size, vault }. The path is slash-normalized and relative to the vault root (e.g.ideas/onboarding_redesign.md), so memory lookups by path are portable. - Caps per-tick emissions at
OBSIDIAN_VAULT_CAP(default 50) and emits a singleobsidian_scan_overflowsignal with the dropped count if exceeded — protects the queue from a fresh clone of a large vault.
The vault itself is not a Composio toolkit — it's a local filesystem adapter, which is what makes the multi-source "product-research iteration" scenario (Linear + GitHub + Obsidian) possible without adding another cloud dependency. On Tauri the path is read directly (no extra permissions beyond the app's existing scope). On the browser, the user picks the directory once via Settings → Obsidian vault; the resulting FileSystemDirectoryHandle is persisted in IndexedDB so the next tick reads without re-prompting. Safari and Firefox do not currently grant persistent directory access — the scanner silently skips in those browsers, the rest of the tick is unaffected.
The classifier maps each obsidian_note_changed signal to a typed decision based on its path prefix:
| Path prefix | Decision type |
|---|---|
projects/, plans/ |
release_plan |
people/ |
todo (action contact_update) |
customers/ |
requirement_synthesis |
ideas/, drafts/, (other) |
doc_update |
The enrich step then reads up to ~2 KB of each changed note via the same PlatformFileSystem.readFile interface and indexes it into openloomi-memory keyed by path — so future linear_review / requirement_synthesis cards can look up people/sarah_chen.md, projects/q2_roadmap.md, ideas/onboarding_redesign.md by path to fold the same evidence into typed decisions.
2. Add a new decision type
A decision type is the user-facing label ("review a PR", "RSVP to a meeting"). Adding one has three touch points:
(a) The classifier branch — scripts/loop-lib.cjs → classify(signal) must return an object of this shape:
{
type: '<decision_type>', // NEW — e.g. 'merge_pr', 'archive_email'
title: 'Human-readable line', // shown in inbox, web UI cards, notifications
action: { kind: '<action_kind>', params: { ... } }, // see §3
memory_refs: [ /* optional, populated by the agentic tick */ ],
confidence: 0.85 | 0.60,
}
Returning null from classify() means "I don't know what to do with this signal" — the tick should fall back to queuing a { type: 'unknown', reason: 'no_matching_action' } decision so the human sees it. Returning nothing / throwing breaks the signal → decision contract and must be avoided.
(b) The decision type table — update these so the new type renders and gets dispatched correctly:
| File | What to add |
|---|---|
scripts/loop-lib.cjs |
A case '<your_type>': branch in classify() (or guard in the relevant signal-type branch). |
SKILL.md (this file) |
A row in the Decision Types table (Type / Trigger / Action columns). |
scripts/loop-web.cjs (UI) |
A hex color in TC, a label in TL, and a .t-<type> CSS class — see the Design system — Ink & Circuit section. The 5 default colors are amber / green / blue / purple / red; pick a new one and document the mapping. |
scripts/loop-tick.cjs |
If the new type needs different enrichment logic (e.g. look up labels, not people), extend the tick prompt that teaches Claude how to enrich this type. |
(c) The hard contract reminder — per §"Data flow per tick", every signal that survives hard filters must produce a decision. So when you add a classifier branch, you also implicitly accept responsibility for handling all the signals that match it. If your branch covers 90% and silently drops 10%, fix the branch — don't add a "skip silently" path.
3. Add a new action kind
An action kind is the executable verb the run prompt tells Claude to perform. It is intentionally decoupled from the decision type: one decision type can dispatch to multiple action kinds over its lifetime, and one action kind can be triggered by several decision types.
Where action kinds live:
| Layer | What it does | Where to edit |
|---|---|---|
| Decision envelope | Carries { action: { kind, params } } so the executor knows what to run. |
Set in classify() (or by the agentic tick via ingest-decision). |
| Executor prompt | scripts/openloomi-loop.cjs → buildPrompt(dec) embeds kind / params into the prompt that gets sent to the agent runtime (openloomi AI API by default; spawned CLI agent via LOOP_AGENT_BIN; in-session when called from a parent agent). |
The default prompt already reads dec.action.kind and dec.action.params and instructs the runtime to dispatch. Custom per-kind prompts go here. |
| In-session fallback | The "Recommended pattern (no bypass needed)" section describes how to handle a decision in the parent Claude session: call Composio via whichever surface is loaded (mcp__composio__COMPOSIO_MULTI_EXECUTE_TOOL, Skill composio-cli …, or Bash(composio …)) with the right tool slug and arguments, then mark the decision done manually. |
No code change needed — just document the Composio tool slug + required params in this file so the executor (whether spawned or in-session) knows what to call. |
To add a new action kind, do this:
- Define the verb. Choose a
kindstring (snake_case, stable — changing it is a breaking change for existing queued decisions). Document it in a new row below. - Define the params. What does the executor need?
eventIdfor an RSVP,repo+numberfor a PR review, etc. Keep params JSON-serializable and Composio-call-shaped. Anything the executor can't reduce to a tool call belongs in the decision'scontext, not inparams. - Wire it into the run prompt. Either:
- Rely on the default
buildPrompt()output (it already says "take the action the action calls for") if the kind maps 1:1 to a Composio tool, or - Add a per-kind prompt section in
buildPrompt()if the kind needs special handling (multi-step, confirmation gates, side effects to record). The prompt should instruct the executor to try MCP →composio-cliskill →composioCLI in that order, same as Pull.
- Rely on the default
- Document the executor path. Add a row to the table below so both humans and future-Claude know which Composio tool slug handles this kind, across all three surfaces.
Current action kinds
action.kind |
Composio tool slug | MCP call | composio-cli Skill call |
composio CLI call |
Agent runtime dispatch (any of 3 surfaces) |
|---|---|---|---|---|---|
calendar_rsvp |
GOOGLECALENDAR_PATCH_EVENT |
mcp__composio__COMPOSIO_MULTI_EXECUTE_TOOL({ tool_slug, arguments: { event_id, calendar_id: "primary", rsvp_response, send_updates } }) |
Skill composio-cli → "execute GOOGLECALENDAR_PATCH_EVENT on googlecalendar with …" |
Bash(composio googlecalendar patch_event --json '{…}') |
The runtime reads params.eventId, decides accept/decline, calls via whichever surface is loaded. Works on Surface A (one HTTP roundtrip), Surface B (spawned agent uses the same call), Surface C (parent session calls directly). |
email_reply |
GMAIL_SEND_EMAIL / GMAIL_CREATE_DRAFT |
mcp__composio__COMPOSIO_MULTI_EXECUTE_TOOL({ tool_slug, arguments: { to, subject, body, threadId } }) |
Skill composio-cli → "execute GMAIL_SEND_EMAIL on gmail with …" |
Bash(composio gmail send_email --json '{…}') |
Runtime drafts via params.to / params.subject / params.threadId. Surface A may return the draft text in the AI response and let the Loop POST the actual send to keep destructive sends behind a separate confirm. |
github_review |
GITHUB_CREATE_REVIEW / GITHUB_ADD_REVIEW_COMMENT |
mcp__composio__COMPOSIO_MULTI_EXECUTE_TOOL({ tool_slug, arguments: { repo, number, body, event } }) |
Skill composio-cli → "execute GITHUB_CREATE_REVIEW on github with …" |
Bash(composio github create_review --json '{…}') |
Runtime reads params.repo / params.number. Surface A returns the review body; the Loop (or the runtime) posts it via Composio. |
slack_reply |
SLACK_SEND_MESSAGE |
mcp__composio__COMPOSIO_MULTI_EXECUTE_TOOL({ tool_slug, arguments: { channel, text, thread_ts } }) |
Skill composio-cli → "execute SLACK_SEND_MESSAGE on slack with …" |
Bash(composio slack send_message --json '{…}') |
Runtime uses params.channel / params.ts. |
todo |
GITHUB_UPDATE_ISSUE (assign / label) or local task tracker |
mcp__composio__COMPOSIO_MULTI_EXECUTE_TOOL({ tool_slug, arguments: { repo, number, state, labels } }) |
Skill composio-cli → "execute GITHUB_UPDATE_ISSUE on github with …" |
Bash(composio github update_issue --json '{…}') |
Runtime records against params.title / params.repo / params.number. |
Adding a new kind — checklist
-
kindis snake_case and irreversible (treat it as an API). -
paramsis a flat JSON object — no functions, no Buffers, no callbacks. - Composio tool slug (or in-session API call) is documented in the table above.
- If the kind is destructive (sends, deletes, transfers money), the run prompt must explicitly instruct Claude to STOP and confirm with the user. The default prompt already does this; don't bypass it.
- The decision can still be
done/dismissednormally — no new status required. -
loop run <id> --dryshows a sensible prompt for the new kind (verify by hand).
4. End-to-end extension recipe (worked example)
Suppose you want to add Trello: "when I'm @-mentioned on a Trello card, surface a reply_trello decision that opens the card and drafts a reply."
-
Signal — Trello isn't on Composio out of the box, so wire it as an
openloomi-connectors-synced channel. That makes it appear inopenloomi-memory list-insights --channel=trello. The tick's fallback synthesizer emits{ source: 'trello', type: 'trello_message', payload: { cardId, mentionsMe, text, url } }. Verify withloop inject -first. -
Decision type — pick
type: 'reply_trello'. Add acaseinloop-lib.cjs → classify()keyed offsignal.type === 'trello_message' && p.mentionsMe. Document the new type in the Decision Types table, add a hex color to the web UI'sTCmap, add a label toTL, and add a.t-reply_trelloCSS class. -
Action kind — pick
kind: 'trello_reply'withparams: { cardId, url }. Add a row to the Current action kinds table pointing at the Trello Composio tool slug (or REST call if not on Composio yet). The defaultbuildPrompt()will pick it up; if the reply flow needs multi-step handling (fetch card → fetch comments → post reply), extendbuildPrompt()with a per-kind section. -
Sanity check — drop a fake signal:
echo '{"source":"trello","type":"trello_message","ts":"2026-06-30T08:00:00Z","messageId":"t1","payload":{"cardId":"c1","mentionsMe":true,"text":"@timi thoughts?","url":"https://trello.com/c/c1"}}' \ | loop inject - loop analyze loop inbox # should show 1 reply_trello decision loop run <id> --dry # should show a prompt that mentions trello_reply -
Iterate — once green on
--dry, flip the agentic tick (loop schedule --interval 600) and watch the new decisions flow in.
Hard contracts (don't break these when extending)
- Every survivor signal → a queued decision.
classify()may return a typed decision, or the tick must queue a{ type: 'unknown', reason: 'no_matching_action' }decision. Never let a signal silently disappear. action.kindis an API. Renaming a kind breaks every queued decision that referenced it. Add new kinds; don't repurpose old ones.action.paramsis JSON. No closures, no live objects, no Date instances — the decision may be reloaded days later from disk.- Destructive actions confirm. If a new action kind sends, deletes, transfers, or charges, the spawned executor must ask before acting. The default prompt already does this; preserve the gate when adding per-kind prompt sections.
- Memory is openloomi-memory's job. New signal sources that learn about new people / projects should
add-memory/add-insightvia the openloomi-memory CLI — don't write to~/.openloomi/data/memory/directly from the loop skill.
Hard-Rule Filters (No-AI Decisions)
These run before the LLM classifier and can short-circuit:
| Signal | Outcome |
|---|---|
Sender matches noreply@*, no-reply@*, donotreply@*, mailer-daemon@*, notifications?@* |
Skip |
Gmail label in Promotions, Social, Forums, Updates, Spam |
Skip |
Calendar event already accepted / declined / tentative |
Skip |
| Email already replied | Skip |
This keeps the LLM work small and the suggestion feed high-signal.
Companion Skills
| When you want… | Use |
|---|---|
| API endpoint reference | openloomi-api |
| Connect / manage a platform | openloomi-connectors |
| Search / write memory | openloomi-memory (delegated target for all reads/writes) |
| User-facing product / setup | openloomi-feature-guide |
This skill (openloomi-loop) is the proactive executor — openloomi-memory is the memory layer. The Loop never owns its own memory; it asks openloomi-memory.
Reference
- openloomi website: https://openloomi.ai
- openloomi documents: https://openloomi.ai/docs
- Composio MCP:
mcp__composio__*tools (preferred when loaded) - Composio Skill:
composio-cliskill (Skill composio-cli …, used when MCP server isn't loaded) - Composio CLI:
composiobinary in$PATH(Bash(composio …), used for headless / CI / scheduled runs) - openloomi native agent API (default Execute surface):
POST http://127.0.0.1:3414/api/native/agent— agentic endpoint (tool use, memory, multi-round, SSE streaming), request body{prompt, provider},Authorization: Bearer $(cat ~/.openloomi/token | base64 -d). Cloud:https://app.alloomi.ai/api/native/agent. Same endpoint thebenchmark/locomosuite drives. - openloomi API docs: see the
openloomi-apiskill (Native module/api/native/*, AI module/api/ai/*). - openloomi-memory CLI:
node $SKILL_DIR/../openloomi-memory/scripts/openloomi-memory.cjs <subcommand> - Token:
~/.openloomi/token(base64-encoded JWT)
skills/pptx/SKILL.md
npx skills add melandlabs/openloomi --skill pptx -g -y
SKILL.md
Frontmatter
{
"name": "pptx",
"license": "Proprietary. LICENSE.txt has complete terms",
"description": "Use this skill any time a .pptx file is involved in any way — as input, output, or both. This includes: creating slide decks, pitch decks, or presentations; reading, parsing, or extracting text from any .pptx file (even if the extracted content will be used elsewhere, like in an email or summary); editing, modifying, or updating existing presentations; combining or splitting slide files; working with templates, layouts, speaker notes, or comments. Trigger whenever the user mentions \"deck,\" \"slides,\" \"presentation,\" or references a .pptx filename, regardless of what they plan to do with the content afterward. If a .pptx file needs to be opened, created, or touched, use this skill."
}
PPTX Skill
Quick Reference
| Task | Guide |
|---|---|
| Read/analyze content | python -m markitdown presentation.pptx |
| Edit or create from template | Read editing.md |
| Create from scratch | Read pptxgenjs.md |
Reading Content
# Text extraction
python -m markitdown presentation.pptx
# Visual overview
python scripts/thumbnail.py presentation.pptx
# Raw XML
python scripts/office/unpack.py presentation.pptx unpacked/
Editing Workflow
Read editing.md for full details.
- Analyze template with
thumbnail.py - Unpack → manipulate slides → edit content → clean → pack
Creating from Scratch
Read pptxgenjs.md for full details.
Use when no template or reference presentation is available.
Design Ideas
Don't create boring slides. Plain bullets on a white background won't impress anyone. Consider ideas from this list for each slide.
Before Starting
- Pick a bold, content-informed color palette: The palette should feel designed for THIS topic. If swapping your colors into a completely different presentation would still "work," you haven't made specific enough choices.
- Dominance over equality: One color should dominate (60-70% visual weight), with 1-2 supporting tones and one sharp accent. Never give all colors equal weight.
- Dark/light contrast: Dark backgrounds for title + conclusion slides, light for content ("sandwich" structure). Or commit to dark throughout for a premium feel.
- Commit to a visual motif: Pick ONE distinctive element and repeat it — rounded image frames, icons in colored circles, thick single-side borders. Carry it across every slide.
Color Palettes
Choose colors that match your topic — don't default to generic blue. Use these palettes as inspiration:
| Theme | Primary | Secondary | Accent |
|---|---|---|---|
| Midnight Executive | 1E2761 (navy) |
CADCFC (ice blue) |
FFFFFF (white) |
| Forest & Moss | 2C5F2D (forest) |
97BC62 (moss) |
F5F5F5 (cream) |
| Coral Energy | F96167 (coral) |
F9E795 (gold) |
2F3C7E (navy) |
| Warm Terracotta | B85042 (terracotta) |
E7E8D1 (sand) |
A7BEAE (sage) |
| Ocean Gradient | 065A82 (deep blue) |
1C7293 (teal) |
21295C (midnight) |
| Charcoal Minimal | 36454F (charcoal) |
F2F2F2 (off-white) |
212121 (black) |
| Teal Trust | 028090 (teal) |
00A896 (seafoam) |
02C39A (mint) |
| Berry & Cream | 6D2E46 (berry) |
A26769 (dusty rose) |
ECE2D0 (cream) |
| Sage Calm | 84B59F (sage) |
69A297 (eucalyptus) |
50808E (slate) |
| Cherry Bold | 990011 (cherry) |
FCF6F5 (off-white) |
2F3C7E (navy) |
For Each Slide
Every slide needs a visual element — image, chart, icon, or shape. Text-only slides are forgettable.
Layout options:
- Two-column (text left, illustration on right)
- Icon + text rows (icon in colored circle, bold header, description below)
- 2x2 or 2x3 grid (image on one side, grid of content blocks on other)
- Half-bleed image (full left or right side) with content overlay
Data display:
- Large stat callouts (big numbers 60-72pt with small labels below)
- Comparison columns (before/after, pros/cons, side-by-side options)
- Timeline or process flow (numbered steps, arrows)
Visual polish:
- Icons in small colored circles next to section headers
- Italic accent text for key stats or taglines
Typography
Choose an interesting font pairing — don't default to Arial. Pick a header font with personality and pair it with a clean body font.
| Header Font | Body Font |
|---|---|
| Georgia | Calibri |
| Arial Black | Arial |
| Calibri | Calibri Light |
| Cambria | Calibri |
| Trebuchet MS | Calibri |
| Impact | Arial |
| Palatino | Garamond |
| Consolas | Calibri |
| Element | Size |
|---|---|
| Slide title | 36-44pt bold |
| Section header | 20-24pt bold |
| Body text | 14-16pt |
| Captions | 10-12pt muted |
Spacing
- 0.5" minimum margins
- 0.3-0.5" between content blocks
- Leave breathing room—don't fill every inch
Avoid (Common Mistakes)
- Don't repeat the same layout — vary columns, cards, and callouts across slides
- Don't center body text — left-align paragraphs and lists; center only titles
- Don't skimp on size contrast — titles need 36pt+ to stand out from 14-16pt body
- Don't default to blue — pick colors that reflect the specific topic
- Don't mix spacing randomly — choose 0.3" or 0.5" gaps and use consistently
- Don't style one slide and leave the rest plain — commit fully or keep it simple throughout
- Don't create text-only slides — add images, icons, charts, or visual elements; avoid plain title + bullets
- Don't forget text box padding — when aligning lines or shapes with text edges, set
margin: 0on the text box or offset the shape to account for padding - Don't use low-contrast elements — icons AND text need strong contrast against the background; avoid light text on light backgrounds or dark text on dark backgrounds
- NEVER use accent lines under titles — these are a hallmark of AI-generated slides; use whitespace or background color instead
QA (Required)
Assume there are problems. Your job is to find them.
Your first render is almost never correct. Approach QA as a bug hunt, not a confirmation step. If you found zero issues on first inspection, you weren't looking hard enough.
Content QA
python -m markitdown output.pptx
Check for missing content, typos, wrong order.
When using templates, check for leftover placeholder text:
python -m markitdown output.pptx | grep -iE "xxxx|lorem|ipsum|this.*(page|slide).*layout"
If grep returns results, fix them before declaring success.
Visual QA
⚠️ USE SUBAGENTS — even for 2-3 slides. You've been staring at the code and will see what you expect, not what's there. Subagents have fresh eyes.
Convert slides to images (see Converting to Images), then use this prompt:
Visually inspect these slides. Assume there are issues — find them.
Look for:
- Overlapping elements (text through shapes, lines through words, stacked elements)
- Text overflow or cut off at edges/box boundaries
- Decorative lines positioned for single-line text but title wrapped to two lines
- Source citations or footers colliding with content above
- Elements too close (< 0.3" gaps) or cards/sections nearly touching
- Uneven gaps (large empty area in one place, cramped in another)
- Insufficient margin from slide edges (< 0.5")
- Columns or similar elements not aligned consistently
- Low-contrast text (e.g., light gray text on cream-colored background)
- Low-contrast icons (e.g., dark icons on dark backgrounds without a contrasting circle)
- Text boxes too narrow causing excessive wrapping
- Leftover placeholder content
For each slide, list issues or areas of concern, even if minor.
Read and analyze these images:
1. /path/to/slide-01.jpg (Expected: [brief description])
2. /path/to/slide-02.jpg (Expected: [brief description])
Report ALL issues found, including minor ones.
Verification Loop
- Generate slides → Convert to images → Inspect
- List issues found (if none found, look again more critically)
- Fix issues
- Re-verify affected slides — one fix often creates another problem
- Repeat until a full pass reveals no new issues
Do not declare success until you've completed at least one fix-and-verify cycle.
Converting to Images
Convert presentations to individual slide images for visual inspection:
python scripts/office/soffice.py --headless --convert-to pdf output.pptx
pdftoppm -jpeg -r 150 output.pdf slide
This creates slide-01.jpg, slide-02.jpg, etc.
To re-render specific slides after fixes:
pdftoppm -jpeg -r 150 -f N -l N output.pdf slide-fixed
Dependencies
pip install "markitdown[pptx]"- text extractionpip install Pillow- thumbnail gridsnpm install -g pptxgenjs- creating from scratch- LibreOffice (
soffice) - PDF conversion (auto-configured for sandboxed environments viascripts/office/soffice.py) - Poppler (
pdftoppm) - PDF to images
skills/ui-ux-pro-max/SKILL.md
npx skills add melandlabs/openloomi --skill ui-ux-pro-max -g -y
SKILL.md
Frontmatter
{
"name": "ui-ux-pro-max",
"description": "UI\/UX design intelligence for web and mobile. Includes 50+ styles, 161 color palettes, 57 font pairings, 161 product types, 99 UX guidelines, and 25 chart types across 10 stacks (React, Next.js, Vue, Svelte, SwiftUI, React Native, Flutter, Tailwind, shadcn\/ui, and HTML\/CSS). Actions: plan, build, create, design, implement, review, fix, improve, optimize, enhance, refactor, and check UI\/UX code. Projects: website, landing page, dashboard, admin panel, e-commerce, SaaS, portfolio, blog, and mobile app. Elements: button, modal, navbar, sidebar, card, table, form, and chart. Styles: glassmorphism, claymorphism, minimalism, brutalism, neumorphism, bento grid, dark mode, responsive, skeuomorphism, and flat design. Topics: color systems, accessibility, animation, layout, typography, font pairing, spacing, interaction states, shadow, and gradient. Integrations: shadcn\/ui MCP for component search and examples."
}
UI/UX Pro Max - Design Intelligence
Comprehensive design guide for web and mobile applications. Contains 50+ styles, 161 color palettes, 57 font pairings, 161 product types with reasoning rules, 99 UX guidelines, and 25 chart types across 10 technology stacks. Searchable database with priority-based recommendations.
When to Apply
This Skill should be used when the task involves UI structure, visual design decisions, interaction patterns, or user experience quality control.
Must Use
This Skill must be invoked in the following situations:
- Designing new pages (Landing Page, Dashboard, Admin, SaaS, Mobile App)
- Creating or refactoring UI components (buttons, modals, forms, tables, charts, etc.)
- Choosing color schemes, typography systems, spacing standards, or layout systems
- Reviewing UI code for user experience, accessibility, or visual consistency
- Implementing navigation structures, animations, or responsive behavior
- Making product-level design decisions (style, information hierarchy, brand expression)
- Improving perceived quality, clarity, or usability of interfaces
Recommended
This Skill is recommended in the following situations:
- UI looks "not professional enough" but the reason is unclear
- Receiving feedback on usability or experience
- Pre-launch UI quality optimization
- Aligning cross-platform design (Web / iOS / Android)
- Building design systems or reusable component libraries
Skip
This Skill is not needed in the following situations:
- Pure backend logic development
- Only involving API or database design
- Performance optimization unrelated to the interface
- Infrastructure or DevOps work
- Non-visual scripts or automation tasks
Decision criteria: If the task will change how a feature looks, feels, moves, or is interacted with, this Skill should be used.
Rule Categories by Priority
For human/AI reference: follow priority 1→10 to decide which rule category to focus on first; use --domain <Domain> to query details when needed. Scripts do not read this table.
| Priority | Category | Impact | Domain | Key Checks (Must Have) | Anti-Patterns (Avoid) |
|---|---|---|---|---|---|
| 1 | Accessibility | CRITICAL | ux |
Contrast 4.5:1, Alt text, Keyboard nav, Aria-labels | Removing focus rings, Icon-only buttons without labels |
| 2 | Touch & Interaction | CRITICAL | ux |
Min size 44×44px, 8px+ spacing, Loading feedback | Reliance on hover only, Instant state changes (0ms) |
| 3 | Performance | HIGH | ux |
WebP/AVIF, Lazy loading, Reserve space (CLS < 0.1) | Layout thrashing, Cumulative Layout Shift |
| 4 | Style Selection | HIGH | style, product |
Match product type, Consistency, SVG icons (no emoji) | Mixing flat & skeuomorphic randomly, Emoji as icons |
| 5 | Layout & Responsive | HIGH | ux |
Mobile-first breakpoints, Viewport meta, No horizontal scroll | Horizontal scroll, Fixed px container widths, Disable zoom |
| 6 | Typography & Color | MEDIUM | typography, color |
Base 16px, Line-height 1.5, Semantic color tokens | Text < 12px body, Gray-on-gray, Raw hex in components |
| 7 | Animation | MEDIUM | ux |
Duration 150–300ms, Motion conveys meaning, Spatial continuity | Decorative-only animation, Animating width/height, No reduced-motion |
| 8 | Forms & Feedback | MEDIUM | ux |
Visible labels, Error near field, Helper text, Progressive disclosure | Placeholder-only label, Errors only at top, Overwhelm upfront |
| 9 | Navigation Patterns | HIGH | ux |
Predictable back, Bottom nav ≤5, Deep linking | Overloaded nav, Broken back behavior, No deep links |
| 10 | Charts & Data | LOW | chart |
Legends, Tooltips, Accessible colors | Relying on color alone to convey meaning |
Quick Reference
1. Accessibility (CRITICAL)
color-contrast- Minimum 4.5:1 ratio for normal text (large text 3:1); Material Designfocus-states- Visible focus rings on interactive elements (2–4px; Apple HIG, MD)alt-text- Descriptive alt text for meaningful imagesaria-labels- aria-label for icon-only buttons; accessibilityLabel in native (Apple HIG)keyboard-nav- Tab order matches visual order; full keyboard support (Apple HIG)form-labels- Use label with for attributeskip-links- Skip to main content for keyboard usersheading-hierarchy- Sequential h1→h6, no level skipcolor-not-only- Don't convey info by color alone (add icon/text)dynamic-type- Support system text scaling; avoid truncation as text grows (Apple Dynamic Type, MD)reduced-motion- Respect prefers-reduced-motion; reduce/disable animations when requested (Apple Reduced Motion API, MD)voiceover-sr- Meaningful accessibilityLabel/accessibilityHint; logical reading order for VoiceOver/screen readers (Apple HIG, MD)escape-routes- Provide cancel/back in modals and multi-step flows (Apple HIG)keyboard-shortcuts- Preserve system and a11y shortcuts; offer keyboard alternatives for drag-and-drop (Apple HIG)
2. Touch & Interaction (CRITICAL)
touch-target-size- Min 44×44pt (Apple) / 48×48dp (Material); extend hit area beyond visual bounds if neededtouch-spacing- Minimum 8px/8dp gap between touch targets (Apple HIG, MD)hover-vs-tap- Use click/tap for primary interactions; don't rely on hover aloneloading-buttons- Disable button during async operations; show spinner or progresserror-feedback- Clear error messages near problemcursor-pointer- Add cursor-pointer to clickable elements (Web)gesture-conflicts- Avoid horizontal swipe on main content; prefer vertical scrolltap-delay- Use touch-action: manipulation to reduce 300ms delay (Web)standard-gestures- Use platform standard gestures consistently; don't redefine (e.g. swipe-back, pinch-zoom) (Apple HIG)system-gestures- Don't block system gestures (Control Center, back swipe, etc.) (Apple HIG)press-feedback- Visual feedback on press (ripple/highlight; MD state layers)haptic-feedback- Use haptic for confirmations and important actions; avoid overuse (Apple HIG)gesture-alternative- Don't rely on gesture-only interactions; always provide visible controls for critical actionssafe-area-awareness- Keep primary touch targets away from notch, Dynamic Island, gesture bar and screen edgesno-precision-required- Avoid requiring pixel-perfect taps on small icons or thin edgesswipe-clarity- Swipe actions must show clear affordance or hint (chevron, label, tutorial)drag-threshold- Use a movement threshold before starting drag to avoid accidental drags
3. Performance (HIGH)
image-optimization- Use WebP/AVIF, responsive images (srcset/sizes), lazy load non-critical assetsimage-dimension- Declare width/height or use aspect-ratio to prevent layout shift (Core Web Vitals: CLS)font-loading- Use font-display: swap/optional to avoid invisible text (FOIT); reserve space to reduce layout shift (MD)font-preload- Preload only critical fonts; avoid overusing preload on every variantcritical-css- Prioritize above-the-fold CSS (inline critical CSS or early-loaded stylesheet)lazy-loading- Lazy load non-hero components via dynamic import / route-level splittingbundle-splitting- Split code by route/feature (React Suspense / Next.js dynamic) to reduce initial load and TTIthird-party-scripts- Load third-party scripts async/defer; audit and remove unnecessary ones (MD)reduce-reflows- Avoid frequent layout reads/writes; batch DOM reads then writescontent-jumping- Reserve space for async content to avoid layout jumps (Core Web Vitals: CLS)lazy-load-below-fold- Use loading="lazy" for below-the-fold images and heavy mediavirtualize-lists- Virtualize lists with 50+ items to improve memory efficiency and scroll performancemain-thread-budget- Keep per-frame work under ~16ms for 60fps; move heavy tasks off main thread (HIG, MD)progressive-loading- Use skeleton screens / shimmer instead of long blocking spinners for >1s operations (Apple HIG)input-latency- Keep input latency under ~100ms for taps/scrolls (Material responsiveness standard)tap-feedback-speed- Provide visual feedback within 100ms of tap (Apple HIG)debounce-throttle- Use debounce/throttle for high-frequency events (scroll, resize, input)offline-support- Provide offline state messaging and basic fallback (PWA / mobile)network-fallback- Offer degraded modes for slow networks (lower-res images, fewer animations)
4. Style Selection (HIGH)
style-match- Match style to product type (use--design-systemfor recommendations)consistency- Use same style across all pagesno-emoji-icons- Use SVG icons (Heroicons, Lucide), not emojiscolor-palette-from-product- Choose palette from product/industry (search--domain color)effects-match-style- Shadows, blur, radius aligned with chosen style (glass / flat / clay etc.)platform-adaptive- Respect platform idioms (iOS HIG vs Material): navigation, controls, typography, motionstate-clarity- Make hover/pressed/disabled states visually distinct while staying on-style (Material state layers)elevation-consistent- Use a consistent elevation/shadow scale for cards, sheets, modals; avoid random shadow valuesdark-mode-pairing- Design light/dark variants together to keep brand, contrast, and style consistenticon-style-consistent- Use one icon set/visual language (stroke width, corner radius) across the productsystem-controls- Prefer native/system controls over fully custom ones; only customize when branding requires it (Apple HIG)blur-purpose- Use blur to indicate background dismissal (modals, sheets), not as decoration (Apple HIG)primary-action- Each screen should have only one primary CTA; secondary actions visually subordinate (Apple HIG)
5. Layout & Responsive (HIGH)
viewport-meta- width=device-width initial-scale=1 (never disable zoom)mobile-first- Design mobile-first, then scale up to tablet and desktopbreakpoint-consistency- Use systematic breakpoints (e.g. 375 / 768 / 1024 / 1440)readable-font-size- Minimum 16px body text on mobile (avoids iOS auto-zoom)line-length-control- Mobile 35–60 chars per line; desktop 60–75 charshorizontal-scroll- No horizontal scroll on mobile; ensure content fits viewport widthspacing-scale- Use 4pt/8dp incremental spacing system (Material Design)touch-density- Keep component spacing comfortable for touch: not cramped, not causing mis-tapscontainer-width- Consistent max-width on desktop (max-w-6xl / 7xl)z-index-management- Define layered z-index scale (e.g. 0 / 10 / 20 / 40 / 100 / 1000)fixed-element-offset- Fixed navbar/bottom bar must reserve safe padding for underlying contentscroll-behavior- Avoid nested scroll regions that interfere with the main scroll experienceviewport-units- Prefer min-h-dvh over 100vh on mobileorientation-support- Keep layout readable and operable in landscape modecontent-priority- Show core content first on mobile; fold or hide secondary contentvisual-hierarchy- Establish hierarchy via size, spacing, contrast — not color alone
6. Typography & Color (MEDIUM)
line-height- Use 1.5-1.75 for body textline-length- Limit to 65-75 characters per linefont-pairing- Match heading/body font personalitiesfont-scale- Consistent type scale (e.g. 12 14 16 18 24 32)contrast-readability- Darker text on light backgrounds (e.g. slate-900 on white)text-styles-system- Use platform type system: iOS 11 Dynamic Type styles / Material 5 type roles (display, headline, title, body, label) (HIG, MD)weight-hierarchy- Use font-weight to reinforce hierarchy: Bold headings (600–700), Regular body (400), Medium labels (500) (MD)color-semantic- Define semantic color tokens (primary, secondary, error, surface, on-surface) not raw hex in components (Material color system)color-dark-mode- Dark mode uses desaturated / lighter tonal variants, not inverted colors; test contrast separately (HIG, MD)color-accessible-pairs- Foreground/background pairs must meet 4.5:1 (AA) or 7:1 (AAA); use tools to verify (WCAG, MD)color-not-decorative-only- Functional color (error red, success green) must include icon/text; avoid color-only meaning (HIG, MD)truncation-strategy- Prefer wrapping over truncation; when truncating use ellipsis and provide full text via tooltip/expand (Apple HIG)letter-spacing- Respect default letter-spacing per platform; avoid tight tracking on body text (HIG, MD)number-tabular- Use tabular/monospaced figures for data columns, prices, and timers to prevent layout shiftwhitespace-balance- Use whitespace intentionally to group related items and separate sections; avoid visual clutter (Apple HIG)
7. Animation (MEDIUM)
duration-timing- Use 150–300ms for micro-interactions; complex transitions ≤400ms; avoid >500ms (MD)transform-performance- Use transform/opacity only; avoid animating width/height/top/leftloading-states- Show skeleton or progress indicator when loading exceeds 300msexcessive-motion- Animate 1-2 key elements per view maxeasing- Use ease-out for entering, ease-in for exiting; avoid linear for UI transitionsmotion-meaning- Every animation must express a cause-effect relationship, not just be decorative (Apple HIG)state-transition- State changes (hover / active / expanded / collapsed / modal) should animate smoothly, not snapcontinuity- Page/screen transitions should maintain spatial continuity (shared element, directional slide) (Apple HIG)parallax-subtle- Use parallax sparingly; must respect reduced-motion and not cause disorientation (Apple HIG)spring-physics- Prefer spring/physics-based curves over linear or cubic-bezier for natural feel (Apple HIG fluid animations)exit-faster-than-enter- Exit animations shorter than enter (~60–70% of enter duration) to feel responsive (MD motion)stagger-sequence- Stagger list/grid item entrance by 30–50ms per item; avoid all-at-once or too-slow reveals (MD)shared-element-transition- Use shared element / hero transitions for visual continuity between screens (MD, HIG)interruptible- Animations must be interruptible; user tap/gesture cancels in-progress animation immediately (Apple HIG)no-blocking-animation- Never block user input during an animation; UI must stay interactive (Apple HIG)fade-crossfade- Use crossfade for content replacement within the same container (MD)scale-feedback- Subtle scale (0.95–1.05) on press for tappable cards/buttons; restore on release (HIG, MD)gesture-feedback- Drag, swipe, and pinch must provide real-time visual response tracking the finger (MD Motion)hierarchy-motion- Use translate/scale direction to express hierarchy: enter from below = deeper, exit upward = back (MD)motion-consistency- Unify duration/easing tokens globally; all animations share the same rhythm and feelopacity-threshold- Fading elements should not linger below opacity 0.2; either fade fully or remain visiblemodal-motion- Modals/sheets should animate from their trigger source (scale+fade or slide-in) for spatial context (HIG, MD)navigation-direction- Forward navigation animates left/up; backward animates right/down — keep direction logically consistent (HIG)layout-shift-avoid- Animations must not cause layout reflow or CLS; use transform for position changes
8. Forms & Feedback (MEDIUM)
input-labels- Visible label per input (not placeholder-only)error-placement- Show error below the related fieldsubmit-feedback- Loading then success/error state on submitrequired-indicators- Mark required fields (e.g. asterisk)empty-states- Helpful message and action when no contenttoast-dismiss- Auto-dismiss toasts in 3-5sconfirmation-dialogs- Confirm before destructive actionsinput-helper-text- Provide persistent helper text below complex inputs, not just placeholder (Material Design)disabled-states- Disabled elements use reduced opacity (0.38–0.5) + cursor change + semantic attribute (MD)progressive-disclosure- Reveal complex options progressively; don't overwhelm users upfront (Apple HIG)inline-validation- Validate on blur (not keystroke); show error only after user finishes input (MD)input-type-keyboard- Use semantic input types (email, tel, number) to trigger the correct mobile keyboard (HIG, MD)password-toggle- Provide show/hide toggle for password fields (MD)autofill-support- Use autocomplete / textContentType attributes so the system can autofill (HIG, MD)undo-support- Allow undo for destructive or bulk actions (e.g. "Undo delete" toast) (Apple HIG)success-feedback- Confirm completed actions with brief visual feedback (checkmark, toast, color flash) (MD)error-recovery- Error messages must include a clear recovery path (retry, edit, help link) (HIG, MD)multi-step-progress- Multi-step flows show step indicator or progress bar; allow back navigation (MD)form-autosave- Long forms should auto-save drafts to prevent data loss on accidental dismissal (Apple HIG)sheet-dismiss-confirm- Confirm before dismissing a sheet/modal with unsaved changes (Apple HIG)error-clarity- Error messages must state cause + how to fix (not just "Invalid input") (HIG, MD)field-grouping- Group related fields logically (fieldset/legend or visual grouping) (MD)read-only-distinction- Read-only state should be visually and semantically different from disabled (MD)focus-management- After submit error, auto-focus the first invalid field (WCAG, MD)error-summary- For multiple errors, show summary at top with anchor links to each field (WCAG)touch-friendly-input- Mobile input height ≥44px to meet touch target requirements (Apple HIG)destructive-emphasis- Destructive actions use semantic danger color (red) and are visually separated from primary actions (HIG, MD)toast-accessibility- Toasts must not steal focus; use aria-live="polite" for screen reader announcement (WCAG)aria-live-errors- Form errors use aria-live region or role="alert" to notify screen readers (WCAG)contrast-feedback- Error and success state colors must meet 4.5:1 contrast ratio (WCAG, MD)timeout-feedback- Request timeout must show clear feedback with retry option (MD)
9. Navigation Patterns (HIGH)
bottom-nav-limit- Bottom navigation max 5 items; use labels with icons (Material Design)drawer-usage- Use drawer/sidebar for secondary navigation, not primary actions (Material Design)back-behavior- Back navigation must be predictable and consistent; preserve scroll/state (Apple HIG, MD)deep-linking- All key screens must be reachable via deep link / URL for sharing and notifications (Apple HIG, MD)tab-bar-ios- iOS: use bottom Tab Bar for top-level navigation (Apple HIG)top-app-bar-android- Android: use Top App Bar with navigation icon for primary structure (Material Design)nav-label-icon- Navigation items must have both icon and text label; icon-only nav harms discoverability (MD)nav-state-active- Current location must be visually highlighted (color, weight, indicator) in navigation (HIG, MD)nav-hierarchy- Primary nav (tabs/bottom bar) vs secondary nav (drawer/settings) must be clearly separated (MD)modal-escape- Modals and sheets must offer a clear close/dismiss affordance; swipe-down to dismiss on mobile (Apple HIG)search-accessible- Search must be easily reachable (top bar or tab); provide recent/suggested queries (MD)breadcrumb-web- Web: use breadcrumbs for 3+ level deep hierarchies to aid orientation (MD)state-preservation- Navigating back must restore previous scroll position, filter state, and input (HIG, MD)gesture-nav-support- Support system gesture navigation (iOS swipe-back, Android predictive back) without conflict (HIG, MD)tab-badge- Use badges on nav items sparingly to indicate unread/pending; clear after user visits (HIG, MD)overflow-menu- When actions exceed available space, use overflow/more menu instead of cramming (MD)bottom-nav-top-level- Bottom nav is for top-level screens only; never nest sub-navigation inside it (MD)adaptive-navigation- Large screens (≥1024px) prefer sidebar; small screens use bottom/top nav (Material Adaptive)back-stack-integrity- Never silently reset the navigation stack or unexpectedly jump to home (HIG, MD)navigation-consistency- Navigation placement must stay the same across all pages; don't change by page typeavoid-mixed-patterns- Don't mix Tab + Sidebar + Bottom Nav at the same hierarchy levelmodal-vs-navigation- Modals must not be used for primary navigation flows; they break the user's path (HIG)focus-on-route-change- After page transition, move focus to main content region for screen reader users (WCAG)persistent-nav- Core navigation must remain reachable from deep pages; don't hide it entirely in sub-flows (HIG, MD)destructive-nav-separation- Dangerous actions (delete account, logout) must be visually and spatially separated from normal nav items (HIG, MD)empty-nav-state- When a nav destination is unavailable, explain why instead of silently hiding it (MD)
10. Charts & Data (LOW)
chart-type- Match chart type to data type (trend → line, comparison → bar, proportion → pie/donut)color-guidance- Use accessible color palettes; avoid red/green only pairs for colorblind users (WCAG, MD)data-table- Provide table alternative for accessibility; charts alone are not screen-reader friendly (WCAG)pattern-texture- Supplement color with patterns, textures, or shapes so data is distinguishable without color (WCAG, MD)legend-visible- Always show legend; position near the chart, not detached below a scroll fold (MD)tooltip-on-interact- Provide tooltips/data labels on hover (Web) or tap (mobile) showing exact values (HIG, MD)axis-labels- Label axes with units and readable scale; avoid truncated or rotated labels on mobileresponsive-chart- Charts must reflow or simplify on small screens (e.g. horizontal bar instead of vertical, fewer ticks)empty-data-state- Show meaningful empty state when no data exists ("No data yet" + guidance), not a blank chart (MD)loading-chart- Use skeleton or shimmer placeholder while chart data loads; don't show an empty axis frameanimation-optional- Chart entrance animations must respect prefers-reduced-motion; data should be readable immediately (HIG)large-dataset- For 1000+ data points, aggregate or sample; provide drill-down for detail instead of rendering all (MD)number-formatting- Use locale-aware formatting for numbers, dates, currencies on axes and labels (HIG, MD)touch-target-chart- Interactive chart elements (points, segments) must have ≥44pt tap area or expand on touch (Apple HIG)no-pie-overuse- Avoid pie/donut for >5 categories; switch to bar chart for claritycontrast-data- Data lines/bars vs background ≥3:1; data text labels ≥4.5:1 (WCAG)legend-interactive- Legends should be clickable to toggle series visibility (MD)direct-labeling- For small datasets, label values directly on the chart to reduce eye traveltooltip-keyboard- Tooltip content must be keyboard-reachable and not rely on hover alone (WCAG)sortable-table- Data tables must support sorting with aria-sort indicating current sort state (WCAG)axis-readability- Axis ticks must not be cramped; maintain readable spacing, auto-skip on small screensdata-density- Limit information density per chart to avoid cognitive overload; split into multiple charts if neededtrend-emphasis- Emphasize data trends over decoration; avoid heavy gradients/shadows that obscure the datagridline-subtle- Grid lines should be low-contrast (e.g. gray-200) so they don't compete with datafocusable-elements- Interactive chart elements (points, bars, slices) must be keyboard-navigable (WCAG)screen-reader-summary- Provide a text summary or aria-label describing the chart's key insight for screen readers (WCAG)error-state-chart- Data load failure must show error message with retry action, not a broken/empty chartexport-option- For data-heavy products, offer CSV/image export of chart datadrill-down-consistency- Drill-down interactions must maintain a clear back-path and hierarchy breadcrumbtime-scale-clarity- Time series charts must clearly label time granularity (day/week/month) and allow switching
How to Use
Search specific domains using the CLI tool below.
Prerequisites
Check if Python is installed:
python3 --version || python --version
If Python is not installed, install it based on user's OS:
macOS:
brew install python3
Ubuntu/Debian:
sudo apt update && sudo apt install python3
Windows:
winget install Python.Python.3.12
How to Use This Skill
Use this skill when the user requests any of the following:
| Scenario | Trigger Examples | Start From |
|---|---|---|
| New project / page | "Build a landing page", "Build a dashboard" | Step 1 → Step 2 (design system) |
| New component | "Create a pricing card", "Add a modal" | Step 3 (domain search: style, ux) |
| Choose style / color / font | "What style fits a fintech app?", "Recommend a color palette" | Step 2 (design system) |
| Review existing UI | "Review this page for UX issues", "Check accessibility" | Quick Reference checklist above |
| Fix a UI bug | "Button hover is broken", "Layout shifts on load" | Quick Reference → relevant section |
| Improve / optimize | "Make this faster", "Improve mobile experience" | Step 3 (domain search: ux, react) |
| Implement dark mode | "Add dark mode support" | Step 3 (domain: style "dark mode") |
| Add charts / data viz | "Add an analytics dashboard chart" | Step 3 (domain: chart) |
| Stack best practices | "React performance tips"、"SwiftUI navigation" | Step 4 (stack search) |
Follow this workflow:
Step 1: Analyze User Requirements
Extract key information from user request:
- Product type: Entertainment (social, video, music, gaming), Tool (scanner, editor, converter), Productivity (task manager, notes, calendar), or hybrid
- Target audience: C-end consumer users; consider age group, usage context (commute, leisure, work)
- Style keywords: playful, vibrant, minimal, dark mode, content-first, immersive, etc.
- Stack: React Native (this project's only tech stack)
Step 2: Generate Design System (REQUIRED)
Always start with --design-system to get comprehensive recommendations with reasoning:
python3 skills/ui-ux-pro-max/scripts/search.py "<product_type> <industry> <keywords>" --design-system [-p "Project Name"]
This command:
- Searches domains in parallel (product, style, color, landing, typography)
- Applies reasoning rules from
ui-reasoning.csvto select best matches - Returns complete design system: pattern, style, colors, typography, effects
- Includes anti-patterns to avoid
Example:
python3 skills/ui-ux-pro-max/scripts/search.py "beauty spa wellness service" --design-system -p "Serenity Spa"
Step 2b: Persist Design System (Master + Overrides Pattern)
To save the design system for hierarchical retrieval across sessions, add --persist:
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --persist -p "Project Name"
This creates:
design-system/MASTER.md— Global Source of Truth with all design rulesdesign-system/pages/— Folder for page-specific overrides
With page-specific override:
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --persist -p "Project Name" --page "dashboard"
This also creates:
design-system/pages/dashboard.md— Page-specific deviations from Master
How hierarchical retrieval works:
- When building a specific page (e.g., "Checkout"), first check
design-system/pages/checkout.md - If the page file exists, its rules override the Master file
- If not, use
design-system/MASTER.mdexclusively
Context-aware retrieval prompt:
I am building the [Page Name] page. Please read design-system/MASTER.md.
Also check if design-system/pages/[page-name].md exists.
If the page file exists, prioritize its rules.
If not, use the Master rules exclusively.
Now, generate the code...
Step 3: Supplement with Detailed Searches (as needed)
After getting the design system, use domain searches to get additional details:
python3 skills/ui-ux-pro-max/scripts/search.py "<keyword>" --domain <domain> [-n <max_results>]
When to use detailed searches:
| Need | Domain | Example |
|---|---|---|
| Product type patterns | product |
--domain product "entertainment social" |
| More style options | style |
--domain style "glassmorphism dark" |
| Color palettes | color |
--domain color "entertainment vibrant" |
| Font pairings | typography |
--domain typography "playful modern" |
| Chart recommendations | chart |
--domain chart "real-time dashboard" |
| UX best practices | ux |
--domain ux "animation accessibility" |
| Alternative fonts | typography |
--domain typography "elegant luxury" |
| Individual Google Fonts | google-fonts |
--domain google-fonts "sans serif popular variable" |
| Landing structure | landing |
--domain landing "hero social-proof" |
| React Native perf | react |
--domain react "rerender memo list" |
| App interface a11y | web |
--domain web "accessibilityLabel touch safe-areas" |
| AI prompt / CSS keywords | prompt |
--domain prompt "minimalism" |
Step 4: Stack Guidelines (React Native)
Get React Native implementation-specific best practices:
python3 skills/ui-ux-pro-max/scripts/search.py "<keyword>" --stack react-native
Search Reference
Available Domains
| Domain | Use For | Example Keywords |
|---|---|---|
product |
Product type recommendations | SaaS, e-commerce, portfolio, healthcare, beauty, service |
style |
UI styles, colors, effects | glassmorphism, minimalism, dark mode, brutalism |
typography |
Font pairings, Google Fonts | elegant, playful, professional, modern |
color |
Color palettes by product type | saas, ecommerce, healthcare, beauty, fintech, service |
landing |
Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof |
chart |
Chart types, library recommendations | trend, comparison, timeline, funnel, pie |
ux |
Best practices, anti-patterns | animation, accessibility, z-index, loading |
google-fonts |
Individual Google Fonts lookup | sans serif, monospace, japanese, variable font, popular |
react |
React/Next.js performance | waterfall, bundle, suspense, memo, rerender, cache |
web |
App interface guidelines (iOS/Android/React Native) | accessibilityLabel, touch targets, safe areas, Dynamic Type |
prompt |
AI prompts, CSS keywords | (style name) |
Available Stacks
| Stack | Focus |
|---|---|
react-native |
Components, Navigation, Lists |
Example Workflow
User request: "Make an AI search homepage."
Step 1: Analyze Requirements
- Product type: Tool (AI search engine)
- Target audience: C-end users looking for fast, intelligent search
- Style keywords: modern, minimal, content-first, dark mode
- Stack: React Native
Step 2: Generate Design System (REQUIRED)
python3 skills/ui-ux-pro-max/scripts/search.py "AI search tool modern minimal" --design-system -p "AI Search"
Output: Complete design system with pattern, style, colors, typography, effects, and anti-patterns.
Step 3: Supplement with Detailed Searches (as needed)
# Get style options for a modern tool product
python3 skills/ui-ux-pro-max/scripts/search.py "minimalism dark mode" --domain style
# Get UX best practices for search interaction and loading
python3 skills/ui-ux-pro-max/scripts/search.py "search loading animation" --domain ux
Step 4: Stack Guidelines
python3 skills/ui-ux-pro-max/scripts/search.py "list performance navigation" --stack react-native
Then: Synthesize design system + detailed searches and implement the design.
Output Formats
The --design-system flag supports two output formats:
# ASCII box (default) - best for terminal display
python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system
# Markdown - best for documentation
python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system -f markdown
Tips for Better Results
Query Strategy
- Use multi-dimensional keywords — combine product + industry + tone + density:
"entertainment social vibrant content-dense"not just"app" - Try different keywords for the same need:
"playful neon"→"vibrant dark"→"content-first minimal" - Use
--design-systemfirst for full recommendations, then--domainto deep-dive any dimension you're unsure about - Always add
--stack react-nativefor implementation-specific guidance
Common Sticking Points
| Problem | What to Do |
|---|---|
| Can't decide on style/color | Re-run --design-system with different keywords |
| Dark mode contrast issues | Quick Reference §6: color-dark-mode + color-accessible-pairs |
| Animations feel unnatural | Quick Reference §7: spring-physics + easing + exit-faster-than-enter |
| Form UX is poor | Quick Reference §8: inline-validation + error-clarity + focus-management |
| Navigation feels confusing | Quick Reference §9: nav-hierarchy + bottom-nav-limit + back-behavior |
| Layout breaks on small screens | Quick Reference §5: mobile-first + breakpoint-consistency |
| Performance / jank | Quick Reference §3: virtualize-lists + main-thread-budget + debounce-throttle |
Pre-Delivery Checklist
- Run
--domain ux "animation accessibility z-index loading"as a UX validation pass before implementation - Run through Quick Reference §1–§3 (CRITICAL + HIGH) as a final review
- Test on 375px (small phone) and landscape orientation
- Verify behavior with reduced-motion enabled and Dynamic Type at largest size
- Check dark mode contrast independently (don't assume light mode values work)
- Confirm all touch targets ≥44pt and no content hidden behind safe areas
Common Rules for Professional UI
These are frequently overlooked issues that make UI look unprofessional: Scope notice: The rules below are for App UI (iOS/Android/React Native/Flutter), not desktop-web interaction patterns.
Icons & Visual Elements
| Rule | Standard | Avoid | Why It Matters |
|---|---|---|---|
| No Emoji as Structural Icons | Use vector-based icons (e.g., Lucide, react-native-vector-icons, @expo/vector-icons). | Using emojis (🎨 🚀 ⚙️) for navigation, settings, or system controls. | Emojis are font-dependent, inconsistent across platforms, and cannot be controlled via design tokens. |
| Vector-Only Assets | Use SVG or platform vector icons that scale cleanly and support theming. | Raster PNG icons that blur or pixelate. | Ensures scalability, crisp rendering, and dark/light mode adaptability. |
| Stable Interaction States | Use color, opacity, or elevation transitions for press states without changing layout bounds. | Layout-shifting transforms that move surrounding content or trigger visual jitter. | Prevents unstable interactions and preserves smooth motion/perceived quality on mobile. |
| Correct Brand Logos | Use official brand assets and follow their usage guidelines (spacing, color, clear space). | Guessing logo paths, recoloring unofficially, or modifying proportions. | Prevents brand misuse and ensures legal/platform compliance. |
| Consistent Icon Sizing | Define icon sizes as design tokens (e.g., icon-sm, icon-md = 24pt, icon-lg). | Mixing arbitrary values like 20pt / 24pt / 28pt randomly. | Maintains rhythm and visual hierarchy across the interface. |
| Stroke Consistency | Use a consistent stroke width within the same visual layer (e.g., 1.5px or 2px). | Mixing thick and thin stroke styles arbitrarily. | Inconsistent strokes reduce perceived polish and cohesion. |
| Filled vs Outline Discipline | Use one icon style per hierarchy level. | Mixing filled and outline icons at the same hierarchy level. | Maintains semantic clarity and stylistic coherence. |
| Touch Target Minimum | Minimum 44×44pt interactive area (use hitSlop if icon is smaller). | Small icons without expanded tap area. | Meets accessibility and platform usability standards. |
| Icon Alignment | Align icons to text baseline and maintain consistent padding. | Misaligned icons or inconsistent spacing around them. | Prevents subtle visual imbalance that reduces perceived quality. |
| Icon Contrast | Follow WCAG contrast standards: 4.5:1 for small elements, 3:1 minimum for larger UI glyphs. | Low-contrast icons that blend into the background. | Ensures accessibility in both light and dark modes. |
Interaction (App)
| Rule | Do | Don't |
|---|---|---|
| Tap feedback | Provide clear pressed feedback (ripple/opacity/elevation) within 80-150ms | No visual response on tap |
| Animation timing | Keep micro-interactions around 150-300ms with platform-native easing | Instant transitions or slow animations (>500ms) |
| Accessibility focus | Ensure screen reader focus order matches visual order and labels are descriptive | Unlabeled controls or confusing focus traversal |
| Disabled state clarity | Use disabled semantics (disabled/native disabled props), reduced emphasis, and no tap action |
Controls that look tappable but do nothing |
| Touch target minimum | Keep tap areas >=44x44pt (iOS) or >=48x48dp (Android), expand hit area when icon is smaller | Tiny tap targets or icon-only hit areas without padding |
| Gesture conflict prevention | Keep one primary gesture per region and avoid nested tap/drag conflicts | Overlapping gestures causing accidental actions |
| Semantic native controls | Prefer native interactive primitives (Button, Pressable, platform equivalents) with proper accessibility roles |
Generic containers used as primary controls without semantics |
Light/Dark Mode Contrast
| Rule | Do | Don't |
|---|---|---|
| Surface readability (light) | Keep cards/surfaces clearly separated from background with sufficient opacity/elevation | Overly transparent surfaces that blur hierarchy |
| Text contrast (light) | Maintain body text contrast >=4.5:1 against light surfaces | Low-contrast gray body text |
| Text contrast (dark) | Maintain primary text contrast >=4.5:1 and secondary text >=3:1 on dark surfaces | Dark mode text that blends into background |
| Border and divider visibility | Ensure separators are visible in both themes (not just light mode) | Theme-specific borders disappearing in one mode |
| State contrast parity | Keep pressed/focused/disabled states equally distinguishable in light and dark themes | Defining interaction states for one theme only |
| Token-driven theming | Use semantic color tokens mapped per theme across app surfaces/text/icons | Hardcoded per-screen hex values |
| Scrim and modal legibility | Use a modal scrim strong enough to isolate foreground content (typically 40-60% black) | Weak scrim that leaves background visually competing |
Layout & Spacing
| Rule | Do | Don't |
|---|---|---|
| Safe-area compliance | Respect top/bottom safe areas for all fixed headers, tab bars, and CTA bars | Placing fixed UI under notch, status bar, or gesture area |
| System bar clearance | Add spacing for status/navigation bars and gesture home indicator | Let tappable content collide with OS chrome |
| Consistent content width | Keep predictable content width per device class (phone/tablet) | Mixing arbitrary widths between screens |
| 8dp spacing rhythm | Use a consistent 4/8dp spacing system for padding/gaps/section spacing | Random spacing increments with no rhythm |
| Readable text measure | Keep long-form text readable on large devices (avoid edge-to-edge paragraphs on tablets) | Full-width long text that hurts readability |
| Section spacing hierarchy | Define clear vertical rhythm tiers (e.g., 16/24/32/48) by hierarchy | Similar UI levels with inconsistent spacing |
| Adaptive gutters by breakpoint | Increase horizontal insets on larger widths and in landscape | Same narrow gutter on all device sizes/orientations |
| Scroll and fixed element coexistence | Add bottom/top content insets so lists are not hidden behind fixed bars | Scroll content obscured by sticky headers/footers |
Pre-Delivery Checklist
Before delivering UI code, verify these items: Scope notice: This checklist is for App UI (iOS/Android/React Native/Flutter).
Visual Quality
- No emojis used as icons (use SVG instead)
- All icons come from a consistent icon family and style
- Official brand assets are used with correct proportions and clear space
- Pressed-state visuals do not shift layout bounds or cause jitter
- Semantic theme tokens are used consistently (no ad-hoc per-screen hardcoded colors)
Interaction
- All tappable elements provide clear pressed feedback (ripple/opacity/elevation)
- Touch targets meet minimum size (>=44x44pt iOS, >=48x48dp Android)
- Micro-interaction timing stays in the 150-300ms range with native-feeling easing
- Disabled states are visually clear and non-interactive
- Screen reader focus order matches visual order, and interactive labels are descriptive
- Gesture regions avoid nested/conflicting interactions (tap/drag/back-swipe conflicts)
Light/Dark Mode
- Primary text contrast >=4.5:1 in both light and dark mode
- Secondary text contrast >=3:1 in both light and dark mode
- Dividers/borders and interaction states are distinguishable in both modes
- Modal/drawer scrim opacity is strong enough to preserve foreground legibility (typically 40-60% black)
- Both themes are tested before delivery (not inferred from a single theme)
Layout
- Safe areas are respected for headers, tab bars, and bottom CTA bars
- Scroll content is not hidden behind fixed/sticky bars
- Verified on small phone, large phone, and tablet (portrait + landscape)
- Horizontal insets/gutters adapt correctly by device size and orientation
- 4/8dp spacing rhythm is maintained across component, section, and page levels
- Long-form text measure remains readable on larger devices (no edge-to-edge paragraphs)
Accessibility
- All meaningful images/icons have accessibility labels
- Form fields have labels, hints, and clear error messages
- Color is not the only indicator
- Reduced motion and dynamic text size are supported without layout breakage
- Accessibility traits/roles/states (selected, disabled, expanded) are announced correctly
skills/xlsx/SKILL.md
npx skills add melandlabs/openloomi --skill xlsx -g -y
SKILL.md
Frontmatter
{
"name": "xlsx",
"license": "Proprietary. LICENSE.txt has complete terms",
"description": "Use this skill any time a spreadsheet file is the primary input or output. This means any task where the user wants to: open, read, edit, or fix an existing .xlsx, .xlsm, .csv, or .tsv file (e.g., adding columns, computing formulas, formatting, charting, cleaning messy data); create a new spreadsheet from scratch or from other data sources; or convert between tabular file formats. Trigger especially when the user references a spreadsheet file by name or path — even casually (like \"the xlsx in my downloads\") — and wants something done to it or produced from it. Also trigger for cleaning or restructuring messy tabular data files (malformed rows, misplaced headers, junk data) into proper spreadsheets. The deliverable must be a spreadsheet file. Do NOT trigger when the primary deliverable is a Word document, HTML report, standalone Python script, database pipeline, or Google Sheets API integration, even if tabular data is involved."
}
Requirements for Outputs
All Excel files
Professional Font
- Use a consistent, professional font (e.g., Arial, Times New Roman) for all deliverables unless otherwise instructed by the user
Zero Formula Errors
- Every Excel model MUST be delivered with ZERO formula errors (#REF!, #DIV/0!, #VALUE!, #N/A, #NAME?)
Preserve Existing Templates (when updating templates)
- Study and EXACTLY match existing format, style, and conventions when modifying files
- Never impose standardized formatting on files with established patterns
- Existing template conventions ALWAYS override these guidelines
Financial models
Color Coding Standards
Unless otherwise stated by the user or existing template
Industry-Standard Color Conventions
- Blue text (RGB: 0,0,255): Hardcoded inputs, and numbers users will change for scenarios
- Black text (RGB: 0,0,0): ALL formulas and calculations
- Green text (RGB: 0,128,0): Links pulling from other worksheets within same workbook
- Red text (RGB: 255,0,0): External links to other files
- Yellow background (RGB: 255,255,0): Key assumptions needing attention or cells that need to be updated
Number Formatting Standards
Required Format Rules
- Years: Format as text strings (e.g., "2024" not "2,024")
- Currency: Use $#,##0 format; ALWAYS specify units in headers ("Revenue ($mm)")
- Zeros: Use number formatting to make all zeros "-", including percentages (e.g., "$#,##0;($#,##0);-")
- Percentages: Default to 0.0% format (one decimal)
- Multiples: Format as 0.0x for valuation multiples (EV/EBITDA, P/E)
- Negative numbers: Use parentheses (123) not minus -123
Formula Construction Rules
Assumptions Placement
- Place ALL assumptions (growth rates, margins, multiples, etc.) in separate assumption cells
- Use cell references instead of hardcoded values in formulas
- Example: Use =B5*(1+$B$6) instead of =B5*1.05
Formula Error Prevention
- Verify all cell references are correct
- Check for off-by-one errors in ranges
- Ensure consistent formulas across all projection periods
- Test with edge cases (zero values, negative numbers)
- Verify no unintended circular references
Documentation Requirements for Hardcodes
- Comment or in cells beside (if end of table). Format: "Source: [System/Document], [Date], [Specific Reference], [URL if applicable]"
- Examples:
- "Source: Company 10-K, FY2024, Page 45, Revenue Note, [SEC EDGAR URL]"
- "Source: Company 10-Q, Q2 2025, Exhibit 99.1, [SEC EDGAR URL]"
- "Source: Bloomberg Terminal, 8/15/2025, AAPL US Equity"
- "Source: FactSet, 8/20/2025, Consensus Estimates Screen"
XLSX creation, editing, and analysis
Overview
A user may ask you to create, edit, or analyze the contents of an .xlsx file. You have different tools and workflows available for different tasks.
Important Requirements
LibreOffice Required for Formula Recalculation: You can assume LibreOffice is installed for recalculating formula values using the scripts/recalc.py script. The script automatically configures LibreOffice on first run, including in sandboxed environments where Unix sockets are restricted (handled by scripts/office/soffice.py)
Reading and analyzing data
Data analysis with pandas
For data analysis, visualization, and basic operations, use pandas which provides powerful data manipulation capabilities:
import pandas as pd
# Read Excel
df = pd.read_excel('file.xlsx') # Default: first sheet
all_sheets = pd.read_excel('file.xlsx', sheet_name=None) # All sheets as dict
# Analyze
df.head() # Preview data
df.info() # Column info
df.describe() # Statistics
# Write Excel
df.to_excel('output.xlsx', index=False)
Excel File Workflows
CRITICAL: Use Formulas, Not Hardcoded Values
Always use Excel formulas instead of calculating values in Python and hardcoding them. This ensures the spreadsheet remains dynamic and updateable.
❌ WRONG - Hardcoding Calculated Values
# Bad: Calculating in Python and hardcoding result
total = df['Sales'].sum()
sheet['B10'] = total # Hardcodes 5000
# Bad: Computing growth rate in Python
growth = (df.iloc[-1]['Revenue'] - df.iloc[0]['Revenue']) / df.iloc[0]['Revenue']
sheet['C5'] = growth # Hardcodes 0.15
# Bad: Python calculation for average
avg = sum(values) / len(values)
sheet['D20'] = avg # Hardcodes 42.5
✅ CORRECT - Using Excel Formulas
# Good: Let Excel calculate the sum
sheet['B10'] = '=SUM(B2:B9)'
# Good: Growth rate as Excel formula
sheet['C5'] = '=(C4-C2)/C2'
# Good: Average using Excel function
sheet['D20'] = '=AVERAGE(D2:D19)'
This applies to ALL calculations - totals, percentages, ratios, differences, etc. The spreadsheet should be able to recalculate when source data changes.
Common Workflow
- Choose tool: pandas for data, openpyxl for formulas/formatting
- Create/Load: Create new workbook or load existing file
- Modify: Add/edit data, formulas, and formatting
- Save: Write to file
- Recalculate formulas (MANDATORY IF USING FORMULAS): Use the scripts/recalc.py script
python scripts/recalc.py output.xlsx - Verify and fix any errors:
- The script returns JSON with error details
- If
statusiserrors_found, checkerror_summaryfor specific error types and locations - Fix the identified errors and recalculate again
- Common errors to fix:
#REF!: Invalid cell references#DIV/0!: Division by zero#VALUE!: Wrong data type in formula#NAME?: Unrecognized formula name
Creating new Excel files
# Using openpyxl for formulas and formatting
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
wb = Workbook()
sheet = wb.active
# Add data
sheet['A1'] = 'Hello'
sheet['B1'] = 'World'
sheet.append(['Row', 'of', 'data'])
# Add formula
sheet['B2'] = '=SUM(A1:A10)'
# Formatting
sheet['A1'].font = Font(bold=True, color='FF0000')
sheet['A1'].fill = PatternFill('solid', start_color='FFFF00')
sheet['A1'].alignment = Alignment(horizontal='center')
# Column width
sheet.column_dimensions['A'].width = 20
wb.save('output.xlsx')
Editing existing Excel files
# Using openpyxl to preserve formulas and formatting
from openpyxl import load_workbook
# Load existing file
wb = load_workbook('existing.xlsx')
sheet = wb.active # or wb['SheetName'] for specific sheet
# Working with multiple sheets
for sheet_name in wb.sheetnames:
sheet = wb[sheet_name]
print(f"Sheet: {sheet_name}")
# Modify cells
sheet['A1'] = 'New Value'
sheet.insert_rows(2) # Insert row at position 2
sheet.delete_cols(3) # Delete column 3
# Add new sheet
new_sheet = wb.create_sheet('NewSheet')
new_sheet['A1'] = 'Data'
wb.save('modified.xlsx')
Recalculating formulas
Excel files created or modified by openpyxl contain formulas as strings but not calculated values. Use the provided scripts/recalc.py script to recalculate formulas:
python scripts/recalc.py <excel_file> [timeout_seconds]
Example:
python scripts/recalc.py output.xlsx 30
The script:
- Automatically sets up LibreOffice macro on first run
- Recalculates all formulas in all sheets
- Scans ALL cells for Excel errors (#REF!, #DIV/0!, etc.)
- Returns JSON with detailed error locations and counts
- Works on both Linux and macOS
Formula Verification Checklist
Quick checks to ensure formulas work correctly:
Essential Verification
- Test 2-3 sample references: Verify they pull correct values before building full model
- Column mapping: Confirm Excel columns match (e.g., column 64 = BL, not BK)
- Row offset: Remember Excel rows are 1-indexed (DataFrame row 5 = Excel row 6)
Common Pitfalls
- NaN handling: Check for null values with
pd.notna() - Far-right columns: FY data often in columns 50+
- Multiple matches: Search all occurrences, not just first
- Division by zero: Check denominators before using
/in formulas (#DIV/0!) - Wrong references: Verify all cell references point to intended cells (#REF!)
- Cross-sheet references: Use correct format (Sheet1!A1) for linking sheets
Formula Testing Strategy
- Start small: Test formulas on 2-3 cells before applying broadly
- Verify dependencies: Check all cells referenced in formulas exist
- Test edge cases: Include zero, negative, and very large values
Interpreting scripts/recalc.py Output
The script returns JSON with error details:
{
"status": "success", // or "errors_found"
"total_errors": 0, // Total error count
"total_formulas": 42, // Number of formulas in file
"error_summary": {
// Only present if errors found
"#REF!": {
"count": 2,
"locations": ["Sheet1!B5", "Sheet1!C10"]
}
}
}
Best Practices
Library Selection
- pandas: Best for data analysis, bulk operations, and simple data export
- openpyxl: Best for complex formatting, formulas, and Excel-specific features
Working with openpyxl
- Cell indices are 1-based (row=1, column=1 refers to cell A1)
- Use
data_only=Trueto read calculated values:load_workbook('file.xlsx', data_only=True) - Warning: If opened with
data_only=Trueand saved, formulas are replaced with values and permanently lost - For large files: Use
read_only=Truefor reading orwrite_only=Truefor writing - Formulas are preserved but not evaluated - use scripts/recalc.py to update values
Working with pandas
- Specify data types to avoid inference issues:
pd.read_excel('file.xlsx', dtype={'id': str}) - For large files, read specific columns:
pd.read_excel('file.xlsx', usecols=['A', 'C', 'E']) - Handle dates properly:
pd.read_excel('file.xlsx', parse_dates=['date_column'])
Code Style Guidelines
IMPORTANT: When generating Python code for Excel operations:
- Write minimal, concise Python code without unnecessary comments
- Avoid verbose variable names and redundant operations
- Avoid unnecessary print statements
For Excel files themselves:
- Add comments to cells with complex formulas or important assumptions
- Document data sources for hardcoded values
- Include notes for key calculations and model sections


