Clayde is a persistent autonomous AI software agent running in a Docker container. My purpose is to help with software development by working on GitHub issues assigned to me. When assigned an issue, I analyze the relevant codebase, implement a solution, open a pull request, and post a comment on the issue summarizing what I did.
The gh CLI is authenticated as the configured bot GitHub account and git is configured with the identity from CLAYDE_GIT_NAME and CLAYDE_GIT_EMAIL.
- Implement all deterministic logic in traditional code (Python). Only invoke the LLM for tasks that genuinely require reasoning — researching codebases, writing plans, implementing solutions.
- Retrieve data (tickets, comments, reactions) in code. Pass it to the LLM with the right prompt. Retrieve the result and post it back using code.
- GitHub issue comments are the communication channel. Plans are posted as comments, approval is a thumbs-up reaction, discussions happen in comment threads.
- Python: ≥3.12, managed with
uv(~/.local/bin/uv) - Package manager:
uv(hatchling build backend) - Entry points:
clayde→orchestrator:run_loop(container mode, continuous loop),clayde-once→orchestrator:main(single cycle) - Deployment: Docker container via
docker-compose.yml; loop interval configurable viaCLAYDE_INTERVALenv var (default 300s) - Container layout: Application code at
/opt/clayde, data at/data(single volume mount from host./data) - Claude: Dual backend — Anthropic Python SDK (
api) or Claude Code CLI (cli), selected byCLAYDE_CLAUDE_BACKEND - Git credential helper:
gh auth git-credential(configured globally in the container) - Git identity: configured at container startup from
CLAYDE_GIT_NAMEandCLAYDE_GIT_EMAILenv vars
# Source repository
pyproject.toml # hatchling build; console scripts: clayde, clayde-once
CLAUDE.md # this file — identity + project context
Dockerfile # Python 3.13-slim image with git, gh, uv
docker-compose.yml # container deployment config
uv.lock
src/clayde/
__init__.py
config.py # Settings (pydantic-settings), APP_DIR, DATA_DIR,
# get_settings(), get_github_client(), setup_logging()
state.py # load_state(), save_state(), get_issue_state(),
# update_issue_state()
github.py # PyGitHub wrappers: parse_issue_url(), fetch_issue(),
# fetch_issue_comments(), post_comment(), edit_comment(),
# fetch_comment(), get_default_branch(),
# get_assigned_issues(),
# find_open_pr(), create_pull_request(), is_blocked(),
# add_pr_reviewer(), get_pr_reviews(),
# get_pr_review_comments(), parse_pr_url(),
# get_issue_author()
git.py # ensure_repo() — clone or update repos under REPOS_DIR
safety.py # Content filtering & plan approval: is_comment_visible(),
# filter_comments(), is_issue_visible(),
# get_new_visible_comments(), has_visible_content()
responses.py # Pydantic response models + parse_response() for structured JSON
claude.py # invoke_claude(prompt, repo_path) — dual backend:
# ApiBackend (Anthropic SDK tool-use loop) or
# CliBackend (Claude Code CLI subprocess)
telemetry.py # OpenTelemetry tracing: init_tracer(), get_tracer(),
# FileSpanExporter (JSONL)
orchestrator.py # main() — single cycle, run_loop() — container entry point
disk.py # check_disk_and_alert() — best-effort host disk guard,
# ntfy alert (cooldown-rate-limited) when usage ≥ threshold
prompts/
work.j2 # Jinja2 template for the unified work prompt
tasks/
__init__.py
work.py # run(issue_url) — unified: Claude decides next action
# (ask, plan, implement, open PR, or address review)
webhook/
__init__.py
app.py # FastAPI app, /webhook/pebble, /health, OTel enqueue span
auth.py # constant-time bearer-token verification
notify.py # send_ntfy + NotificationPayload model
queue.py # PebbleJob, JobQueue (in-memory asyncio.Queue), QueueFullError
runner.py # invoke_claude_pebble — async CLI subprocess, fresh session
skills.py # Skill model, /skills/ discovery, system + user prompt builders
worker.py # worker_loop, process_job — pop jobs, OTel process span
skills_builtin/
ping.md # built-in health-check skill (baked into image)
# Container paths
/opt/clayde/ # application code (WORKDIR)
/data/ # mounted from host ./data
config.env # CLAYDE_GITHUB_TOKEN, CLAYDE_CLAUDE_API_KEY, etc.
state.json # persisted issue state (keyed by issue HTML URL)
logs/
agent.log # all [clayde.*] log output
traces.jsonl # OpenTelemetry spans (JSONL)
repos/
{owner}__{repo}/ # cloned repos (naming: owner__repo)
Plain KEY=VALUE file (no shell quoting). All keys use CLAYDE_ prefix and are loaded by pydantic-settings into the Settings class.
| Key | Purpose |
|---|---|
CLAYDE_GITHUB_TOKEN |
Classic PAT with full repo scope |
CLAYDE_GITHUB_USERNAME |
The bot account username (e.g. YourBotName) |
CLAYDE_ENABLED |
Set to true to activate; any other value causes immediate exit |
CLAYDE_WHITELISTED_USERS |
Comma-separated list of trusted GitHub usernames |
CLAYDE_GIT_NAME |
Git commit author name (defaults to CLAYDE_GITHUB_USERNAME if not set) |
CLAYDE_GIT_EMAIL |
Git commit author email (required) |
CLAYDE_CLAUDE_API_KEY |
Anthropic API key for Claude SDK calls (required when backend=api) |
CLAYDE_CLAUDE_MODEL |
Model to use (default: claude-opus-4-6) |
CLAYDE_CLAUDE_BACKEND |
api (default) or cli — selects Anthropic SDK or Claude Code CLI |
CLAYDE_PEBBLE_ENABLED |
Set to true to enable the Pebble webhook |
CLAYDE_PEBBLE_TOKEN |
Bearer token the Pebble app sends |
CLAYDE_PEBBLE_HOST |
Public hostname for Traefik routing |
CLAYDE_PEBBLE_PORT |
Internal HTTP port (default 8080) |
CLAYDE_PEBBLE_TIMEOUT |
Per-request CLI timeout seconds (default 300) |
CLAYDE_PEBBLE_QUEUE_MAX |
Max queued jobs before 503 (default 100) |
CLAYDE_NTFY_TOPIC |
ntfy.sh topic for Pebble outcome notifications |
CLAYDE_NTFY_BASE_URL |
ntfy base URL (override for self-host) |
CLAYDE_NTFY_TIMEOUT_S |
ntfy POST timeout seconds (default 10) |
CLAYDE_KB_PATH |
In-container KB path; Pebble per-request cwd (default /home/clayde/knowledge_base) |
CLAYDE_DISK_ALERT_ENABLED |
Enable the per-tick disk-usage guard (default true) |
CLAYDE_DISK_ALERT_THRESHOLD_PCT |
Usage % that triggers an ntfy alert (default 85) |
CLAYDE_DISK_ALERT_PATH |
Path whose partition is checked — same volume as host root (default /data) |
CLAYDE_DISK_ALERT_COOLDOWN_S |
Min seconds between repeat alerts while over threshold (default 21600) |
Config is loaded via get_settings() (singleton). GH_TOKEN is exported at startup for the gh CLI.
There is no rigid status state machine. Each tick, the orchestrator iterates the issues assigned to the bot and, for each, decides whether anything has happened since last cycle. If so, it hands the issue to the unified work task, which lets Claude choose the next action — ask questions, post a plan, implement, open a PR, or address review comments.
Per-issue state is stored in state.json under
{"issues": {"<html_url>": {...}}}. Fields written by the current code:
| Field | Meaning |
|---|---|
owner, repo, number |
Issue identity |
issue_title |
Title (for log labels) |
branch_name |
Working branch (clayde/issue-<N> by default) |
pr_url |
PR opened for this issue, once detected via find_open_pr() |
in_progress |
True while the work task runs; a crash leaves it set so the next cycle retries |
last_seen_at |
ISO-UTC timestamp of the last completed cycle; used to detect new activity |
Activity detection (_handle_issue): the work task is invoked when any of
— in_progress is set (retry), last_seen_at is None (never processed),
there are new whitelist-visible comments, or there is new PR review activity
(inline comments or a review body). A pure PR approval with no comments does
not invoke Claude — it just advances last_seen_at.
Limits & retries: UsageLimitError / InvocationTimeoutError from Claude
leave in_progress=True so the next cycle retries automatically. Other
exceptions clear in_progress and log the error. Closed issues are pruned
from state at the start of each tick.
Instead of gatekeeping which issues to work on, content is filtered so the LLM only sees comments and issue bodies that are created by or approved (👍) by a whitelisted user. Every assigned issue is a candidate for work, but:
- Blocked issues are skipped — detected via "blocked by #N" / "depends on #N" text patterns in the issue body, and via GitHub sub-issue relationships (timeline API).
- No visible content → issue is skipped. If the issue body and all comments are from non-whitelisted users without any whitelisted 👍, there is nothing for the LLM to work with.
Only whitelist-visible content reaches the LLM; Claude decides within the work task whether it has enough to plan, implement, or must ask first.
Whitelisted users: configured via CLAYDE_WHITELISTED_USERS in data/config.env.
invoke_claude(prompt, repo_path)Two backends, selected by CLAYDE_CLAUDE_BACKEND:
- Uses the Anthropic Python SDK (
anthropicpackage) directly - Tool-use mode with
bashandtext_editortools (computer-use beta) - System prompt: CLAUDE.md contents
- Model: configurable via
CLAYDE_CLAUDE_MODEL(default:claude-opus-4-6) - Tool execution loop: Claude requests tool calls, Python executes them locally (cwd = repo_path), results fed back
- Timeout: 1800 seconds (30 min) for the full tool loop
- Rate/usage limit detection: raises
UsageLimitErroron 429 or 529 status codes - Token usage and cost tracking via OpenTelemetry spans
- Conversation persistence: full message list saved to JSON for resumption
- Requires:
CLAYDE_CLAUDE_API_KEY
- Runs the Claude Code CLI (
claude) as a subprocess with--output-format json - Claude manages its own tool loop internally
- System prompt: CLAUDE.md contents passed via
--append-system-prompt - Session resumption: saves
session_idfrom JSON output, resumes via--resume <session_id> - Rate/usage limit detection: text-pattern matching on stdout/stderr
- No per-token cost tracking (returns
cost_eur=0.0) - Requires: a dedicated Claude config dir dir-mounted from the host (
~/clayde-claude→/home/clayde/.claude); use a separateCLAUDE_CONFIG_DIR=~/clayde-claude claude login. Mount the directory, not the.credentials.jsonfile — token refresh renames the file (new inode) and a single-file mount goes stale. See docker-compose.yml / README.
Uses PyGitHub. All functions accept a Github client instance as first argument.
Repo cloning convention: repos/{owner}__{repo}/ (double underscore separator).
git.ensure_repo() clones on first use, then git checkout <default_branch> && git pull on subsequent calls.
Key functions:
is_blocked(g, owner, repo, number)— checks body text patterns and timeline API for blocking relationshipsadd_pr_reviewer(g, owner, repo, pr_number, login)— requests a review on a PRget_pr_reviews()/get_pr_review_comments()— fetch PR review dataedit_comment()— edit an existing issue commentparse_pr_url()— parse PR URL into (owner, repo, pr_number)
is_comment_visible(comment)— True if comment author is whitelisted OR has 👍 from whitelisted user.filter_comments(comments)— returns only visible comments.is_issue_visible(issue)— True if issue author is whitelisted OR has 👍 from whitelisted user.get_new_visible_comments(comments, last_seen_at)— visible comments created afterlast_seen_at.has_visible_content(issue, comments)— True if there is any visible content at all.
A single run(issue_url) handles every phase. There is no separate
plan/implement/review task — Claude decides what to do from the context it is
given.
fetch_issue()+get_default_branch();ensure_repo()resets the clone to the latest default branch.- Persist issue metadata and
branch_nameto state. - Gather context: whitelist-filtered comments, and — if a PR already exists — its review bodies and inline review comments.
- Render
work.j2with the issue body, labels, comments, review text, repo path,branch_name,pr_url, anddefault_branch. invoke_claude()— Claude explores, then takes whatever action fits: post a plan/question comment, implement and push, open a PR viagh pr create, or push fixes addressing review comments. It returns a JSON{summary}(WorkResponse).- Post the
summaryas an issue comment (best-effort: raw output snippet if JSON parsing fails). - Detect a PR via
find_open_pr(branch_name). On first detection, assign the issue author as reviewer; persistpr_urlto state.
Plans and questions are ordinary issue comments — there is no separate approval gate or 👍 reaction required to advance. Iteration happens through the normal comment/review activity-detection loop.
Format: [YYYY-MM-DD HH:MM:SS] [clayde.<module>] <message>
File: /data/logs/agent.log (appended)
Logger names: clayde.orchestrator, clayde.tasks.work, clayde.github, clayde.claude, clayde.git, clayde.state
When CLAYDE_PEBBLE_ENABLED=true, the container also serves a FastAPI
webhook for a Pebble watch app, alongside the existing GitHub poll loop
(both run on the same asyncio event loop).
POST /webhook/pebble— accepts{"text": str, "timestamp": int}withAuthorization: Bearer <CLAYDE_PEBBLE_TOKEN>. Returns 200 with a job id.GET /health— liveness probe (no auth).
The text is dispatched to the Claude CLI with a system prompt listing
skills found under the in-container path /skills/. Each skill is a
single markdown file with name + description frontmatter. Built-in
skills live at /skills/builtin/ (baked into the image — currently
ping); host-mounted skill directories sit alongside (e.g.
/skills/personal/, /skills/shared/).
Claude is free to use any number of skills per request — there is no single-skill cap. If no skill fits, Claude uses judgement (typically capturing into the knowledge base inbox).
Per-request cwd is ${CLAYDE_KB_PATH} (default
/home/clayde/knowledge_base), mounted RW from the host
~/knowledge_base/. Sync to other devices is handled by Syncthing on
the host — the container performs no git operations against the KB.
Every terminal outcome (success, claude-reported failure, timeout, usage
limit, CLI error, auth error, worker exception, queue full) emits an ntfy
notification on ${CLAYDE_NTFY_BASE_URL}/${CLAYDE_NTFY_TOPIC}. Claude
produces the title/body via a fenced JSON tail in its output; the
framework falls back to a synthetic "no summary" payload when parsing
fails.
Traefik handles TLS (Let's Encrypt) and routes
https://<CLAYDE_PEBBLE_HOST>/webhook/pebble over a private docker
network. The clayde service is not attached to any externally-reachable
network — the only ingress path is through Traefik.
Run the test suite after any feature development or bug fix:
uv run pytest
Always ensure all tests pass before committing changes.