email-manual · v1.1.0 · 2026-08-10 · sha256 ee929dd59d65cebe
email-manual v1.1.0A
Immutable. This exact content is served forever at /api/v1/blob/ee929dd59d65cebe.
---
name: email-manual
description: >
Operational guide for the `email` tool — LingTai email protocol within your
`.lingtai/` network. Covers send/check/read/dismiss/reply/reply_all/search/
archive/delete/contacts, reply discipline, bare-path addressing (`human`,
`mimo-1`) and `peer`/`abs` modes, self-send notes that survive molt, delayed
self-send time capsules, the full-body persistent notification contract, and
the 50,000-char send cap. INTERNAL only — real internet email is `mcp-manual`;
recurring schedules are `shell-manual`. Calls use the LTP v2
action/input/reasoning envelope.
version: 1.1.0
tags: [capabilities, email, communication]
last_changed_at: "2026-07-27T00:00:00Z"
related_files:
- src/lingtai/tools/email/__init__.py
- src/lingtai/tools/email/_family_schema.py
- src/lingtai/tools/email/manager.py
- src/lingtai/tools/email/primitives.py
- src/lingtai/tools/email/ANATOMY.md
- src/lingtai/tools/email/CONTRACT.md
maintenance: |
Tracks the routed source/resources it summarizes; update when the underlying capability or its sub-references change.
---
# Email Manual — the internal `email` tool
> LingTai email protocol between agents in your `.lingtai/` network. Not the internet. No IMAP, no SMTP, no DNS. Messages are JSON files written under `mailbox/inbox/` of the recipient agent and `mailbox/sent/` of the sender.
## 0. How to call it — the envelope
Every call takes `action` + `input` + `reasoning`, where `input` is the strict
argument object **for the selected action only**:
```python
email(action="check", input={}, reasoning="check for new mail")
email(action="read", input={"email_id": ["<id>"]}, reasoning="read the request")
email(action="send", input={"address": "human", "message": "done"},
reasoning="report completion")
```
Two rules follow from that, and they are enforced at dispatch, not just in the
schema:
- **Each argument belongs to one action.** `query` only exists on `search`,
`filter`/`n` only on `check`, `attachments`/`delay`/`mode` only on `send`. A
key from another action's branch is refused *before* anything is read, sent,
or marked read — you get `unsupported email input field`, not a silent
ignore.
- **`reasoning` and `summarize` are root fields, never inside `input`.**
`reasoning` is required and is recorded in your diary; `summarize` is the
optional result post-processing control.
**`summarize` guidance for this family.** `check`, `read`, and `search` are
**bulky-result** actions — mailbox listings and full bodies can be long, so
`summarize=true` is reasonable when you only need the gist. Leave it false
when you need exact IDs, addresses, or verbatim body text, because you will
act on those literally. Every other action (`send`, `dismiss`, `reply`,
`reply_all`, `archive`, `delete`, the four contact verbs) is
**short-result**: its receipt is small and meant to be read exactly, so leave
`summarize` false. Call `manual` itself with `summarize=false` so procedure
and constraints are not summarized away.
**Settings:** `email` supports no settings file at either level — there is no
`settings/email.json` and no `settings/email.<action>.json`. Its behavior is
governed by this manual and the tool's own defaults.
## 1. What is internal email
The `email` tool moves messages as files between agents that share a `.lingtai/` directory tree:
- `email(action="send")` records sender-side outbox/sent state, then starts one `_mailman` daemon thread per recipient. Even when `delay=0`, the tool may return `status: "sent"` before that background delivery attempt finishes.
- Delivery to a normal agent accepts only a target with `.agent.json` and a fresh `.agent.heartbeat` (normally younger than two seconds); human recipients (`admin: null`) skip the heartbeat check. If the target is refreshing/relaunching and its heartbeat is not fresh yet, delivery is refused as `not running`; no recipient inbox entry is queued for later. The eventual failure is surfaced to the sender as an `email.bounce` event in `.notification/system.json`.
- Read state lives in the recipient's `mailbox/read.json` (a set of message IDs).
- The kernel mirrors current unread mail into `.notification/email.json`, which surfaces as a notification block read via `notification(action="check")` — that's how you find out new mail arrived.
> **Refresh/relaunch window.** A target `lingtai run` process can already be visible in `ps` before it publishes a fresh heartbeat. In that interval, internal email may bounce for liveness while a CPR attempt's child launch exits because the CLI duplicate-process guard finds the existing same-workdir PID. Those results are compatible: do not stack CPR attempts. Wait for the target heartbeat to become fresh, then retry the email once. Use CPR only if the existing startup exits or fails to become live.
**If a request involves `@gmail.com`, `@outlook.com`, IMAP folders, or anything that needs to leave the machine, the right tool is the `imap` MCP addon — see the `mcp-manual` skill, not this one.**
| Feature | Internal Email (this skill) | IMAP (see `mcp-manual`) |
|-----------------|--------------------------------------------------------|--------------------------------------------------|
| What | LingTai email protocol within `.lingtai/` network | Real email via IMAP/SMTP (Gmail, Outlook, etc.) |
| Address format | Bare path (e.g. `human`, `mimo-1`) | `@` address (e.g. `alice@gmail.com`) |
| Tool | `email` (intrinsic) | `imap` (MCP server, `imap` addon) |
| Reply policy | Always reply on the same channel | Requires confirmation for unknown senders |
| Persistence | Survives molt, lives in working directory | External mailbox, managed by IMAP server |
| Use case | Agent-to-agent communication, self-send, time capsules | Real-world email integration |
## 2. Addressing
Addresses are **bare directory names** inside `.lingtai/`. No `@`, no domains, no slashes.
| Address | Meaning |
|----------------------|----------------------------------------------------------|
| `human` | The human's mailbox at `.lingtai/human/` |
| `mimo-1` | An agent whose working directory is `.lingtai/mimo-1/` |
| `<your-own-name>` | Self — creates an inbox entry that survives molt (§6) |
Multiple recipients: pass `address` as a string or a list, plus optional `cc` / `bcc`.
```python
email(action="send",
input={"address": ["mimo-1", "scribe"], "cc": ["human"],
"subject": "status", "message": "ready"},
reasoning="report status to the team")
```
To discover who exists: glob `.lingtai/*/.agent.json` from a shell. Use the `agent_name` field of each as the address. Do not invent addresses — a refused dispatch produces an `email.bounce` event and queues no recipient inbox entry.
### `mode` — peer vs abs address resolution
`mode` is the **address mode for `send`**, not an output-verbosity knob. Almost always leave it unset:
- `peer` (default) — `address` is a bare agent name resolved against your own network folder. Correct for the human, fellow agents, and your own avatars.
- `abs` — `address` is a literal absolute path to another agent's working directory, e.g. `/Users/alice/projectB/.lingtai/外援`. Use it only to reach an agent in a *different* `.lingtai/` network on the same machine. It embeds a `_return_route` so replies resolve unambiguously, and it does **not** bypass the handshake: the recipient still needs a valid `.agent.json` and a fresh heartbeat.
Any other value is rejected with `invalid mode`.
## 3. Reply discipline — the one rule you cannot break
> **Reply on the channel the message arrived on.**
If a message arrived via `email`, reply with `email(action="reply", ...)`. Do not pivot to `pigeon`, IM, or a fresh `send`. If you must change channels (e.g. the original sender is dead), explain that pivot in the reply body before sending it elsewhere.
**Prefer `reply` and `reply_all` over `send`** even when you know the addresses:
- `reply` preserves the thread linkage (the original `id` lands in the new message's `in_reply_to`), so a future `search` or `check` shows the conversation as related.
- `reply_all` mirrors the original recipient set automatically, so you don't drop someone who was `cc`'d.
- `send` is for **new** conversations.
Doing it the other way scatters conversations across orphaned threads and is the single most common confusion source in human-facing audits.
## 4. Sender display name resolution
Inbound mail carries an `identity` block:
```json
"identity": {
"sender_name": "mimo-1",
"sender_nickname": "MiMo",
"via": "lingtai" | "claude-code" | ...
}
```
When you mention the sender in a reply body or in a summary you give the human, use `sender_nickname` if it is set and non-empty; otherwise fall back to `sender_name`. The address itself (`from`) is for routing, not for prose.
## 5. Actions — full surface
| Action | Purpose | Required args |
|-------------------|--------------------------------------------------------------------|----------------------------------------------------|
| `send` | Start a new thread; body hard-capped at 50,000 characters | `address`, `subject`, `message` |
| `check` | List inbox (newest-first), with optional `filter={...}` and `n=N` | — |
| `read` | Fetch source-of-truth record/attachments and mark read | `email_id` (list of IDs) |
| `dismiss` | Mark read **without** re-fetching body — preferred after persistent content is handled | `email_id` (list of IDs) |
| `reply` | Reply to sender only; preserves thread linkage | `email_id`, `message` |
| `reply_all` | Reply to sender + all original recipients minus self | `email_id`, `message` |
| `search` | Search across inbox/sent/archive by `query` + `filter` | `query` (and/or `filter`) |
| `archive` | Move from inbox to archive folder (keeps thread, removes from view)| `email_id` |
| `delete` | Permanently delete (inbox/archive only; `sent` is read-only) | `email_id` |
| `contacts` | List your address book | — |
| `add_contact` | Add or upsert by `address` | `address`, `name`, optional `note` |
| `remove_contact` | Remove by `address` | `address` |
| `edit_contact` | Update fields | `address`, plus the fields to change |
### `read` vs `dismiss` — when to use which
Unread email bodies are injected in full into `_meta.agent_meta.notifications.persistent.email` (up to the 50,000-character send-layer cap below). You do **not** need `read` merely to see ordinary message text. When the whole persistent notification envelope exceeds its model-visible cap (default 10,000 characters; live-configurable via the `LINGTAI_NOTIFICATION_MAX_CHARS` environment variable, clamped to at most 10,000), the full block is spilled to `<workdir>/logs/notification-overflow-<ts>.json` and the block carries an `overflow` marker with the path; read that file (or the producer tool) for the full bodies. After you have handled the visible content, prefer `dismiss`: same read-state effect, no body returned, and the unread notification clears once count reaches zero.
Use `read` when you need to refresh the source-of-truth mailbox record, inspect attachment/metadata details, or deliberately fetch the producer state before a reply/audit. Use `reply`/`reply_all` when answering. Failing to `dismiss`, `read`, `archive`, or `delete` a handled mail keeps the notification reminding you on every heartbeat.
These are the producer-owned verbs for the `email` notification channel; a generic `notification(action='dismiss_channel', input={'channel': 'email', 'force': null, 'reason': null}, reasoning='...')` would clear only the mirror. See `notification-manual` → `reference/dismissal-safety/SKILL.md`.
### 50,000-character send cap
Internal email bodies are capped at 50,000 characters at **send time**. The reason is architectural: unread bodies are injected in full into the persistent notification stream, so the reading/notification layer should not guess, summarize, or truncate ordinary mail. If a message is too large for that guarantee, `send` refuses it with `limit_chars` and `actual_chars`; shorten the body, attach a file, or put bulky material somewhere else and mail a pointer.
### `check` filter
`check` accepts a structured `filter` for narrowing the inbox without round-tripping:
```python
email(action="check",
input={
"n": 20,
"filter": {
"unread_only": True,
"from": "mimo-1",
"subject": "status",
"contains": "blocker",
"after": "2026-05-18T00:00:00Z",
"has_attachments": False,
"sort": "newest",
"truncate": 500, # body preview length per entry
},
},
reasoning="find unread blockers from mimo-1")
```
`filter` and `n` belong to `check` alone. Any field you do not need may simply
be omitted (or sent as `null`, which is treated the same as omitting it).
Use this aggressively. Pulling 100 messages with `check` and then post-filtering in your head is wasteful.
## 6. Self-send — persistent notes that survive molt
Mail sent to **your own address** lands in your own inbox. It is marked self-sent, but otherwise behaves like any other unread message — meaning:
- It survives a molt (because it lives in `mailbox/inbox/`, not in chat history).
- It surfaces in the persistent unread notification lane until you `dismiss`, `read`, `archive`, or `delete` it.
- It can be `search`ed by the future you.
Use this for: TODOs you want to remember after a memory rotation, breadcrumbs about decisions, "hand-off to self" notes during a long task. See the recipes in §10.
## 7. Time capsule — delayed self-send
Add `"delay": <seconds>` to `send`'s `input` to defer delivery. The outbox entry is written immediately; the `_mailman` daemon thread sleeps until the deadline, then dispatches. Combined with self-send this gives you cheap one-shot alarms without standing up a cron; the notification is delivered exactly once.
Use delayed self-send as a **future nudge**, not delayed tool execution. The message should tell the future you what to inspect and why, then let that future turn decide with current context whether to run `shell(action="poll")`, `daemon(check)`, a channel read, or nothing at all. It is one of the escape hatches when a repeated-call `_advisory` says you may be polling the same thing: write one concrete reminder, then yield/idle.
**Recurring work is not an email feature.** The internal `email` tool has no recurring scheduling API. For repeating reminders or agent-side scheduled work, use a host scheduler (cron, launchd, systemd, or an event watcher) via `shell-manual` → `reference/scheduled-work/SKILL.md`; for a single lightweight wakeup that does not need mailbox state, `shell-manual` → `reference/notification-reminders/SKILL.md` owns the `.notification/cron.json` pattern.
## 8. Privacy — internal IDs
The mailbox UUID (`email_id`) is **local to your working directory**. Never paste a raw mailbox ID into a message to another agent or to the human — it has no meaning outside your tree and reveals nothing useful. Refer to messages by `subject` + `from` + approximate time.
The exception: when you call `email(action="read"/"dismiss"/"reply", input={"email_id": [...]})`, you pass IDs you read out of *your own* persistent notification or mailbox listing. That's internal plumbing, fine.
## 9. Addon ownership — what this skill does NOT cover
This skill is the manual for the **kernel-intrinsic `email` tool** only. Adjacent surfaces live elsewhere:
| Want to … | Use |
|----------------------------------------------------|----------------------------------------------|
| Send/receive real internet email (Gmail, etc.) | `mcp-manual` → `imap` or `cloud_mail` addon |
| Send Telegram / Feishu / WeChat / WhatsApp messages | `mcp-manual` → respective MCP addon |
| Send a notification-style ping to another agent | This skill — it IS the notification channel |
| Schedule a one-off wake-up of your own loop | This skill, `delay` + self-send (§7) |
| Run recurring agent-side work | Host scheduler / event watcher via `shell-manual` |
Those MCP addons are separate processes with separate auth surfaces and failure modes; each ships its own manual. Do not try to use the `email` tool for an external address: an unknown target is refused without creating a recipient inbox entry and is reported through `email.bounce`.
## 10. Quick reference — common recipes
```python
# Handle content already injected into notification_persistent.email
email(action="dismiss", input={"email_id": ["<id-from-persistent-email>"]},
reasoning="clear mail already handled from the notification")
# Need source-of-truth refresh / attachments / metadata
email(action="read", input={"email_id": ["<id-from-persistent-email>"]},
reasoning="read the full body and attachments")
# Optional mailbox listing / filters
email(action="check", input={"n": 20, "filter": {"unread_only": True}},
reasoning="list pending unread mail")
# Thread-preserving reply
email(action="reply", input={"email_id": ["<id>"], "message": "ack, looking now"},
reasoning="acknowledge on the channel it arrived on")
# Self-note that survives molt
email(action="send",
input={"address": "<self>", "subject": "resume",
"message": "Picked the Helmholtz approach; see paper/drafts/2026-05-18.md"},
reasoning="leave a breadcrumb for after the next molt")
# 5-minute timer
email(action="send",
input={"address": "<self>", "delay": 300,
"subject": "ding", "message": "check the deploy"},
reasoning="set a one-shot nudge to re-check the deploy")
# Reach an agent in another .lingtai/ network on this machine
email(action="send",
input={"mode": "abs", "address": "/Users/alice/projectB/.lingtai/外援",
"subject": "cross-network", "message": "ping"},
reasoning="contact an agent in another network by absolute path")
# Find related mail
email(action="search", input={"query": "helmholtz"},
reasoning="find prior discussion of this approach")
# Address book
email(action="add_contact",
input={"address": "mimo-1", "name": "MiMo (vision)",
"note": "reachable for image-analysis requests"},
reasoning="record a durable contact")
```
Note that `search` takes `query` (and optionally `folder`) — the `filter`
object belongs to `check`, and passing it to `search` is refused at dispatch.
---
> **Found a bug or issue?** If you encounter any problems with this skill, load the `lingtai-issue-report` skill and follow its instructions to report it.
## Cleanup / Footprint
Internal email persists under the agent mailbox: inbox/archive/sent message
files, attachments, contacts, and read/archive state. Mail is also memory: do not
blindly delete it. Prefer `email(archive)` or `email(delete)` verbs over `rm`,
and never delete mail that is the only copy of a decision, handoff, or
attachment the human may expect you to retain.
Footprint check (read-only, records the audit):
```bash
python3 - <<'PY'
import json, time
from pathlib import Path
agent = Path.cwd()
roots = [p for p in [agent / "mailbox", agent / "mail", agent / "email"] if p.exists()]
def size(p):
return p.stat().st_size if p.is_file() else sum(f.stat().st_size for f in p.rglob("*") if f.is_file())
rows = [(p, size(p)) for r in roots for p in ([r] if r.is_file() else r.iterdir())]
total = sum(s for _, s in rows)
print(f"internal email roots: {[str(r) for r in roots]}")
print(f"top-level entries: {len(rows)}; bytes: {total}")
for p, s in sorted(rows, key=lambda r: r[1], reverse=True)[:20]: print(f"{s:>12} {p}")
log = agent / "logs" / "cleanup.jsonl"; log.parent.mkdir(parents=True, exist_ok=True)
log.open("a", encoding="utf-8").write(json.dumps({"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), "tool": "email", "dry_run": True, "candidates": len(rows), "bytes": total, "human_approved": False, "summary": "internal email footprint audit"}) + "\n")
PY
```
Recommended cadence: when large attachments are exchanged, before exporting or
archiving a project, and quarterly for long-lived agents. Cleanup requires a
dry-run report plus explicit user consent; after deletion/archive, append an
`apply` record to `logs/cleanup.jsonl`.