A secure LLM task runner and personal AI assistant that separates credential-bearing data fetching from LLM processing. Supports both scheduled tasks (morning briefings, weather summaries) and interactive agent mode (chat via CLI or iMessage with tool calling).
A creel is a wicker basket usually used for carrying fish or blocks of peat. It is also the fish trap used to catch lobsters and other crustaceans.
Agentic LLM systems give the model access to tools, credentials, and untrusted input all at once. Creel enforces per-tool isolation — each executor runs in its own container with only the credentials it needs:
| Component | Has access to | Does NOT have |
|---|---|---|
| Each executor | Only its own credential (one OAuth scope, one API key) | LLM, other tools' credentials |
| Bridge executors | Scoped HTTP token for one macOS app | LLM, other bridge endpoints |
| LLM Runner | Anthropic API key only | Any tool credentials |
| Orchestrator | All secrets, LLM output | Untrusted external input |
Even if prompt injection occurs (e.g., via a calendar event title), the LLM container has nothing to exfiltrate except its own API key. A compromised executor can only access its one scoped credential — not your email, not your files, not your messages.
flowchart TD
subgraph orch["Orchestrator"]
direction TB
schedule["Scheduler / Agent Loop"]
guardian["Guardian Pipeline"]
template["Prompt Builder"]
output["Output Router"]
end
subgraph containers["Docker Executors (isolated)"]
direction TB
google["Google Suite\n📅 Calendar · 📧 Gmail · 📁 Drive"]
web["Web Tools\n🔍 Search · 🌐 Fetch · 🌤 Weather"]
exec["Shell / Exec\n⚙️ Mounted paths only"]
end
subgraph bridge["Host Bridge (macOS native)"]
direction TB
bridge_api["FastAPI Server"]
native["📝 Notes · ✅ Reminders\n📋 Things 3 · 💬 iMessage"]
end
subgraph llm_container["LLM Container"]
llm["Claude\n🔑 Anthropic API key only"]
end
subgraph channels["Channels"]
cli["TUI / CLI"]
imsg["iMessage"]
end
channels -- "message" --> orch
schedule -- "tool call" --> guardian
guardian -- "approved" --> containers
guardian -- "approved" --> bridge_api
bridge_api --> native
containers -- "JSON result" --> template
bridge_api -- "JSON result" --> template
template -- "prompt\n(no secrets)" --> llm
llm -- "response" --> output
output --> channels
style containers fill:#2d333b,stroke:#f47067,stroke-width:2px,color:#f0f0f0
style bridge fill:#2d333b,stroke:#fd7e14,stroke-width:2px,color:#f0f0f0
style llm_container fill:#2d333b,stroke:#f47067,stroke-width:2px,color:#f0f0f0
style orch fill:#2d333b,stroke:#58a6ff,stroke-width:2px,color:#f0f0f0
style channels fill:#2d333b,stroke:#3fb950,stroke-width:2px,color:#f0f0f0
Key insight: Each red box is a separate security boundary. The LLM never sees credentials. Executors only get their own scoped secret. Even a compromised tool can't reach other tools' data.
In agent mode, the same security boundary applies — the LLM requests tool calls, but the orchestrator handles secrets injection and executor execution:
flowchart TD
CH["Channels\nstdin | iMsg | BB"] -- "incoming message" --> SM["Session Manager\n(JSON files)"]
SM -- "message + history" --> AL["Agent Loop"]
AL --> LLM["LLM call"]
LLM --> TU{"tool_use?"}
TU -- no --> resp["Response"]
TU -- yes --> EX["Execute via executor\n(secrets injected)"]
EX --> TR["tool_result"] --> AL
Scheduled tasks can also use agent mode by setting mode: agent in the task YAML.
The guardian layer screens inputs and validates tool calls before they execute. All stages are optional and independently configurable in agent.yaml:
flowchart TD
A["Incoming message"] --> B["screen_input(text)\n← before session history"]
B --> FC["FastClassifier\nDeBERTa/ONNX, ~10ms"]
B --> LJ["LLMJudge\nHaiku, ~300ms, off by default"]
FC --> blocked{"blocked?"}
LJ --> blocked
blocked -- yes --> reject["Return rejection,\nskip agent loop"]
blocked -- no --> agent["Agent loop → LLM returns tool_use"]
agent --> VA["validate_action(tool, args)\n← before execute_tool_call"]
VA --> PE["PolicyEngine\nYAML rules, <1ms"]
VA --> CC["CoherenceCheck\nHaiku, ~300ms"]
PE -- allow --> execute["Execute"]
PE -- review --> approval["Human approval\nor auto_approve"]
PE -- deny --> err1["Return error to LLM"]
CC -- coherent --> execute
CC -- incoherent --> err2["Return error to LLM"]
Stages:
| Stage | Component | What it does |
|---|---|---|
| 1 | Fast classifier | Local DeBERTa model scores prompt-injection likelihood against a confidence threshold |
| 2 | LLM judge | Secondary Haiku-based check (disabled by default) |
| 3 | Policy engine | fnmatch rules in policies/default.yaml map tool names to allow/review/deny |
| 4 | Coherence check | LLM-based check that tool calls match the user's original intent (catches prompt injection causing unrelated actions) |
Policy rules (policies/default.yaml):
allow: [check_weather, check_calendar, check_email, read_email, check_drive, check_messages, get_chats, react_imessage]
review: [send_*, upload_*, create_*, mark_*, trash_*]
deny: [delete_*]
# Tools that match 'review' rules but can skip human approval
auto_approve:
- mark_read
- react_imessageDeny wins over review, review wins over allow. Unknown tools default to review. Tools listed in auto_approve skip the human confirmation prompt even when matched by a review rule.
Human-in-the-loop review: Tools matching review rules prompt the user for approval before executing. In the TUI, this appears as an inline confirmation dialog. A configurable timeout (default 60s) denies the action if no response is received.
Audit logging writes to guardian_audit.jsonl with hashed inputs (never raw text), tool names, arg keys (not values), verdicts, and confidence scores.
Configuration in agent.yaml:
guardian:
enabled: true
review:
timeout_seconds: 60
default_on_timeout: deny
fast_classifier:
enabled: true
threshold: 0.95
model_name: protectai/deberta-v3-base-prompt-injection-v2
llm_judge:
enabled: false
coherence:
enabled: true
model: claude-haiku-4-5-20251001
max_tokens: 256
policy:
enabled: true
policy_file: policies/default.yaml
audit:
enabled: true
log_file: guardian_audit.jsonlFor macOS-specific tools (Apple Notes, Apple Reminders, Things 3, iMessage), Docker containers can't directly execute AppleScript or access macOS applications. The host bridge solves this by running a FastAPI server on the host system that provides authenticated HTTP endpoints for containerized executors.
Why: Docker containers are sandboxed and can't access the macOS scripting bridge or application APIs that tools like Notes, Reminders, and Things 3 require.
How: A FastAPI server (python -m bridge.server) runs as a host process and exposes REST endpoints at /notes/*, /reminders/*, /things/*, and /imessage/*. Containerized executors make HTTP requests to these endpoints with scoped authentication tokens.
Security: Each executor receives a scoped token that only grants access to its specific tool endpoints. For example, the apple_notes executor can only call /notes/* endpoints, not /reminders/* or /things/*.
CLI Integration: The bridge server delegates to command-line tools:
- Apple Notes:
memoCLI for reading/writing notes - Apple Reminders:
remindctlCLI for managing reminders - Things 3:
thingsCLI for task management - iMessage:
imsgCLI for sending/receiving messages
Starting the bridge: python -m bridge.server --host 127.0.0.1 --port 8099 starts the FastAPI server with authentication middleware and scoped endpoint routing.
The exec executor provides sandboxed shell command execution within isolated Docker containers. It's designed for running system commands, scripts, and CLI tools while maintaining security through filesystem isolation and network restrictions.
Sandboxing: Commands execute in a minimal Alpine Linux container (alpine:latest) with:
- Network isolation:
--network=noneby default (no internet access) - Filesystem isolation: Only configured mount points are accessible
- Minimal base: Just
bash,grep,sed,awk,curl,jqinstalled - Read-only root: Container filesystem is read-only except for mounted paths
Mount Configuration: Mount points specify host paths and access modes:
- Path mapping: Host directory → container directory
- Access modes:
ro(read-only) orrw(read-write) - Scope limitation: Only explicitly mounted paths are accessible
Future Enhancement: Specialized container images for different use cases:
exec-dev: Development tools (git, make, compilers)exec-python: Python runtime and common packagesexec-node: Node.js runtime and npm
git clone https://github.com/creel-ai/creel.git
cd creel
```bash
# macOS
brew install pyenv uv age
# Linux
curl https://pyenv.run | bash # then follow shell setup instructions
curl -LsSf https://astral.sh/uv/install.sh | sh
sudo apt install age # or: brew install agepyenv install 3.12.12 # if not already installed
uv venv
source .venv/bin/activate
uv pip install -e ".[dev, guardian]"
# Set up age for secrets encryption (one-time)
mkdir -p ~/.age
age-keygen -o ~/.age/key.txt 2> ~/.age/key.pub
# List available tasks
creel list
# Validate a task definition
creel validate weather_checkexport ANTHROPIC_API_KEY=sk-ant-... # or use ANTHROPIC_AUTH_TOKENcreel daemon start # start the background agent
creel attach # open the rich TUIThat's it — you're chatting with your agent. Type /help in the TUI to see commands.
# One-shot message (no TUI)
creel send "What's the weather today?"
# Stream a response
creel send "Summarize my calendar" --stream
# Run a scheduled task
creel run weather_check
# Run on startup (macOS)
creel daemon install
# Query the security audit log
creel audit --blocked --tail 50📖 Full setup guide including secrets encryption, Google OAuth, and container mode: Getting Started docs
The repository deploys two separate Vercel projects:
site/(marketing site)docs/(MkDocs documentation built from../mkdocs.yml)
For site/:
- Framework Preset:
Other - Install Command: (empty)
- Build Command: (empty)
- Output Directory:
.(or leave empty)
For docs/:
- Framework Preset:
Other - Install Command: (empty)
- Build Command:
bash build-vercel.sh - Output Directory:
.vercel-static
- Enable Git integration in Vercel for preview deploys on pull requests and production deploys from
main. - No GitHub Actions deploy workflow is required.
Set MKDOCS_SITE_URL in the docs Vercel project environment to your docs domain (for example https://docs.creel-ai.com/).
The runner supports two ways to authenticate with the Anthropic API:
| Method | Env var | How to get it |
|---|---|---|
| API key | ANTHROPIC_API_KEY |
console.anthropic.com |
| Claude Code setup token | ANTHROPIC_AUTH_TOKEN |
claude setup-token |
If both are set, ANTHROPIC_AUTH_TOKEN takes precedence.
export ANTHROPIC_API_KEY=sk-ant-...
creel run weather_checkClaude Code can generate OAuth tokens that work with the Anthropic API:
# Generate a setup token (requires Claude Code CLI)
claude setup-token
# Copy the sk-ant-oat01-... value
export ANTHROPIC_AUTH_TOKEN=sk-ant-oat01-...
creel run weather_checkEither variable can go in an age-encrypted secrets file:
# Create the plaintext .env
echo 'ANTHROPIC_AUTH_TOKEN=sk-ant-oat01-...' > secrets/anthropic.env
# Encrypt and delete plaintext
./scripts/encrypt-secret.sh secrets/anthropic.env
rm secrets/anthropic.envThen reference it in your task YAML under llm.secrets.
The runner loads a root .env file (gitignored) at startup for non-secret configuration like phone numbers:
# .env (project root — gitignored, never committed)
PHONE=+1234567890Values are available as environment variables and can be referenced in task YAMLs with $VAR syntax:
output:
type: imessage
to: "$PHONE"Real environment variables take precedence over .env values.
Tasks are YAML files in tasks/. Each defines what data to fetch, how to prompt the LLM, and where to send the result.
# tasks/morning_briefing.yaml
name: morning_briefing
schedule: "0 7 * * *" # 7am daily
fetch:
calendar:
image: executor-gcal:latest
secrets: secrets/gcal.env.enc
args:
range: today
weather:
image: executor-weather:latest
args:
location: denver
prompt: |
You're my personal assistant. Give me a quick rundown of my day.
Today: {date}
Weather: {weather}
Calendar: {calendar}
Keep it under 150 words. Flag any early meetings or conflicts.
output:
type: imessage
to: "$PHONE"
llm:
model: claude-sonnet-4-20250514
max_tokens: 300
secrets: secrets/anthropic.env.enc| Field | Required | Description |
|---|---|---|
name |
yes | Unique task identifier |
schedule |
yes | 5-part cron expression |
fetch |
yes | Map of executor name to config |
fetch.<name>.image |
yes | Docker image for containerized mode |
fetch.<name>.secrets |
no | Path to age-encrypted .env file |
fetch.<name>.args |
no | Key-value args passed to the executor |
prompt |
yes | Prompt template with {name} placeholders |
output.type |
yes | imessage, stdout, or file |
output.to |
yes | Phone number, empty string, or file path |
llm.model |
no | Anthropic model ID (default: claude-sonnet-4-20250514) |
llm.max_tokens |
no | Max response tokens (default: 300) |
llm.secrets |
no | Path to age-encrypted .env with ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY |
The {date} placeholder is always available and resolves to the current date.
Tasks can use the agent loop for multi-step tool calling by setting mode: agent:
# tasks/email_triage.yaml
name: email_triage
schedule: "0 8 * * *"
mode: agent
fetch:
gmail:
image: executor-gmail:latest
secrets: secrets/gmail.env.enc
args:
query: "is:unread newer_than:1d"
tools:
trash_email:
executor: gmail_modify
secrets: secrets/gmail_modify.env.enc
description: "Move an email to trash"
parameters:
message_id:
type: string
description: "Gmail message ID"
required: true
fixed_args:
action: "trash"
agent:
max_turns: 10
prompt: |
Triage my unread emails. Trash spam. Summarize what you did.
{gmail}
output:
type: imessage
to: "$PHONE"
llm:
model: claude-sonnet-4-20250514
max_tokens: 1024
secrets: secrets/anthropic.env.encAgent mode task fields (in addition to standard fields):
| Field | Required | Description |
|---|---|---|
mode |
no | simple (default) or agent |
tools |
no | Map of tool name to tool config |
tools.<name>.executor |
yes | Executor to execute (e.g., gmail_modify) |
tools.<name>.secrets |
no | Path to age-encrypted .env file |
tools.<name>.description |
yes | Description shown to the LLM |
tools.<name>.parameters |
no | Parameters the LLM can provide |
tools.<name>.fixed_args |
no | Args always passed to executor (override LLM input) |
agent.max_turns |
no | Max agent loop iterations (default: 10) |
The global agent config (agent.yaml) defines tools, LLM settings, session behavior, channels, workspace memory, and guardian settings for interactive chat mode:
system_prompt: |
You are a personal assistant. Be concise and helpful.
Today is {date}.
tools:
check_weather:
executor: weather
description: "Get current weather and forecast"
parameters:
location:
type: string
description: "City name or coordinates"
required: true
check_email:
executor: gmail_readonly
secrets: secrets/gmail.env.enc
description: "Search Gmail for emails"
parameters:
query:
type: string
description: "Gmail search query"
required: true
# ... see agent.yaml for all tools (calendar, drive, iMessage, etc.)
llm:
model: claude-sonnet-4-20250514
max_tokens: 1024
secrets: secrets/anthropic.env.enc
agent:
max_turns: 15
session:
sessions_dir: sessions
max_history: 50
summarize_on_trim: true
workspace:
path: workspace
timezone: "America/Denver"
memory_days: 2
memory_max_chars: 5000
max_chars_per_file: 20000
channels:
imessage:
listen_to: "$PHONE"
poll_interval: 3
bluebubbles:
server_url: "$BLUEBUBBLES_URL"
password: "$BLUEBUBBLES_PASSWORD"
listen_to:
- "$PHONE"
poll_interval: 3Sessions are stored as JSON files in sessions/ (gitignored) and persist conversation history across interactions. When summarize_on_trim is enabled, old messages are summarized before being trimmed.
Workspace memory provides file-based memory across sessions:
workspace/memory/YYYY-MM-DD.md— daily append-only logs written by the agent'sremembertoolworkspace/MEMORY.md— curated long-term memory
Recent daily logs and long-term memory are injected into the system prompt automatically.
Creel supports quiet hours to suppress proactive notifications during configured time periods (e.g., nighttime, work hours). Quiet hours are configured in agent.yaml and only suppress outbound notifications — direct replies to user messages are never suppressed.
quiet_hours:
enabled: true
start: "22:00" # 10 PM
end: "08:00" # 8 AM
timezone: "America/Denver"Uses wttr.in - no API key required.
weather:
image: executor-weather:latest
args:
location: denver # city name or coordinatesRequires a one-time OAuth setup:
# 1. Create GCP project, enable Calendar API, download OAuth credentials
# 2. Run the setup script (--encrypt auto-encrypts and deletes plaintext)
python scripts/setup-google-oauth.py gcal --encryptThe executor uses a read-only scope (calendar.readonly) and authenticates with a refresh token.
Reads emails matching a Gmail search query. Supports both listing emails and reading individual messages by ID. Requires a one-time OAuth setup:
# Same GCP project — enable the Gmail API
python scripts/setup-google-oauth.py gmail --encryptThe executor uses a read-only scope (gmail.readonly). Configuration:
gmail_readonly:
image: executor-gmail-readonly:latest
secrets: secrets/gmail.env.enc
args:
query: "is:unread newer_than:1d" # Gmail search syntax
max_results: "20" # max messages to fetch
message_id: "" # set to read a specific email by IDWhen reading a single message by ID, the full decoded body is returned (prefers text/plain, falls back to HTML stripping via BeautifulSoup).
Creates calendar events. Requires a one-time OAuth setup with the calendar.events scope:
python scripts/setup-google-oauth.py gcal_write --encryptConfiguration:
gcal_write:
image: executor-gcal-write:latest
secrets: secrets/gcal_write.env.enc
args:
summary: "Team standup"
start: "2025-01-15T09:00:00-07:00" # ISO 8601
end: "2025-01-15T09:30:00-07:00"
description: "Daily sync" # optional
location: "Room 42" # optionalSends an email. Requires a one-time OAuth setup with the gmail.send scope:
python scripts/setup-google-oauth.py gmail_send --encryptConfiguration:
gmail_send:
image: executor-gmail-send:latest
secrets: secrets/gmail_send.env.enc
args:
to: "recipient@example.com"
subject: "Daily report"
body: "Here is today's summary..."Modifies, trashes, or permanently deletes Gmail messages. Requires a one-time OAuth setup with the gmail.modify scope:
python scripts/setup-google-oauth.py gmail_modify --encryptConfiguration:
gmail_modify:
image: executor-gmail-modify:latest
secrets: secrets/gmail_modify.env.enc
args:
action: "modify" # modify, trash, or delete
message_id: "18f1a2b3c4d5e6f" # Gmail message ID
add_labels: "STARRED" # comma-separated label IDs (modify only)
remove_labels: "UNREAD,INBOX" # comma-separated label IDs (modify only)Lists and reads files from Google Drive. Requires a one-time OAuth setup with the drive.readonly scope:
python scripts/setup-google-oauth.py drive --encryptConfiguration:
drive:
image: executor-drive:latest
secrets: secrets/drive.env.enc
args:
query: "mimeType='application/pdf'" # Drive search query (optional)
max_results: "20"Uploads a file to Google Drive. Requires a one-time OAuth setup with the drive.file scope:
python scripts/setup-google-oauth.py drive_write --encryptConfiguration:
drive_write:
image: executor-drive-write:latest
secrets: secrets/drive_write.env.enc
args:
name: "report.txt"
content: "File contents here..."
mime_type: "text/plain" # optional, defaults to text/plain
folder_id: "" # optional Drive folder IDSends and reads iMessages via a BlueBubbles server. Requires a running BlueBubbles instance.
bluebubbles:
image: executor-bluebubbles:latest
secrets: secrets/bluebubbles.env.enc
args:
action: "get_recent_messages" # get_recent_messages, send_message, send_reaction, get_chats
chat_id: "chat123"
limit: "25"Built-in safety: hard caps on message count (50), message length (2000 chars), send rate limiting (10/min), and allowlist enforcement for recipients.
Web search via the Brave Search API.
brave_search:
image: executor-brave-search:latest
secrets: secrets/brave_search.env.enc
args:
query: "latest news on AI safety"
count: "5" # max 20Extracts text content from web pages using BeautifulSoup. Strips scripts, nav, and boilerplate.
fetch_url:
image: executor-fetch-url:latest
args:
url: "https://example.com/article"
max_chars: "10000"No API key required.
Reads and creates notes in Notes.app via the host bridge. Uses the memo CLI tool for macOS integration.
apple_notes:
args:
action: "list_notes" # list_notes, search_notes, read_note, create_note
folder: "Notes"
limit: "25"Reads and creates reminders in Reminders.app via the host bridge. Uses the remindctl CLI tool for macOS integration.
apple_reminders:
args:
action: "list_reminders" # list_reminders, create_reminder, complete_reminder, get_lists
list_name: "Reminders"Manages tasks in Things 3 via the host bridge. Uses the things CLI tool for macOS integration.
things:
args:
action: "list_tasks" # list_tasks, create_task, complete_task, search_tasks
area: "Personal"
limit: "25"Sends and reads iMessages via the host bridge. Uses the imsg CLI tool for macOS integration, providing an alternative to the BlueBubbles executor.
imessage_bridge:
args:
action: "get_recent" # get_recent, send_message, get_chats
limit: "25"
chat_id: "chat123"Executes shell commands in sandboxed Docker containers with configurable mount points and network isolation.
exec:
args:
command: "ls -la /workspace"
workdir: "/workspace"
mounts:
- path: "/Users/user/docs"
target: "/workspace"
mode: "ro"All Google services use the same OAuth2 client credentials (client ID + client secret from a single GCP project) but obtain separate refresh tokens with different scopes. This means:
- One
client_secret.jsonfile works for all services - Each service gets its own
.envfile (e.g.secrets/gcal.env,secrets/gmail_send.env) - Each refresh token is scoped to a single API permission
- Revoking one token doesn't affect the others
# Set up all services at once (encrypts automatically)
python scripts/setup-google-oauth.py --all
# Or set up specific services
python scripts/setup-google-oauth.py gmail gcal
# Encrypt with age after each OAuth flow
python scripts/setup-google-oauth.py gmail --encrypt
# Encrypt all existing .env files in secrets/ (no OAuth flow)
python scripts/setup-google-oauth.py --encrypt-allAvailable services: gcal, gcal_write, gmail, gmail_send, gmail_modify, drive, drive_write.
Secrets are encrypted at rest using age. The Python side uses pyrage for decryption.
# Generate an age key pair (one-time)
mkdir -p ~/.age
age-keygen -o ~/.age/key.txt 2> ~/.age/key.pub
# Encrypt a .env file
./scripts/encrypt-secret.sh secrets/anthropic.env
# The decryption key path defaults to ~/.age/key.txt
# Override with AGE_IDENTITY environment variable
export AGE_IDENTITY=/path/to/key.txtThe .env format supports KEY=value, quoted values, and comments:
ANTHROPIC_API_KEY=sk-ant-...
GOOGLE_CREDENTIALS_JSON='{"refresh_token": "...", "client_id": "...", "client_secret": "..."}'
For production use, executors and the LLM runner execute in isolated Docker containers with restricted capabilities. The --containers flag works with task runs, scheduler jobs, and daemon runtime:
# Build container images
docker build -t executor-weather:latest executors/weather/
docker build -t executor-gcal:latest executors/gcal/
docker build -t executor-gcal-write:latest executors/gcal_write/
docker build -t executor-gmail-readonly:latest executors/gmail_readonly/
docker build -t executor-gmail-send:latest executors/gmail_send/
docker build -t executor-gmail-modify:latest executors/gmail_modify/
docker build -t executor-drive:latest executors/drive/
docker build -t executor-drive-write:latest executors/drive_write/
docker build -t executor-bluebubbles:latest executors/bluebubbles/
docker build -t executor-brave-search:latest executors/brave_search/
docker build -t executor-fetch-url:latest executors/fetch_url/
docker build -t llm-runner:latest llm/
# Run a task with containers
creel --containers run morning_briefing
# Scheduler with containers
creel --containers schedule
# Daemon runtime with containerized executors
creel --containers daemon startContainers run with:
--read-onlyfilesystem--cap-drop=ALL--security-opt=no-new-privileges- Memory and CPU limits (
256m,0.5CPU) - 60-second timeout
- Only the secrets each container needs
In daemon mode, the agent loop runs on the host while each tool call (executor) executes in its own isolated container. This preserves the trust boundary: the LLM never sees credentials, and executor code runs sandboxed.
creel <command> [options]
Commands:
run <task> Run a task immediately
schedule Start cron scheduler for all tasks
list List available tasks
validate <task> Validate a task YAML file
daemon ... Manage daemon lifecycle (start|stop|status|install|uninstall)
attach Attach TUI client to running daemon
send <message> Send one message via daemon API
audit Query the guardian audit log
Global options:
-v, --verbose Enable verbose/debug output
--containers Run executors/LLM in Docker containers (all commands)
--tasks-dir PATH Tasks directory (default: tasks/)
--agent-config PATH Path to agent.yaml (default: agent.yaml)
--json-logs Output structured JSON log lines (for production)
--no-judge Disable the LLM judge to save API calls during development
Run options:
--dry Render prompt only, skip LLM and output
Daemon start options:
--socket-path PATH Unix socket path (default: ~/.creel/daemon.sock)
--pid-file PATH PID file path (default: ~/.creel/daemon.pid)
--log-file PATH Daemon log file (default: ~/.creel/daemon.log)
--channel TYPE Channel plugin: none, imessage, bluebubbles
--no-scheduler Disable scheduler in daemon runtime
--label NAME launchd label (default: com.creel.daemon)
--plist-path PATH launchd plist path
Attach options:
--sender-id ID Sender ID/session namespace (default: cli)
--new Start and attach to a new session
--resume ID Attach and resume a specific session
--socket-path PATH Unix socket path (default: ~/.creel/daemon.sock)
Send options:
--sender-id ID Sender ID/session namespace (default: cli)
--session-id ID Resume and send into a specific session
--socket-path PATH Unix socket path (default: ~/.creel/daemon.sock)
--stream Stream response events from daemon SSE endpoint
Audit options:
--tail N Show last N entries (default: 20)
--all Show all entries
--blocked Show only blocked input events
--denied Show only denied action events
--event TYPE Filter by event type (screen_input, validate_action, tool_result)
--tool NAME Filter by tool name
--since DATE Show entries since date (YYYY-MM-DD or YYYY-MM-DDTHH:MM:SS)
creel/
├── agent.yaml # Global agent config (tools, channels, sessions, guardian)
├── pyproject.toml
├── tasks/ # Task definitions (YAML)
├── policies/ # Guardian policy rules
├── secrets/ # Encrypted .env files (gitignored)
├── scripts/ # Utility scripts (encryption, OAuth setup)
├── src/
│ ├── taskrunner/
│ │ ├── cli.py # creel CLI entrypoint
│ │ ├── daemon/ # Daemon service, API, client contracts
│ │ ├── chat.py # Agent/session router
│ │ ├── tui.py # Textual TUI client
│ │ ├── scheduler.py # APScheduler integration
│ │ ├── session.py # JSON file-backed conversation sessions
│ │ └── channels/ # iMessage + BlueBubbles channel plugins
│ ├── bridge/
│ │ └── server.py # Host bridge HTTP API for macOS-native tools
│ ├── executors/ # Tool executors (weather, Google, Notes, etc.)
│ ├── guardian/ # Prompt-injection + policy protection pipeline
│ └── llm/ # Containerized LLM runner
├── tests/
├── approvals/ # Pending approval queue (gitignored)
└── workspace/ # Agent workspace memory (gitignored)
# First-time setup
pyenv install 3.12.12 # .python-version pins this
uv venv # creates .venv using pyenv's Python
source .venv/bin/activate
uv pip install -e ".[dev]"
# Optional: required for live ONNX export + classifier smoke tests
uv pip install -e ".[guardian]"
# Run tests
pytest