Metadata-Version: 2.5
Name: agentdag
Version: 0.1.0
Summary: Coordinator that runs a small graph of AI-agent nodes with bounded spend, mechanical gates and journaled state
Project-URL: Homepage, https://github.com/bitranox/agentdag
Project-URL: Repository, https://github.com/bitranox/agentdag.git
Project-URL: Issues, https://github.com/bitranox/agentdag/issues
Author-email: bitranox <bitranox@gmail.com>
License: MIT
License-File: LICENSE
Keywords: agents,claude,codex,coordinator,dag
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Typing :: Typed
Requires-Python: >=3.12
Requires-Dist: btx-lib-mail>=4.0.0
Requires-Dist: claude-agent-sdk>=0.2.164
Requires-Dist: click>=8.5.0
Requires-Dist: filelock>=4.0.12
Requires-Dist: lib-cli-exit-tools>=2.4.0
Requires-Dist: lib-layered-config>=7.0.1
Requires-Dist: lib-log-rich>=6.4.1
Requires-Dist: orjson>=3.13.0
Requires-Dist: pydantic>=2.13.5
Requires-Dist: python-dotenv>=1.2.4
Requires-Dist: pyyaml>=6.0.3
Requires-Dist: rich-click>=1.9.9
Provides-Extra: dev
Requires-Dist: bandit>=1.9.4; extra == 'dev'
Requires-Dist: build>=1.6.1; extra == 'dev'
Requires-Dist: click>=8.5.0; extra == 'dev'
Requires-Dist: httpx2>=2.13.1; extra == 'dev'
Requires-Dist: hypothesis>=6.168.5; extra == 'dev'
Requires-Dist: import-linter>=2.15; extra == 'dev'
Requires-Dist: jaraco-context>=6.1.2; extra == 'dev'
Requires-Dist: jsonschema>=4.26.0; extra == 'dev'
Requires-Dist: pip-audit>=2.10.1; extra == 'dev'
Requires-Dist: pynacl>=1.6.2; extra == 'dev'
Requires-Dist: pyright[nodejs]>=1.1.414; extra == 'dev'
Requires-Dist: pytest-cov>=7.1.0; extra == 'dev'
Requires-Dist: pytest>=9.1.1; extra == 'dev'
Requires-Dist: python-multipart>=0.0.32; extra == 'dev'
Requires-Dist: rtoml>=0.13.0; extra == 'dev'
Requires-Dist: ruff>=0.16.10; extra == 'dev'
Requires-Dist: textual>=8.2.8; extra == 'dev'
Requires-Dist: twine>=7.0.0; extra == 'dev'
Requires-Dist: types-pyyaml>=6.0.12.20260906; extra == 'dev'
Requires-Dist: urllib3>=2.8.0; extra == 'dev'
Requires-Dist: virtualenv>=21.14.5; extra == 'dev'
Requires-Dist: wheel>=0.48.0; extra == 'dev'
Description-Content-Type: text/markdown

# agentdag

