Observed since 20 Oct, 21:47 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
Coordinator that runs a small graph of AI-agent nodes with bounded spend, mechanical gates and journaled state
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 below.
.env is the first one found from the current directory upward; when there is none,
$XDG_CONFIG_HOME/agentdag/.env (~/.config/... when unset) is read on Linux
(see CONFIG.md for macOS and Windows).ubuntu-latest,
macos-latest, windows-latest) and cover CPython 3.12 through 3.15
alongside the latest available 3.x release provided by Actions.uv is an ultrafast Python package manager written in Rust (10-20x faster than pip/poetry).
# 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"
uvx agentdag@latest --help
# One-time install from PyPI (isolated environment, added to PATH)
uv tool install agentdag
# or from the git repository
uv tool install --from "git+https://github.com/bitranox/agentdag.git" agentdag
# Pick the interpreter explicitly if needed, e.g. --python 3.14
# Update (requires network)
uv tool upgrade agentdag
# Run
agentdag --help
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. All supported methods register the agentdag command on your PATH.
This repository commits a uv.lock. It pins every package the project needs, transitive
dependencies included, to an exact version and to the hashes of that version's files. The
exclude-newer setting under [tool.uv] in pyproject.toml adds a cooldown: uv leaves any
release younger than that out of the lock. CI installs from the lock, so a hijacked release that
gets pulled again within the cooldown never reaches our builds, and a file whose hash does not
match is refused.
That protection stays in this repository. The installable package declares only lower bounds
(>=) for its dependencies, so a plain pip install takes the newest release of every transitive
dependency at the moment you run it. If one of them was hijacked an hour earlier, you get the
hijacked version. pipx install and uv tool install without options do the same.
To install the command line tool with a cooldown of your own (uv then skips any release younger than 7 days, transitive ones included), or to run the exact tree our CI tests:
uv tool install --exclude-newer "7 days" agentdag
git clone https://github.com/bitranox/agentdag.git && cd agentdag && uv sync --locked && uv run agentdag --help
If you depend on agentdag in your own project, give that project the same cooldown (7 days here, as
an example) and commit the uv.lock that uv add agentdag writes:
[tool.uv]
exclude-newer = "7 days"
See CONFIG.md for detailed documentation on the layered configuration system, including precedence rules, profile support, and customization best practices.
# Install
uv tool install agentdag
# Verify
agentdag --version
# deploy config files (user level; --target app or host needs sudo)
agentdag config-deploy --target user
# Try it out
agentdag hello
agentdag info
agentdag config
The CLI leverages rich-click so help output, validation errors, and prompts render with Rich styling while keeping the familiar click ergonomics.
# 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 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:
# 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.
A run never writes to a real repository.
graph-a scratch reads each real repository exactly once, with git clone --mirror.<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.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 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 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.
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.
max_turns per node is a turn count, not a budget.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.
sudo install -d -m 0700 -o "$USER" -g "$USER" /var/lib/agentdag/runs
Five verbs, over one run directory per run id:
# 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.
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.
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.--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.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.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.The application includes email sending capabilities via btx-lib-mail, supporting both simple notifications and rich HTML emails with attachments.
Configure email settings via environment variables, .env file, or configuration files:
Environment Variables:
Environment variables use the format: <PREFIX>___<SECTION>__<KEY>=value
___) separates PREFIX from SECTION__) separates SECTION from KEY# 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:
[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
starttls_verify = true # false accepts any certificate: only for a known internal relay
timeout = 60.0
# local_hostname = "mail.example.com" # EHLO name; set it where reverse DNS is slow
# delivery_deadline = 300 # upper bound in seconds for one SMTP session; 0 = none
A key [email] or [email.attachments] does not know is refused (exit 78,
email.<key>: unknown key), whichever layer it comes from, so a typo cannot leave a setting at
its default.
.env File (no prefix in .env; a prefixed key is not recognised):
# Email configuration for local testing
EMAIL__SMTP_HOSTS=["smtp.gmail.com:587"]
EMAIL__FROM_ADDRESS=noreply@example.com
For Gmail, create an App Password instead of using your account password.
In a .env file (no prefix in .env; a prefixed key is not recognised):
EMAIL__SMTP_HOSTS=["smtp.gmail.com:587"]
EMAIL__FROM_ADDRESS=your-email@gmail.com
EMAIL__SMTP_USERNAME=your-email@gmail.com
EMAIL__SMTP_PASSWORD=your-16-char-app-password
or as environment variables (these need the prefix):
export AGENTDAG___EMAIL__SMTP_HOSTS='["smtp.gmail.com:587"]'
export AGENTDAG___EMAIL__FROM_ADDRESS=your-email@gmail.com
export AGENTDAG___EMAIL__SMTP_USERNAME=your-email@gmail.com
export AGENTDAG___EMAIL__SMTP_PASSWORD=your-16-char-app-password
# 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"
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
For simple plain-text notifications, use the convenience command:
# 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"
from pydantic import SecretStr
from agentdag.adapters.email.sender import EmailConfig
from agentdag.composition import send_email, send_notification
# Configure email. EmailConfig is a btx_lib_mail ConfMail, so in Python the fields use the
# library's names (smtphosts, smtp_use_starttls, smtp_timeout, smtp_starttls_verify,
# smtp_local_hostname, smtp_delivery_deadline); configuration files keep their keys (smtp_hosts,
# use_starttls, timeout, starttls_verify, local_hostname, delivery_deadline). smtp_password is a SecretStr: it prints as
# '**********' in logs, reprs and dumps, and config.smtp_password.get_secret_value() returns
# the plain text.
config = EmailConfig(
smtphosts=["smtp.gmail.com:587"],
from_address="alerts@myapp.com",
smtp_username="myuser@gmail.com",
smtp_password=SecretStr("app-password"),
smtp_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",
)
Connection Failures:
telnet smtp.gmail.com 587Authentication Errors:
Emails Not Arriving:
from_address is valid and not blacklistedDownload counts and popularity will appear here as the collector receives them.
| Version | Published | Direct dependencies | Open version |
|---|---|---|---|
| 0.1.2 | 12 Oct 2026 | 33 |
This describes our collected data; it is not a security assessment.
Observed since 20 Oct, 21:47 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package:fe0660c3e74f61fac2e43927735227ca0d7bdd2d1d74054a97a3b8fd4ce92061