Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions managed_agents/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ report.html
__pycache__/
*.pyc
.env
*.whl
2 changes: 1 addition & 1 deletion managed_agents/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ version = "0.1.0"
description = "Runnable examples for the Claude Managed Agents API"
requires-python = ">=3.11,<3.13"
dependencies = [
"anthropic>=0.91.0",
"anthropic>=0.109.0",
"python-dotenv",
]

Expand Down
22 changes: 22 additions & 0 deletions managed_agents/sentry/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Anthropic auth, either of:
# - an API key from https://platform.claude.com/
# - nothing here, after signing in once with `ant auth login`
# (the SDK discovers CLI credentials automatically)
ANTHROPIC_API_KEY=sk-ant-...

# Sentry: Settings → Auth Tokens → Create New Token
# with org:read, project:read, and event:read scopes
SENTRY_AUTH_TOKEN=sntrys_... # secret: goes in a vault, nowhere else
SENTRY_ORG=your-org-slug # the slug in your Sentry URL: sentry.io/organizations/<slug>/
SENTRY_PROJECT=your-project # project slug, not the numeric ID

# From `uv run python setup_agent.py`
CLAUDE_VAULT_ID=vlt...
CLAUDE_AGENT_ID=agent_...
CLAUDE_ENVIRONMENT_ID=env_...

# From `uv run python deploy.py`
CLAUDE_DEPLOYMENT_ID=deployment_...

# Optional: override the agent's model (read by agent_config.py)
# COOKBOOK_MODEL=claude-sonnet-4-6
6 changes: 6 additions & 0 deletions managed_agents/sentry/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.env
.venv/
__pycache__/
*.pyc
uv.lock
TRIAGE_REPORT.md
17 changes: 17 additions & 0 deletions managed_agents/sentry/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Sentry triage × Claude Managed Agents

Scheduled deployment: cron → CMA session with `sentry-cli` and a vault env-var credential → triage report in `/mnt/session/outputs/`.

## When the user asks to set this up, get it working, or debug it

1. **Invoke `/claude-api` first.** That skill loads the full Managed Agents API reference (agents, sessions, environments, events, webhooks, deployments, vaults, memory stores). Use it as the source of truth for any SDK call you write or edit. Don't guess field names.
2. **Read `./skill.md`** and walk the user through it step by step. It has the ordered checklist, every gotcha (two separate host allowlists, immutable `secret_name`, replace-only `allowed_hosts`, DST cron semantics, auto-pause on permanent failures), and the debugging table.
3. **After the base schedule works, offer extensions.** Ask the user which (if any) they want, then edit `setup_agent.py` and/or `deploy.py` accordingly:
- **Deliver the report** instead of leaving it in the sandbox: a `session.status_idled` webhook that downloads the report and posts to Slack (see `managed_agents/slack` for the bridge pattern)
- **More CLIs**: add other env-var-authenticated CLIs as additional vault credentials, one credential per token with its own host allowlist
- **Outcomes**: rubric-graded iterate loop (`user.define_outcome` event instead of `user.message` in `initial_events`)
- **Memory store**: track issue history across runs so the agent can flag regressions it has seen before (`resources: [{type: "memory_store", ...}]`)

Pull exact shapes from the `/claude-api` skill's `shared/managed-agents-*.md` docs.

One-time provisioning is `uv run python setup_agent.py`. Schedule with `uv run python deploy.py`. Smoke-test with `uv run python run_now.py`. Push prompt or model changes with `uv run python update_agent.py` (edits live in `agent_config.py`).
44 changes: 44 additions & 0 deletions managed_agents/sentry/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Sentry triage × Claude Managed Agents