<!-- Badges -->
[![CI](https://github.com/bitranox/agentdag/actions/workflows/default_cicd_public.yml/badge.svg)](https://github.com/bitranox/agentdag/actions/workflows/default_cicd_public.yml)
[![CodeQL](https://github.com/bitranox/agentdag/actions/workflows/codeql.yml/badge.svg)](https://github.com/bitranox/agentdag/actions/workflows/codeql.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Open in Codespaces](https://img.shields.io/badge/Codespaces-Open-blue?logo=github&logoColor=white&style=flat-square)](https://codespaces.new/bitranox/agentdag?quickstart=1)
[![PyPI](https://img.shields.io/pypi/v/agentdag.svg)](https://pypi.org/project/agentdag/)
[![PyPI - Downloads](https://img.shields.io/pypi/dm/agentdag.svg)](https://pypi.org/project/agentdag/)
[![Code Style: Ruff](https://img.shields.io/badge/Code%20Style-Ruff-46A3FF?logo=ruff&labelColor=000)](https://docs.astral.sh/ruff/)
[![codecov](https://codecov.io/gh/bitranox/agentdag/graph/badge.svg?token=UFBaUDIgRk)](https://codecov.io/gh/bitranox/agentdag)
[![Maintainability](https://qlty.sh/badges/041ba2c1-37d6-40bb-85a0-ec5a8a0aca0c/maintainability.svg)](https://qlty.sh/gh/bitranox/projects/agentdag)
[![security: bandit](https://img.shields.io/badge/security-bandit-yellow.svg)](https://github.com/PyCQA/bandit)


`agentdag` is a coordinator for small graphs of AI-agent nodes. It dispatches each node, gates what the node produced on something mechanical, and branches only on typed records, never on prose an agent wrote.

The graph A baseline is the first coordinator graph it ships (0.0.1 on PyPI is the scaffold alone): a fleet migration that gives every repository its own worktree, its own agent and its own gate run, and pushes what passed to scratch clones after one console approval. It deliberately has no journal, no token cap and no unattended approve. Those are later milestones, and what their absence costs is what this baseline is for. See [Graph A: fleet migration](#graph-a-fleet-migration) below.

- CLI entry point styled with rich-click (rich output + click ergonomics).
- Layered configuration system with lib_layered_config (defaults → app → host → user → .env → env).
- Rich structured logging with lib_log_rich (console, journald, eventlog, Graylog/GELF).
- Exit-code and messaging helpers powered by lib_cli_exit_tools.
- Metadata helpers ready for packaging, testing, and release automation.


### Python 3.12+ Baseline

- The project targets **Python 3.12 and newer**.
- Runtime dependencies require current stable releases (`rich-click>=1.9.6`
  and `lib_cli_exit_tools>=2.2.4`). Dev dependencies (pytest, ruff, pyright,
  bandit, etc.) specify minimum version constraints to ensure compatibility.
- CI workflows exercise GitHub's rolling runner images (`ubuntu-latest`,
  `macos-latest`, `windows-latest`) and cover CPython 3.12 through 3.14
  alongside the latest available 3.x release provided by Actions.

---

## Install - recommended via uv

[uv](https://docs.astral.sh/uv/) is an ultrafast Python package manager written in Rust (10-20x faster than pip/poetry).

### Install uv (if not already installed) 
```bash
# macOS/Linux
curl -LsSf https://astral.sh/uv/install.sh | sh

# Copy the actual binaries
cp /root/.local/bin/uv /usr/local/bin/uv
cp /root/.local/bin/uvx /usr/local/bin/uvx

# Ensure world-executable
chmod 755 /usr/local/bin/uv /usr/local/bin/uvx

# Windows (PowerShell)
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
```

### One-shot run (no install needed)

```bash
uvx agentdag@latest --help
```

### Persistent install as CLI tool

```bash
# Install latest python
install_latest_python_gcc.sh
# pin uv to the latest python
uv python pin /opt/python-latest/bin/python3
# One-time install, persists from the git repo
uv tool install --python /opt/python-latest/bin/python3 --from "git+https://github.com/bitranox/agentdag.git" agentdag
# or One-time install, persists from PyPi
uv tool install --python /opt/python-latest/bin/python3 agentdag
# Update (requires network)
uv tool upgrade agentdag
# Run
agentdag --help
```

### Persistent install as CLI tool
```bash
# install the CLI tool (isolated environment, added to PATH)
uv tool install agentdag

# upgrade to latest
uv tool upgrade agentdag
```

### Install as project dependency

```bash
uv venv && source .venv/bin/activate   # Windows: .venv\Scripts\Activate.ps1
uv pip install agentdag
```

For alternative install paths (pip, pipx, source builds, etc.), see
[INSTALL.md](INSTALL.md). All supported methods register the `agentdag` command on your PATH.

---

## Configuration

See [CONFIG.md](CONFIG.md) for detailed documentation on the layered configuration system, including precedence rules, profile support, and customization best practices.

---

## Quick Start

```bash
# Install
uv tool install agentdag

# Verify
agentdag --version

# deploy config files
agentdag deploy-config --target app

# Try it out
agentdag hello
agentdag info
agentdag config
```

---

## Usage

The CLI leverages [rich-click](https://github.com/ewels/rich-click) so help output, validation errors, and prompts render with Rich styling while keeping the familiar click ergonomics.

### Available Commands

```bash
# Graph A fleet migration (see the section below)
agentdag graph-a scratch real-repos.txt
agentdag graph-a run /tmp/agentdag-scratch/REPOS.txt brief.md

# Display package information
agentdag info

# Greeting and error-handling demos
agentdag hello
agentdag fail
agentdag --traceback fail

# Configuration management
agentdag config                         # Show current configuration
agentdag config --format json           # Show as JSON
agentdag config --section lib_log_rich  # Show specific section
agentdag config --profile production    # Use a named profile

# Deploy configuration templates to target directories
# Without profile:
agentdag config-deploy --target app    # → /etc/xdg/{slug}/config.toml
agentdag config-deploy --target host   # → /etc/xdg/{slug}/hosts/{hostname}.toml
agentdag config-deploy --target user   # → ~/.config/{slug}/config.toml

# With profile:
agentdag config-deploy --target app --profile production   # → /etc/xdg/{slug}/profile/production/config.toml
agentdag config-deploy --target host --profile production  # → /etc/xdg/{slug}/profile/production/hosts/{hostname}.toml
agentdag config-deploy --target user --profile production  # → ~/.config/{slug}/profile/production/config.toml

# With custom permissions (POSIX only):
agentdag config-deploy --target user --file-mode 640       # Files with rw-r----- (640)
agentdag config-deploy --target user --dir-mode 750        # Directories with rwxr-x--- (750)
agentdag config-deploy --target app --no-permissions       # Skip permission setting (use umask)

# Profile names: alphanumeric, hyphens, underscores; max 64 chars; must start with letter/digit
# See CONFIG.md for full validation rules

# Deploy configuration examples
agentdag config-generate-examples --destination ./examples

# Load configuration from an explicit .env file (skips upward directory search)
agentdag --env-file /path/to/.env config
agentdag --env-file ./environments/production.env send-notification ...

# Override configuration at runtime (repeatable --set)
agentdag --set lib_log_rich.console_level=DEBUG config
agentdag --set email.smtp_hosts='["smtp.example.com:587"]' config --format json

# Logging demo
agentdag logdemo
agentdag --set lib_log_rich.console_level=DEBUG logdemo

# Send email
agentdag send-email \
    --to recipient@example.com \
    --subject "Test Email" \
    --body "Hello from bitranox!"

# Send email with HTML body and attachments
agentdag send-email \
    --to recipient@example.com \
    --subject "Monthly Report" \
    --body "See attached." \
    --body-html "<h1>Report</h1><p>Details in the PDF.</p>" \
    --attachment report.pdf

# Send plain-text notification
agentdag send-notification \
    --to ops@example.com \
    --subject "Deploy OK" \
    --message "Application deployed successfully"

# All commands work with any entry point
python -m agentdag info
uvx agentdag info
```

---

## Graph A: fleet migration

Graph A applies one brief to a fleet of repositories: each repository gets its own worktree, its own agent node and its own gate run, and only what passed the gate is offered for pushing.

Two commands, in the order they are used:

```bash
# 1. read the real repositories once and mirror each into the scratch fleet
agentdag graph-a scratch real-repos.txt --scratch /tmp/agentdag-scratch

# a mirror that already exists is reused as it stands, however stale;
# --refresh throws it away and reads the real repository again
agentdag graph-a scratch real-repos.txt --refresh

# 2. run the graph over the mirrors that step wrote
agentdag graph-a run /tmp/agentdag-scratch/REPOS.txt brief.md --parallel 2 --model sonnet
```

`real-repos.txt` holds one path per line; blank lines and lines starting with `#` are ignored. `brief.md` is the change to make, handed to every node as its system prompt. `graph-a scratch` writes the list of mirrors to `<scratch>/REPOS.txt`, which is what `graph-a run` reads.

`graph-a run` prints the run directory as it starts and the path of its `tally.json` when it finishes. That file holds one row per repository, with the gate's exit code and the node's turn and token counts.

### The scratch-clone rule

A run never writes to a real repository.

- `graph-a scratch` reads each real repository exactly once, with `git clone --mirror`.
- The bare clones under `<scratch>/origin/` are the only push targets a run accepts. A target anywhere else stops the run before the first node is dispatched, and the same check guards the push step itself.
- Neither clone keeps a remote: the mirror does not point back at the real repository and the worktree does not point at the mirror, so a node's reflex `git push` has nowhere to go. This is not containment. A node with unrestricted Bash can still push to any path it can name, and that needs a sandbox, which the baseline does not have.

### The gate is a separate process, and gates are not serialised

The gate is `make test` in the node's own worktree, run as a separate process; the coordinator reads its exit code and nothing else. The baseline's gate command is fixed at that, which is part of what makes it the control; the kernel's is configurable, see [Coordinator](#coordinator-agentdag-run) below. Gate runs are not serialised against each other: bmk 3.17.0 guards its own shared tool environment, so every bmk holds a shared lock on it for its lifetime and only the provisioning waits, never the whole gate. `--parallel` therefore bounds the gates as well as the agent nodes.

### One credential per node

Each node runs with `CLAUDE_CONFIG_DIR` pointing at its own directory under the run store, holding its own copy of `~/.claude/.credentials.json`, created owner-only. A node's token refresh lands in that copy rather than in the operator's file, and parallel nodes never share one credential file. An operator with no credential file is not an error: the node then fails with the CLI's own "not logged in" message.

### What the baseline does not have

- No journal. The run store is a fresh timestamped directory per invocation, so a crash after the nodes have run and before the approval throws that work away.
- No token or spend cap. `max_turns` per node is a turn count, not a budget.
- No unattended approve. The run blocks on a console confirmation, so it cannot run on a schedule or in CI.

---

## Coordinator (agentdag run)

`agentdag run` is the general coordinator kernel: a journal, a resumable run directory, a
tier policy and a Claude executor with an allowlisted per-node credential. It runs the SAME
`graph-a` workflow the baseline above runs, plus a `hold`-by-default human approve step and
a crash window that re-dispatches only what a journal shows never finished. `agentdag graph-a
...` stays in the repo unchanged as the M1 BASELINE - the control this kernel is measured
against until M5 compares the two.

Codex (a second executor arm) and a per-turn token spend cap are NOT in this version.

**The gate is the run's own command.** `[kernel] gate_command` is the argv every gate node
runs, `["make", "test"]` by default, in the node's own worktree and under an allowlisted
environment rather than the coordinator's whole one. What the node RECORDS is what ran: the
argv comes from the wired gate itself, so a node's `input.json`, its brief and its journal key
all name the command the machine executed, and the planner is told it too. A plan cannot choose
it - the gate is the step the agent that wrote the change has no say over - and an explicit
empty command is refused before a run directory exists, because a run with no gate is what the
gate exists to prevent. Like every other `[kernel]` setting it is resolved once at `run start`
and travels on the run, so the background coordinator and every later `resume`, `approve` or
`retry` gate on the command the run was started with.

One-time setup: the run directory needs to exist and be writable before the first `run start`.

```bash
sudo install -d -m 0700 -o "$USER" -g "$USER" /var/lib/agentdag/runs
```

Five verbs, over one run directory per run id:

```bash
# start a run; --foreground drives the coordinator in-process (the testable path);
# without it, the coordinator is launched detached (a systemd --user scope on Linux,
# a plain child process elsewhere) and the command returns immediately
agentdag run start graph-a \
  --arg repos_file=/tmp/agentdag-scratch/REPOS.txt --arg brief_file=brief.md \
  --arg scratch=/tmp/agentdag-scratch --runs /var/lib/agentdag/runs

# print state.json's fields and the journal's last event
agentdag run status <run-id>

# print one line per result the journal holds, or --json for the same as JSON
agentdag run records <run-id>

# relaunch a run that suspended, crashed, or otherwise is not yet `done`
agentdag run resume <run-id> --reason manual

# record a decision for a suspended approve node, then relaunch (unless --no-relaunch)
agentdag run approve <run-id> a_push_list --decision approve
```

A run directory (`<runs>/<run-id>/`) holds `journal.jsonl` (the append-only, replayable
log - one JSON line per event), `audit.jsonl` (a copy, written first), `state.json`
(status, cursor, token totals, and the `settings` the run was started with), `lock` (the
exclusive run lock), `launch.log` (a
background launch's redirected stdout/stderr), and per-node subdirectories:
`decisions/` (recorded approvals), `retries/` (recorded retry grants), `intents/`,
`artefacts/`, `wt/` (worktrees), `nodes/` (each dispatch's brief, input and record),
`manifest/` (map/reduce manifests), and `attempted/` and `done/` (the two apply markers).

The Claude executor authenticates each node from one of two sources, chosen once at `run
start`, printed there and carried on the run: the config's `[credentials]
claude_oauth_token_file` keyfile if that path exists, else a private owner-only copy of the
operator's own `~/.claude/.credentials.json`. A run started on a keyfile that has since gone is
refused by name at its next launch rather than quietly switched to the copy. Either way, the
CLI itself never reads the credential's content - only the executor does, inside the
coordinator process, at the point it actually dispatches a node.

### What the kernel enforces, and what it does not

The per-node credential and the environment allowlists stop ACCIDENTAL leakage through
inherited environment variables. They are not isolation. A node is a process running as
the same operating-system user as the coordinator, with no sandbox and no separate
account, so its own Bash tool can read files anywhere on the machine that user can read
and can make outbound network requests. The M1 baseline's "this is not containment"
caveat above applies to the kernel unchanged in kind.

**The boundary the kernel does enforce, stated in one place.** A node's Write and Edit tool
calls are refused outside its declared write set inside the run directory, by a live
`PreToolUse` deny rather than a scan. A `plan-goal` run may name a second root with
`--arg workspace=DIR`, an operator-supplied directory the plan works in instead of a worktree
the run owns; a node is then bounded by both roots and by nothing else, and the workspace is
granted whole rather than by glob. What that costs is stated where it is recorded: the
after-the-fact scan compares the run directory alone, so writes made in a workspace are writes
no scan judged, and every scan names the roots it did not cover in its own `unwatched_roots` key
fact, empty when there is nothing to name. A workspace inside the run
directory is refused, which is what keeps the two statements from overlapping. A planner node's
reads are confined to its own directory and working directory, and it gets no Bash at all. Every
other node's Bash is filtered by the
`[kernel] deny_bash` substring list, which is refused by name when set to a blank value rather
than read as an empty list. The tools that reach the network or spawn a sub-agent are refused
outright: `WebFetch`, `WebSearch` and `Task` by default, `[kernel] deny_tools`. An operator
widens either list deliberately by writing an explicit `[]`. What a node may reach for WITHOUT
being asked is a separate, weaker setting - `[kernel] tools`, the SDK's auto-approval list, and
`[kernel] permission_mode`, what the CLI does with a call no hook denied. Neither is part of the
boundary: neither offered mode prompts, so naming a tool in `tools` auto-approves it and
omitting one closes nothing under either mode, and a node runs a tool it was never handed
whenever no deny hook refuses it - under `bypassPermissions` provided the provider CLI honours
that mode rather than silently downgrading it in this headless setup, which is unconfirmed. The
CLI consults a `PreToolUse` decision before it evaluates any permission rule, read at source
rather than measured live. Bash is the hole in that boundary:
a shell command can read and write anywhere the operating-system user can, and nothing catches
a write it makes outside the run directory. Both lists, like every other `[kernel]` setting, are
resolved once at `run start` and written to the run's `state.json` as its `settings` block; the
background coordinator and every later `resume`, `approve` or `retry` read them back from there,
and the deadline sweep announces a crashed run through the sink it was started with, so a value
supplied by an environment variable, `--set` or `--profile` reaches the run, and a relaunch
under a changed configuration does not change it.

- **The Bash denylist blocks the exact command shapes the policy lists, and nothing
  else.** Measured against the shipped policy: `curl -XPOST ...`, `curl -d ...`, a plain
  GET with the data in the URL, `git -C some/path push` and `python3 -c ...` all pass. It
  raises the cost of an accident; it does not stop a determined process.
- **A Bash write is caught only by the post-hoc scan, and only inside the run directory.**
  The live write-set deny above sees the Write and Edit tools; a shell command writes first
  and the scan reports afterwards, and a write outside the run directory is not seen at all.
  Under `--parallel` greater than 1 a stray write that lands inside a SIBLING's declared
  region cannot be attributed to either node, and the scan says so rather than naming one.
- **`by` and `token_id` on a decision are not an authentication mechanism.** They record
  who the recording process said it was. Any process running as the same operating-system
  user can write a decision file, so the run directory's own permissions are the actual
  control.
- **Cancel and deadline teardown reaps grandchildren only under the systemd scope.**
  There, the whole cgroup goes. Under `NoScope` - off Linux, or with no live
  `systemd --user` manager - POSIX hosts kill the coordinator's process GROUP, which
  reaches its children, and Windows kills only the one launched process.
- **A failed CODE node (gate, scan, tally, discover) is final in this version.** Its
  record is served from the journal on every resume and no command mints a new attempt,
  so a run that failed on one can only be restarted as a NEW run. M3 adds the retry path.
- **A `Sandbox` port now exists, but the shipped `none` adapter enforces nothing.** It
  turns the paragraph above into a typed declaration rather than only prose - every node
  the coordinator dispatches gets a record carrying `sandbox.adapter == "none"` with
  `filesystem`, `network_egress` and `separate_uid` all `false`, persisted in `record.json`
  and the journal's `result` line, so `agentdag run records --json` shows it too (the
  default plain-text output prints only node id, attempt, status, charged tokens and cost). A record served
  from the journal on replay keeps the declaration it was originally dispatched under,
  even when a later launch is wired with a different `Sandbox` adapter.

---

## Email Sending

The application includes email sending capabilities via [btx-lib-mail](https://pypi.org/project/btx-lib-mail/), supporting both simple notifications and rich HTML emails with attachments.

#### Email Configuration

Configure email settings via environment variables, `.env` file, or configuration files:

**Environment Variables:**

Environment variables use the format: `<PREFIX>___<SECTION>__<KEY>=value`
- Triple underscore (`___`) separates PREFIX from SECTION
- Double underscore (`__`) separates SECTION from KEY

```bash
# A list is a JSON array; a comma-separated value would be ONE host string
export AGENTDAG___EMAIL__SMTP_HOSTS='["smtp.gmail.com:587","smtp.backup.com:587"]'
export AGENTDAG___EMAIL__FROM_ADDRESS="alerts@myapp.com"
export AGENTDAG___EMAIL__SMTP_USERNAME="your-email@gmail.com"
export AGENTDAG___EMAIL__SMTP_PASSWORD="your-app-password"
export AGENTDAG___EMAIL__USE_STARTTLS="true"
export AGENTDAG___EMAIL__TIMEOUT="60.0"
```

**Configuration File**:
```toml
[email]
smtp_hosts = ["smtp.gmail.com:587", "smtp.backup.com:587"]  # Fallback to backup if primary fails
from_address = "alerts@myapp.com"
smtp_username = "myuser@gmail.com"
smtp_password = "secret_password"  # Consider using environment variables for sensitive data
use_starttls = true
timeout = 60.0
```

**`.env` File:**
```bash
# Email configuration for local testing
AGENTDAG___EMAIL__SMTP_HOSTS=smtp.gmail.com:587
AGENTDAG___EMAIL__FROM_ADDRESS=noreply@example.com
```

#### Gmail Configuration Example

For Gmail, create an [App Password](https://support.google.com/accounts/answer/185833) instead of using your account password:

```bash
AGENTDAG___EMAIL__SMTP_HOSTS=smtp.gmail.com:587
AGENTDAG___EMAIL__FROM_ADDRESS=your-email@gmail.com
AGENTDAG___EMAIL__SMTP_USERNAME=your-email@gmail.com
AGENTDAG___EMAIL__SMTP_PASSWORD=your-16-char-app-password
```

#### Send Simple Email

```bash
# Send basic email to one recipient
agentdag send-email \
    --to recipient@example.com \
    --subject "Test Email" \
    --body "Hello from bitranox!"

# Send to multiple recipients
agentdag send-email \
    --to user1@example.com \
    --to user2@example.com \
    --subject "Team Update" \
    --body "Please review the latest changes"
```

#### Send HTML Email with Attachments

```bash
agentdag send-email \
    --to recipient@example.com \
    --subject "Monthly Report" \
    --body "Please find the monthly report attached." \
    --body-html "<h1>Monthly Report</h1><p>See attached PDF for details.</p>" \
    --attachment report.pdf \
    --attachment data.csv
```

#### Send Notifications

For simple plain-text notifications, use the convenience command:

```bash
# Single recipient
agentdag send-notification \
    --to ops@example.com \
    --subject "Deployment Success" \
    --message "Application deployed successfully to production at $(date)"

# Multiple recipients
agentdag send-notification \
    --to admin1@example.com \
    --to admin2@example.com \
    --subject "System Alert" \
    --message "Database backup completed successfully"
```

#### Programmatic Email Usage

```python
from agentdag.adapters.email.sender import EmailConfig
from agentdag.composition import send_email, send_notification

# Configure email
config = EmailConfig(
    smtp_hosts=["smtp.gmail.com:587"],
    from_address="alerts@myapp.com",
    smtp_username="myuser@gmail.com",
    smtp_password="app-password",
    timeout=60.0,
)

# Send simple email
send_email(
    config=config,
    recipients="recipient@example.com",
    subject="Test Email",
    body="Hello from Python!",
)

# Send email with HTML and attachments
from pathlib import Path

send_email(
    config=config,
    recipients=["user1@example.com", "user2@example.com"],
    subject="Report",
    body="See attached report",
    body_html="<h1>Report</h1><p>Details in attachment</p>",
    attachments=[Path("report.pdf")],
)

# Send notification
send_notification(
    config=config,
    recipients="ops@example.com",
    subject="Deployment Complete",
    message="Production deployment finished successfully",
)
```

#### Email Troubleshooting

**Connection Failures:**
- Verify SMTP hostname and port are correct
- Check firewall allows outbound connections on SMTP port
- Test connectivity: `telnet smtp.gmail.com 587`

**Authentication Errors:**
- For Gmail: Use App Password, not account password
- Ensure username/password are correct
- Check for 2FA requirements

**Emails Not Arriving:**
- Check recipient's spam folder
- Verify `from_address` is valid and not blacklisted
- Review SMTP server logs for delivery status

## Further Documentation

- [Documentation index](docs/README.md)  -  what agentdag is, why it is shaped this way, and how it works
- [Install Guide](INSTALL.md)
- [Development Handbook](DEVELOPMENT.md)
- [Contributor Guide](CONTRIBUTING.md)
- [Changelog](CHANGELOG.md)
- [Module Reference](docs/systemdesign/module_reference.md)
- [AI transparency](ai-transparency.md)  -  who decided what, what is checked, and what is not
- [Our stance on AI](ai-stance.md)
- [License](LICENSE)
