Metadata-Version: 2.4
Name: strands-icloud
Version: 0.1.0
Summary: 🍎 Strands tool for Apple/iCloud: mail, calendar, messages, contacts, photos, notes, reminders, Find My — local-first, MCP-ready, CLI included
Author: Cagatay Cali
License: MIT
License-File: LICENSE
Keywords: agents,ai-tools,apple,calendar,findmy,icloud,imessage,mail,mcp,photos,strands
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: MacOS X
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.10
Requires-Dist: caldav
Requires-Dist: keyring
Requires-Dist: osxphotos
Requires-Dist: pyicloud
Requires-Dist: strands-agents
Requires-Dist: strands-mcp-server
Description-Content-Type: text/markdown

<div align="center">

<img src="docs/hero.svg" alt="strands-icloud" width="880"/>

</div>

# 🍎 strands-icloud

**One Strands tool for the entire Apple ecosystem.** Mail, Calendar, Messages, Contacts, Photos, Notes, Reminders, Find My — local-first on macOS, remote-capable via standard protocols, exposable over MCP.

Built for [Strands Agents](https://github.com/strands-agents/sdk-python). Battle-tested inside [devduck](https://github.com/cagataycali/devduck).

```python
use_apple(action="messages.search", query="flight")
use_apple(action="mail.unread", limit=10)
use_apple(action="photos.search", query="drone")   # ← returns REAL image blocks the model can SEE
use_apple(action="findmy.locate", device="iPhone")
```

---

## Table of Contents

- [Architecture](#architecture)
- [Installation](#installation)
- [CLI](#cli)
- [Permissions Setup (macOS)](#permissions-setup-macos)
- [Credentials & Keychain](#credentials--keychain)
- [Action Reference](#action-reference)
  - [Messages](#-messages)
  - [Mail (local)](#-mail-local--mailapp)
  - [Calendar (local)](#-calendar-local--calendarapp)
  - [Contacts (local)](#-contacts-local--contactsapp)
  - [Photos](#-photos--osxphotos-with-image-blocks)
  - [Notes](#-notes--notesapp)
  - [Reminders](#-reminders--remindersapp)
  - [Find My](#-find-my--pyicloud)
  - [IMAP (remote)](#-imap-remote--imapmailmecom)
  - [CalDAV (remote)](#-caldav-remote--caldavicloudcom)
  - [CardDAV (remote)](#-carddav-remote--contactsicloudcom)
- [MCP Server](#mcp-server)
- [Parameter Reference](#parameter-reference)
- [Response Format](#response-format)
- [Design Decisions](#design-decisions)
- [Performance Notes](#performance-notes)
- [Troubleshooting](#troubleshooting)
- [Security Model](#security-model)
- [Known Limitations](#known-limitations)

---

## Architecture

Two access layers, one dispatch surface:

```mermaid
flowchart TB
    A["🍎 use_apple(action=...)"]:::hub

    A --> L
    A --> R
    A --> C

    subgraph L["🖥 LOCAL · on-Mac · zero auth"]
        direction TB
        AS["AppleScript bridge<br/>Mail · Calendar · Contacts<br/>Notes · Reminders · Messages (send)"]
        FS["Direct file access<br/>chat.db (SQLite, read-only)<br/>Photos library (osxphotos)"]
    end

    subgraph R["🌐 REMOTE · anywhere · app-specific password ✓"]
        direction TB
        IMAP["IMAP :993<br/>imap.mail.me.com"]
        CAL["CalDAV<br/>caldav.icloud.com"]
        CARD["CardDAV<br/>contacts.icloud.com"]
    end

    subgraph C["☁️ CLOUD · pyicloud · real password + 2FA"]
        FM["Find My<br/>devices · locate · play sound"]
    end

    classDef hub fill:#0a84ff,stroke:#0a84ff,color:#fff,font-weight:bold
    classDef default fill:#1c1c1e,stroke:#3a3a3c,color:#f5f5f7
    style L fill:#0e2a12,stroke:#32d74b,color:#f5f5f7
    style R fill:#0b1f33,stroke:#0a84ff,color:#f5f5f7
    style C fill:#2a0e1a,stroke:#ff375f,color:#f5f5f7
```

```mermaid
flowchart LR
    subgraph clients["Consumers"]
        DD["🦆 devduck<br/>(pipx inject)"]
        SA["Strands Agent<br/>(pip install)"]
        CC["Claude Code / Desktop<br/>Cursor · Kiro<br/>(MCP)"]
        TERM["Terminal<br/>(uvx strands-icloud)"]
    end

    DD --> UA["use_apple"]
    SA --> UA
    CC -->|"strands-icloud-mcp<br/>stdio / HTTP"| UA
    TERM -->|"CLI"| UA

    classDef default fill:#1c1c1e,stroke:#3a3a3c,color:#f5f5f7
    style UA fill:#0a84ff,stroke:#0a84ff,color:#fff,font-weight:bold
    style clients fill:#000,stroke:#3a3a3c,color:#98989d
```

**Why local-first?** Your Mac already syncs everything via iCloud. Reading local data means zero auth, zero rate limits, zero Apple-breaking-the-API risk, and full history (Messages back to day one). Remote protocols are the fallback for headless/off-Mac deployments; pyicloud is only used where there is no local equivalent (Find My).

### Module layout

```
strands_icloud/
├── __init__.py      # exports use_apple
├── use_apple.py     # @tool dispatch + all local backends (~850 lines)
├── remote.py        # IMAP / CalDAV / CardDAV clients (stdlib imaplib + caldav + raw DAV)
├── mcp.py           # MCP server entrypoint (strands-icloud-mcp)
└── cli.py           # terminal CLI (strands-icloud / uvx strands-icloud)
```

---

## Installation

### Into devduck (pipx)

```bash
pipx inject devduck git+ssh://git@github.com/cagataycali/strands-icloud.git
# or from a local clone:
pipx inject devduck /path/to/strands-icloud
```

Register the tool (add to `~/.zshrc`):

```bash
export DEVDUCK_TOOLS="$DEVDUCK_TOOLS;strands_icloud:use_apple"
```

### Standalone (any Strands agent)

```bash
pip install git+ssh://git@github.com/cagataycali/strands-icloud.git
```

```python
from strands import Agent
from strands_icloud import use_apple

agent = Agent(tools=[use_apple])
agent("what's on my calendar this week?")
```

### Dependencies

| Package | Used for | Required? |
|---------|----------|-----------|
| `strands-agents` | `@tool` decorator | yes |
| `osxphotos` | Photos library access | photos.* only |
| `pyicloud` | Find My | findmy.* only |
| `caldav` | CalDAV calendar client | caldav.* only |
| `keyring` | macOS Keychain credential lookup | optional (falls back to env) |
| `strands-mcp-server` | MCP entrypoint | MCP only |

Everything imports lazily — a missing optional dep only disables its domain, never the whole tool.


---

## CLI

Every action, straight from your terminal — no agent required. Ships as the `strands-icloud` console script:

```bash
# zero-install via uv (private repo → use --from git+ssh)
uvx --from git+ssh://git@github.com/cagataycali/strands-icloud.git strands-icloud reminders.list

# short alias once published to PyPI
uvx strands-icloud reminders.list

# or just use the installed script (pipx/pip)
strands-icloud messages.search --query flight
```

### Examples

```bash
# Messages
strands-icloud messages.chats -n 10
strands-icloud messages.search -q "baklava"
strands-icloud messages.send --to "+15517274110" --text "hey from the terminal"

# Mail — local (Mail.app) or remote (IMAP, faster on big inboxes)
strands-icloud mail.unread -n 10
strands-icloud imap.search -q invoice --folder INBOX -n 5
strands-icloud imap.read -q 4762                  # read by message ID

# Calendar — local or CalDAV (syncs to all devices)
strands-icloud calendar.events --days 14
strands-icloud caldav.create --title "Dentist" --start "2026-07-25 14:30" --calendar Home

# Photos — save image files locally
strands-icloud photos.search --label dog -n 3 --save ./out
strands-icloud photos.recent -n 5 --no-images     # metadata only

# Everything else
strands-icloud contacts.search -q mom
strands-icloud notes.read -q "meeting"
strands-icloud findmy.locate --device iPhone
strands-icloud actions                            # list every available action
strands-icloud mcp --http --port 8010             # start the MCP server

# Machine-readable output
strands-icloud reminders.list --json | jq '.content[0].text'
```

### CLI flags

| Flag | Maps to | Notes |
|------|---------|-------|
| `--query` / `-q` | `query` | search text or message/note ID |
| `--limit` / `-n` | `limit` | default 20 |
| `--chat` / `--folder` | `chat` | chat filter (messages) or IMAP folder |
| `--calendar` | `calendar_name` | |
| `--save DIR` | — | write photo images to disk |
| `--no-images` | `with_images=False` | metadata only, saves tokens/time |
| `--json` | — | structured output (image bytes omitted) |
| all others | same-named param | `--to --text --subject --body --title --start --end --due --days --person --label --device --from-date --to-date` |

Exit code is `0` on success, `1` on error — safe for scripting.

---

## Permissions Setup (macOS)

macOS gates each data source separately. You need:

### 1. Full Disk Access (required: Messages, speeds up Mail)

`System Settings → Privacy & Security → Full Disk Access` → add **the app that hosts the Python process**:

- Running in Terminal/iTerm/Ghostty → add that terminal app
- Running as a launchd service → add the Python binary itself
- Running inside an IDE → add the IDE

Restart the process after granting. Without FDA, `messages.*` fails with `unable to open database file`.

### 2. Automation permissions (per-app, auto-prompted)

First time you call each domain, macOS shows *"...wants to control Mail.app"* — click OK. One prompt per (host app × target app) pair. These land in `System Settings → Privacy & Security → Automation`.

If you accidentally denied one:

```bash
tccutil reset AppleEvents          # resets ALL automation grants
```

### 3. Photos library access

`osxphotos` reads the Photos SQLite directly — covered by Full Disk Access. Export of edited/iCloud-offloaded originals may trigger a Photos permission prompt on first use.

---

## Credentials & Keychain

### App-specific password (IMAP / CalDAV / CardDAV)

Generate at [account.apple.com → Sign-In and Security → App-Specific Passwords](https://account.apple.com/account/manage). Format: `xxxx-xxxx-xxxx-xxxx`.

**Store it in macOS Keychain (recommended):**

```bash
security add-generic-password -a "you@icloud.com" -s "strands-icloud" -w "xxxx-xxxx-xxxx-xxxx"
```

**Then only the username goes in your shell config:**

```bash
# ~/.zshrc
export ICLOUD_USERNAME="you@icloud.com"
```

Credential resolution order for remote protocols:

1. `ICLOUD_PASSWORD` env var (if set — e.g. CI, containers)
2. Keychain service `strands-icloud` (via `keyring`)
3. Keychain service `pyicloud` (legacy compat)
4. → error with instructions

### Real password (Find My only)

pyicloud drives the iCloud **web** API, which uses Apple's GSA/SRP handshake — **app-specific passwords do not work there**. If you want Find My:

```bash
security add-generic-password -a "you@icloud.com" -s "strands-icloud-real" -w "YOUR_REAL_PASSWORD"
```

Resolution order: `ICLOUD_PASSWORD_REAL` env → keychain `strands-icloud-real` → keychain `pyicloud`.

First login triggers 2FA once (run any `findmy.*` action interactively, or use the `icloud` CLI that ships with pyicloud). The session cookie is cached in `~/.pyicloud` and lasts ~weeks.

---

## Action Reference

Every action returns `{"status": "success"|"error", "content": [{"text": ...}, ...]}`. Photos actions append image blocks after the text block.

### 💬 Messages

Backend: **direct SQLite read** of `~/Library/Messages/chat.db` (opened `mode=ro` — never locks Messages.app), AppleScript for sending. Full history, fast, works even if Messages.app is closed.

| Action | Params | Description |
|--------|--------|-------------|
| `messages.list` | `chat` (optional filter: name/number/email substring), `limit` | Recent messages, chronological. `chat` matches chat_identifier, display_name, or handle |
| `messages.search` | `query` (required), `limit` | Full-text LIKE search over all message text, all time |
| `messages.chats` | `limit` | Conversations sorted by most recent activity, with message counts |
| `messages.send` | `to` (phone/email), `text` | Sends via Messages.app. Tries iMessage participant first, falls back to generic buddy (SMS relay if configured) |

```python
use_apple(action="messages.list", chat="+31638236164", limit=50)
use_apple(action="messages.search", query="baklava")
use_apple(action="messages.send", to="cagataycali@icloud.com", text="hey from my agent")
```

Timestamps are converted from Apple epoch (nanoseconds since 2001-01-01) automatically.

### 📧 Mail (local — Mail.app)

Backend: AppleScript. **Mail.app must be running** (`open -gja Mail` launches it hidden/background).

| Action | Params | Description |
|--------|--------|-------------|
| `mail.unread` | `limit` | Unread from inbox. Scans newest→oldest, capped at 400 most recent (see [Performance](#performance-notes)) |
| `mail.search` | `query` (required), `limit` | Subject OR sender contains query. Uses Mail's `whose` filter — slow on huge inboxes |
| `mail.read` | `query` (subject substring) | Full body (plain text) of first matching email |
| `mail.send` | `to`, `subject`, `body` | Composes and sends immediately via default account |

```python
use_apple(action="mail.unread", limit=10)
use_apple(action="mail.read", query="Run failed: CI")
use_apple(action="mail.send", to="x@y.com", subject="hi", body="sent by agent")
```

### 📅 Calendar (local — Calendar.app)

Backend: AppleScript across **all calendars**.

| Action | Params | Description |
|--------|--------|-------------|
| `calendar.events` | `days` (default 7), `limit` | Upcoming events across every calendar |
| `calendar.create` | `title`, `start` (`YYYY-MM-DD HH:MM`), `end` (optional, default +1h), `calendar_name` (optional, default first writable) | Creates event |

```python
use_apple(action="calendar.events", days=14)
use_apple(action="calendar.create", title="Dentist", start="2026-07-25 14:30", calendar_name="Home")
```

### 👤 Contacts (local — Contacts.app)

| Action | Params | Description |
|--------|--------|-------------|
| `contacts.search` | `query` (name substring), `limit` | Name, all phones, all emails |

### 🖼 Photos — osxphotos with image blocks

Backend: [osxphotos](https://github.com/RhetTbull/osxphotos) reading the Photos library database directly. **The killer feature: results include Bedrock Converse-API image content blocks, so the model literally sees the photos** (same mechanism as `strands_tools.image_reader`).

| Action | Params | Description |
|--------|--------|-------------|
| `photos.search` | `query`, `person`, `label`, `from_date`/`to_date` (`YYYY-MM-DD`), `limit`, `with_images` | All filters AND-combined. `query` matches filename/title/description/labels/persons/albums. `person` = Photos face names. `label` = Apple's on-device ML labels (e.g. "Baklava", "Document", "Sea") |
| `photos.recent` | `limit`, `with_images` | Newest photos |
| `photos.albums` | — | All album names |

```python
use_apple(action="photos.search", person="mom", from_date="2026-01-01")
use_apple(action="photos.search", label="dog", limit=5)
use_apple(action="photos.recent", limit=3, with_images=False)   # metadata only
```

**Image pipeline:** export original → if HEIC/oversized, convert via `sips` to JPEG (quality 80, max edge 1600px) → attach as `{"image": {"format": ..., "source": {"bytes": ...}}}`. Hard caps: **max 5 images per response**, 4 MB per image (context window protection). Exports land in `/tmp/apple_photos_export/`.

### 📝 Notes — Notes.app

| Action | Params | Description |
|--------|--------|-------------|
| `notes.list` | `limit` | Titles + modification dates (default account) |
| `notes.read` | `query` (title substring) | Full plaintext of first match |
| `notes.create` | `title`, `body` | New note in default account |

### ☑️ Reminders — Reminders.app

| Action | Params | Description |
|--------|--------|-------------|
| `reminders.list` | `limit` | Incomplete reminders with due dates |
| `reminders.create` | `title`, `due` (optional) | New reminder. Due date support is limited by AppleScript date quirks — title always works |

### 📱 Find My — pyicloud

Requires **real** Apple ID password (see [Credentials](#credentials--keychain)). Session cached after first 2FA.

| Action | Params | Description |
|--------|--------|-------------|
| `findmy.devices` | — | All devices: name, model, battery %, coordinates |
| `findmy.locate` | `device` (name substring) | Coordinates + Apple Maps link + accuracy + timestamp |
| `findmy.play_sound` | `device` | Ping the device (the classic "find my phone in the couch") |

```python
use_apple(action="findmy.locate", device="iPhone")
# 📍 Çağatay's iPhone: (52.37403, 4.88969)
# https://maps.apple.com/?ll=52.37403,4.88969
```

### 🌐 IMAP (remote — imap.mail.me.com)

Backend: Python stdlib `imaplib`, SSL port 993. **Works from any machine** — Linux server, container, CI. App-specific password is sufficient.

| Action | Params | Description |
|--------|--------|-------------|
| `imap.folders` | — | List all mailboxes (INBOX, Archive, Sent Messages, ...) |
| `imap.search` | `query`, `chat` (=folder, default INBOX), `limit` | Searches SUBJECT OR FROM OR BODY. Returns message IDs for `imap.read` |
| `imap.unread` | `chat` (=folder), `limit` | UNSEEN messages |
| `imap.read` | `query` (=message ID from search results), `chat` (=folder) | Full body. Prefers text/plain, falls back to tag-stripped HTML. All fetches use BODY.PEEK — **never marks messages as read** |

> Note: `chat` is reused as the folder parameter to keep the tool signature lean.

### 📅 CalDAV (remote — caldav.icloud.com)

| Action | Params | Description |
|--------|--------|-------------|
| `caldav.calendars` | — | List calendars |
| `caldav.events` | `days`, `limit` | Upcoming events across all calendars, expanded recurrences |
| `caldav.create` | `title`, `start`, `end`, `calendar_name` | Create event — **syncs to all your Apple devices in seconds** |

### 👤 CardDAV (remote — contacts.icloud.com)

| Action | Params | Description |
|--------|--------|-------------|
| `carddav.contacts` | `query` (optional name filter), `limit` | Full addressbook via raw DAV: PROPFIND principal discovery → addressbook-home-set → REPORT addressbook-query → vCard parse (FN/TEL/EMAIL) |

---

## MCP Server

Everything above, exposed over the [Model Context Protocol](https://modelcontextprotocol.io) — usable from Claude Code, Claude Desktop, Kiro, Cursor, or any MCP client. Pattern borrowed from [strands-cad](https://github.com/cagataycali/strands-cad).

```bash
strands-icloud-mcp                      # stdio (default — for desktop clients)
strands-icloud-mcp --http --port 8010   # streamable HTTP (multi-client)
strands-icloud-mcp --http --stateless   # stateless HTTP (horizontally scalable)
strands-icloud-mcp --agent-invocation   # also expose invoke_agent (full conversations)
strands-icloud-mcp --debug              # verbose logging (stderr)
```

### Claude Code

```bash
claude mcp add apple -- strands-icloud-mcp
```

### Claude Desktop

```json
{
  "mcpServers": {
    "apple": {
      "command": "strands-icloud-mcp"
    }
  }
}
```

> If installed inside a pipx venv, use the absolute path: `~/.local/pipx/venvs/devduck/bin/strands-icloud-mcp`.

### Implementation notes (learned the hard way in strands-cad)

- **stdout is sacred** in stdio mode — it's the protocol channel. All logging goes to stderr.
- The server calls the **raw tool function** (`mcp_server._tool_func`), not `agent.tool.mcp_server(...)`. Going through `agent.tool.*` marks the agent as mid-invocation; since stdio blocks forever, every nested tool call would then be rejected by the SDK.
- `--agent-invocation` is **off by default**: tools-only surface. Enable it to let MCP clients run full agentic conversations against an embedded Strands agent.

### Verified handshake

```
initialize            → serverInfo: strands-agent (1.27.x)
tools/list            → ["use_apple"]
tools/call use_apple(action="reminders.list")
                      → ☑️ 3 open reminders: ...
```

---

## Parameter Reference

Full signature — every param is optional except `action`:

| Param | Type | Default | Used by |
|-------|------|---------|---------|
| `action` | str | — | everything (required) |
| `query` | str | None | search actions, `mail.read`, `notes.read`, `imap.read` (=msg ID) |
| `to` | str | None | `messages.send`, `mail.send` |
| `text` | str | None | `messages.send` |
| `subject`, `body` | str | None | `mail.send`, `notes.create` |
| `title` | str | None | `calendar.create`, `notes.create`, `reminders.create`, `caldav.create` |
| `chat` | str | None | `messages.list` (chat filter), `imap.*` (folder name) |
| `person`, `label` | str | None | `photos.search` |
| `device` | str | None | `findmy.*` |
| `calendar_name` | str | None | `calendar.create`, `caldav.create` |
| `start`, `end` | str | None | event creation, `YYYY-MM-DD HH:MM` |
| `due` | str | None | `reminders.create` |
| `from_date`, `to_date` | str | None | `photos.search`, `YYYY-MM-DD` |
| `days` | int | 7 | `calendar.events`, `caldav.events` |
| `limit` | int | 20 | all list/search actions |
| `with_images` | bool | True | `photos.*` — set False for metadata-only (saves tokens) |

---

## Response Format

```python
# text-only actions
{
    "status": "success",
    "content": [{"text": "📧 5 unread:\n- [...] ..."}]
}

# photos with images — text block first, then image blocks
{
    "status": "success",
    "content": [
        {"text": "🖼 3 photos:\n- IMG_4321.HEIC | 2026-07-21 12:59 | labels: Baklava, ..."},
        {"image": {"format": "jpeg", "source": {"bytes": b"..."}}},
        ...
    ]
}

# errors are never raised — always structured
{
    "status": "error",
    "content": [{"text": "chat.db not found at ..."}]
}
```

---

## Design Decisions

1. **One mega-tool, dot-namespaced actions** (`use_apple(action="messages.search")`) instead of 25 separate tools. Keeps the agent's tool list small, matches the `use_aws`/`use_spotify`/`adb` convention, and lets one docstring teach the model the entire surface.
2. **Local-first, remote-fallback.** AppleScript + direct file reads beat any network API for latency, history depth, and reliability. Remote protocols exist for off-Mac deployment of the *same* tool.
3. **Read-only by default at the storage layer.** chat.db opens with `?mode=ro`; IMAP fetches use `BODY.PEEK`. Writes only happen through explicit send/create actions via Apple's own apps/APIs.
4. **Graceful degradation.** Optional deps import lazily inside the action branch. No osxphotos? Photos actions error cleanly with install instructions; everything else works.
5. **Structured errors, never exceptions.** The agent can read the failure and route around it (e.g., fall back from `mail.unread` to `imap.unread`).
6. **Image budget.** Max 5 images × ≤4 MB, auto-downscaled to 1600px JPEG q80. A photo dump should never blow up the context window.

---

## Performance Notes

| Operation | Cost | Notes |
|-----------|------|-------|
| `messages.*` (read) | ~10 ms | Direct SQLite, indexed |
| `mail.unread` | 2–30 s | AppleScript iteration, newest-first, capped at 400 recent messages. The naive `whose read status is false` filter took **>120 s on a 16k inbox** — that's why it scans instead |
| `mail.search` | 10 s–3 min | Uses Mail's `whose` filter (server-side in Mail), acceptable but not instant on big inboxes. Prefer `imap.search` for speed |
| `imap.search` | 1–3 s | iCloud does the searching |
| `calendar.events` | 5–60 s | AppleScript iterates every calendar; slow with many calendars. `caldav.events` is usually faster |
| `photos.search` | 5–30 s first call | osxphotos loads the library DB once per call; big libraries pay upfront. Filtering itself is in-memory and fast |
| `caldav/carddav` | 1–5 s | Plain HTTPS |

Rules of thumb: **Messages → local always. Mail search → IMAP. Calendar list → CalDAV. Photos → local (only option). Sends → local (Apple handles delivery).**

---

## Troubleshooting

| Symptom | Cause | Fix |
|---------|-------|-----|
| `unable to open database file` on `messages.*` | No Full Disk Access | Grant FDA to host app, **restart the process** |
| `Timeout on mail.*` | Mail.app not running or gigantic inbox | `open -gja Mail`, wait for index; use `imap.*` instead |
| `Not authorized to send Apple events` | Automation permission denied | System Settings → Automation, or `tccutil reset AppleEvents` |
| `PyiCloudFailedLoginException` on `findmy.*` | Using app-specific password | pyicloud needs the **real** password (keychain `strands-icloud-real`) |
| 2FA loop on findmy | Expired session | Re-auth interactively once; check `~/.pyicloud` is writable |
| `imap.* AUTHENTICATIONFAILED` | Bad/revoked app-specific password | Regenerate at account.apple.com, update keychain entry |
| Photos export returns nothing | Original offloaded to iCloud ("Optimize Mac Storage") | Tool retries with `use_photos_export=True` (slower, downloads); ensure disk space |
| Keychain prompt blocks headless run | keyring needs UI unlock | `security unlock-keychain` in session, or use env vars for that host |
| MCP client sees no tools | stdout polluted by a print | Run with `--debug`, check nothing but JSON-RPC goes to stdout |

Nuclear option for AppleScript weirdness: quit and relaunch the target app.

---

## Security Model

- **App-specific password** (keychain `strands-icloud`): grants mail/calendar/contacts via legacy protocols. Revocable at account.apple.com anytime without touching your real password. **Cannot** be used to log into iCloud web, change account settings, or access Find My.
- **Real password** (keychain `strands-icloud-real`, optional): only needed for Find My. Never written to disk by this tool; keychain access requires your login session.
- **No plaintext secrets in dotfiles**: only `ICLOUD_USERNAME` lives in `~/.zshrc`.
- **Local data never leaves the machine** except as tool results into your model provider (mind what you query if using a cloud LLM — that's the actual data boundary here).
- chat.db is opened read-only; the tool physically cannot modify or delete message history.

---

## Known Limitations

- **macOS only** for local actions (AppleScript, chat.db, osxphotos). Remote actions (`imap.*`, `caldav.*`, `carddav.*`) run anywhere Python does.
- `reminders.create` due dates: AppleScript date construction is unreliable across locales; reminders are created without due dates for now.
- `messages.send` requires Messages.app signed in; SMS relay needs an iPhone with Text Message Forwarding enabled.
- `mail.unread` scans the 400 most recent inbox messages — unread mail older than that won't appear (use `imap.unread` for exhaustive).
- Group chat display names in Messages may be empty (falls back to chat identifier).
- pyicloud is an unofficial client of a private API — Apple can (and occasionally does) break it. Local + DAV paths are unaffected.
- HEIC → JPEG conversion via `sips` loses HDR/depth metadata (irrelevant for model vision).

---

## Development

```bash
git clone git@github.com:cagataycali/strands-icloud.git
cd strands-icloud
uv venv && source .venv/bin/activate
uv pip install -e .

# smoke test without an agent
python -c "
from strands_icloud.use_apple import use_apple
fn = getattr(use_apple, '_tool_func', use_apple)
print(fn(action='reminders.list', limit=3))
"

# reinject into devduck after changes
pipx inject devduck . --force
```

## License

MIT © Çağatay Çalı