A scheduled [Managed Agent](https://platform.claude.com/docs/en/managed-agents/overview) that pulls the last 24 hours of Sentry issues with `sentry-cli` and writes a prioritized triage report. No host process: the cron schedule lives server-side as a deployment.

```
cron (0 9 * * 1-5) ──▶ deployment ──▶ session (sandbox)
│ sentry-cli / curl with
│ placeholder token
egress proxy: placeholder → real token,
*.sentry.io only
TRIAGE_REPORT.md in /mnt/session/outputs/
```

The Sentry token is an `environment_variable` vault credential. The sandbox holds an opaque placeholder, and the egress proxy substitutes the real token only on requests to `*.sentry.io`. The model never sees the secret. The same pattern works for `gh`, `twilio`, `vercel`, or any other CLI that authenticates via an env var.

## Quickstart

```bash
cd managed_agents/sentry
uv sync
claude "walk me through setting this up."
```

Claude reads [`skill.md`](./skill.md) and drives the config: Sentry token, vault + agent + environment, a manual test run, then the cron deployment.

Authenticate with an `ANTHROPIC_API_KEY` in `.env`, or sign in once with [`ant auth login`](https://platform.claude.com/docs/en/api/sdks/cli). Either works: the SDK discovers CLI credentials automatically when no API key is set.

## Files

| | |
|---|---|
| `cma.py` | Shared client, env loading, event streaming |
| `agent_config.py` | Model and system prompt, shared by setup and update |
| `setup_agent.py` | One-time: vault + credential + agent + environment |
| `update_agent.py` | Push prompt or model changes, re-pin the deployment |
| `deploy.py` | `deployments.create` with a cron schedule |
| `run_now.py` | Manual trigger, stream the session, download the report |
| `runs.py` | Run history and failures |
| `teardown.py` | Pause and archive everything |
| `skill.md` | Setup walkthrough, gotchas, debugging |

Requires `anthropic` ≥ 0.109.0.
51 changes: 51 additions & 0 deletions managed_agents/sentry/agent_config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Agent configuration: model and system prompt.

Shared by setup_agent.py (first create) and update_agent.py (push changes).
"""

import os

from dotenv import load_dotenv

load_dotenv()

MODEL = os.environ.get("COOKBOOK_MODEL", "claude-opus-4-8")


def build_system_prompt(org: str, project: str) -> str:
# The system prompt carries everything that isn't a secret: org and project
# slugs, the triage method, the report format. Never the token; system
# prompts are stored in the session's event history.
return f"""You are an SRE triage assistant. Each run, you produce a morning \
triage report covering the last 24 hours of Sentry issues for the on-call engineer.

## Sentry access

- `sentry-cli` is installed. It authenticates via the SENTRY_AUTH_TOKEN environment \
variable, which is already set. Never print it, and never pass it as a CLI flag.
- Org: `{org}` Project: `{project}`
- For data the CLI doesn't expose (event counts, user counts, stack traces), call the \
REST API directly, e.g.:
curl -s -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \
"https://sentry.io/api/0/organizations/{org}/issues/?project={project}&query=is:unresolved&statsPeriod=24h&sort=freq"

## Workflow

1. Pull unresolved issues from the last 24 hours (new and escalating).
2. For the highest-impact issues, pull details: event count, users affected, \
first/last seen, culprit, a representative stack trace.
3. Classify each as NEW (first seen <24h), REGRESSION (was resolved, came back), \
ESCALATING (event count accelerating), or ONGOING.
4. Rank by user impact, not raw event count.

## Output

Write the report to /mnt/session/outputs/TRIAGE_REPORT.md:

- **Summary**: 2-3 sentences. New issue count, total users affected, anything on fire.
- **Top issues** (max 5): title, short ID, classification, users affected, event count, \
one-line root-cause hypothesis, suggested next step.
- **Watchlist**: issues that didn't make the top 5 but are worth an eye.

Keep it under one page. The reader is an on-call engineer with five minutes. If there \
are no issues in the window, say so in one line. Do not pad."""
68 changes: 68 additions & 0 deletions managed_agents/sentry/cma.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Shared client, env loading, and streaming helpers for the triage scripts."""

import os
import sys
import time

from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv()

# The SDK adds the managed-agents beta header automatically for agents,
# environments, sessions, vaults, and deployments. The files resource doesn't:
# without it, /v1/files rejects scope_id ("unknown field"), so pass it
# explicitly when listing session-scoped files (run_now.py).
BETAS = ["managed-agents-2026-04-01"]

client = Anthropic()


def require_env(name: str) -> str:
value = os.environ.get(name, "")
if not value or value.endswith("..."):
sys.exit(f"{name} is not set in .env (see .env.example)")
return value


def wait_for_idle_status(session_id: str, max_wait: float = 5.0) -> None:
"""Poll until the server-side status is `idle`.

The `session.status_idle` stream event can arrive slightly before
`sessions.retrieve` reports `status == "idle"`, so an `archive()`
issued straight after the stream exits can 400. This absorbs the race.
"""
deadline = time.monotonic() + max_wait
while time.monotonic() < deadline:
if client.beta.sessions.retrieve(session_id).status == "idle":
return
time.sleep(0.25)


def stream_until_end_turn(session_id: str) -> None:
"""Stream a session's events and print until the agent finishes its turn.

`session.status_idle` is not always terminal: with `stop_reason.type ==
"requires_action"` the agent is waiting on a client event (a tool
confirmation or custom tool result), so keep streaming. The terminal stop
reasons are `end_turn` (normal completion) and `retries_exhausted`
(failure). Breaking only on `end_turn` would hang forever on a failed run.
"""
with client.beta.sessions.events.stream(session_id) as stream:
for ev in stream:
match ev.type:
case "agent.message":
for block in ev.content:
if block.type == "text":
print(block.text, end="")
case "agent.tool_use":
print(f"\n[{ev.name}]")
case "session.status_idle" if (
ev.stop_reason and ev.stop_reason.type != "requires_action"
):
if ev.stop_reason.type != "end_turn":
print(f"\nsession stopped: {ev.stop_reason.type}")
break
case "session.status_terminated":
return
wait_for_idle_status(session_id)
47 changes: 47 additions & 0 deletions managed_agents/sentry/deploy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
"""Create the scheduled deployment: weekday mornings, 9 AM Eastern.

Copy the printed deployment ID into .env.
"""

from cma import client, require_env

CLAUDE_AGENT_ID = require_env("CLAUDE_AGENT_ID")
CLAUDE_ENVIRONMENT_ID = require_env("CLAUDE_ENVIRONMENT_ID")
CLAUDE_VAULT_ID = require_env("CLAUDE_VAULT_ID")
SENTRY_ORG = require_env("SENTRY_ORG")
SENTRY_PROJECT = require_env("SENTRY_PROJECT")

TRIAGE_PROMPT = (
"Run today's Sentry triage. Pull the last 24 hours of unresolved issues for "
f"{SENTRY_ORG}/{SENTRY_PROJECT}, triage them per your instructions, and write "
"the report to /mnt/session/outputs/TRIAGE_REPORT.md. "
"Reply with the Summary section when you're done."
)

# The schedule is a POSIX cron expression plus an IANA timezone, matched on
# wall-clock time (see skill.md for the DST edges). Sessions start themselves
# on Anthropic infra; nothing keeps running on this machine.
deployment = client.beta.deployments.create(
name="Weekday morning Sentry triage",
agent=CLAUDE_AGENT_ID,
environment_id=CLAUDE_ENVIRONMENT_ID,
vault_ids=[CLAUDE_VAULT_ID],
initial_events=[
{
"type": "user.message",
"content": [{"type": "text", "text": TRIAGE_PROMPT}],
}
],
schedule={
"type": "cron",
"expression": "0 9 * * 1-5", # weekday mornings
"timezone": "America/New_York",
},
)

print(f"deployment: {deployment.id} ({deployment.status})")
print("next runs:")
for ts in deployment.schedule.upcoming_runs_at:
print(f" {ts}")
print("\nAdd to .env:")
print(f"CLAUDE_DEPLOYMENT_ID={deployment.id}")
9 changes: 9 additions & 0 deletions managed_agents/sentry/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[project]
name = "sentry-triage-cma"
version = "0.1.0"
description = "Scheduled Sentry triage with a Claude Managed Agent"
requires-python = ">=3.11,<3.13"
dependencies = [
"anthropic>=0.109.0",
"python-dotenv",
]
46 changes: 46 additions & 0 deletions managed_agents/sentry/run_now.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Trigger a manual deployment run, stream it, and download the report.

Starts a session immediately, exactly as the schedule would, and records a
deployment run with `trigger_context.type: "manual"`. Use it to smoke-test
the deployment without waiting for the next cron tick.
"""

import time
from pathlib import Path

from cma import BETAS, client, require_env, stream_until_end_turn

CLAUDE_DEPLOYMENT_ID = require_env("CLAUDE_DEPLOYMENT_ID")

manual_run = client.beta.deployments.run(CLAUDE_DEPLOYMENT_ID)
print(f"run: {manual_run.id}")
print(f"session: {manual_run.session_id}")

# The run created a real session. Stream it like any other.
stream_until_end_turn(manual_run.session_id)

# The agent wrote to /mnt/session/outputs/, which the Files API captures
# automatically. Indexing can lag 1-3 seconds after the session goes idle,
# so retry an empty list a few times.
report_files = []
for _ in range(5):
report_files = client.beta.files.list(
scope_id=manual_run.session_id,
betas=BETAS,
).data
if report_files:
break
time.sleep(1)

if not report_files:
print("\nno files found; check the transcript above for whether the agent wrote the report")

# The agent picked these filenames, and the agent reads attacker-controlled
# input (issue titles, stack traces). Strip any path components before
# writing to the local disk.
for f in report_files:
local_name = Path(f.filename).name
print(f"\n{f.filename} ({f.size_bytes} bytes)")
content = client.beta.files.download(f.id)
content.write_to_file(local_name)
print(f"downloaded to ./{local_name}")
21 changes: 21 additions & 0 deletions managed_agents/sentry/runs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""List run history for the deployment, failures last.

Every scheduled or manual trigger writes a deployment run record, whether or
not it produced a session. `error.code` distinguishes rate-limited, archived
environment, missing vault, and backend errors. Permanent failures
(`vault_not_found`, `agent_archived`, `environment_archived`) auto-pause the
deployment and set `paused_reason`.
"""

from cma import client, require_env

CLAUDE_DEPLOYMENT_ID = require_env("CLAUDE_DEPLOYMENT_ID")

runs = client.beta.deployment_runs.list(deployment_id=CLAUDE_DEPLOYMENT_ID)
for run in runs:
outcome = run.session_id or (run.error.code if run.error else "?")
print(f"{run.created_at} [{run.trigger_context.type}] {outcome}")

failed = client.beta.deployment_runs.list(deployment_id=CLAUDE_DEPLOYMENT_ID, has_error=True)
for run in failed:
print(f"FAILED {run.created_at}: {run.error.code} ({run.error.message})")
Loading
Loading