Python SDK & CLI
Maintained by: Aether365 Team Audience: Developers and DevOps engineers Scope: Installing and using the official aether365 Python SDK and command line interface
The aether365 package is the official Python SDK and command line interface for the Aether365 API. It wraps https://api.aether365.io, handles authentication, unwraps the response envelope, and retries transient failures automatically. Anything you can do with curl you can do with the SDK or the CLI.
The SDK and CLI use an API key (ak_live_...) and target the unified endpoint - requests are routed to your tenant's home region automatically. See Authentication for how keys work.
Installation
The package is distributed internally (not on PyPI). Install it directly from the repository or a local checkout.
bash
# From a local checkout of the monorepo
pip install ./packages/cli
# Or with Poetry, from a path
poetry add ./packages/cliPython 3.12 or newer is required. Installing the package also puts the aether365 command on your PATH.
Configuration
Both the SDK and the CLI read the same environment variables:
| Variable | Purpose | Default |
|---|---|---|
AETHER365_API_KEY | Your API key (ak_live_...). Required. | - |
AETHER365_API_URL | Override the base URL (for example the dev endpoint). | https://api.aether365.io |
AETHER365_OUTPUT | CLI output format: table or json. | table |
bash
export AETHER365_API_KEY="ak_live_..."Region is automatic: the API resolves your tenant from the key and forwards to the correct region, so you never configure or pass a region.
Quickstart: SDK
python
from aether365 import Aether365Client
with Aether365Client() as client: # reads AETHER365_API_KEY
tenant = client.tenants.me()
print(tenant["name"])
# Trigger a scan and wait for it to finish
scan = client.scans.create("compliance")
for snapshot in client.scans.wait(scan["id"]):
print(snapshot["status"])
# Read the findings
findings = client.scans.results(scan["id"], result="Failed")
print(f"{len(findings)} failed checks")Quickstart: CLI
bash
# Show the authenticated tenant
aether365 tenant me
# Trigger a scan and follow progress until it finishes
aether365 scan run --type compliance --watch
# List scans as JSON
aether365 --output json scan list
# Fail a CI job when a scan has failed findings (exit code 2)
aether365 scan results <SCAN_ID> --fail-on-findingsWhat an API key can and cannot do
An API key is scoped to a safe, read-plus-operational surface. It can read your data, trigger and manage scans, generate reports, and manage attack-surface targets. It cannot perform account-control or directory-lockout actions - API key management, billing, team membership, connection onboarding/offboarding, SSO, applying a remediation plan, or the AI Pilot conditional-access/break-glass writes. Those remain available only to a signed-in session in the dashboard. A request to a disallowed route returns 403 AUTH_INSUFFICIENT_SCOPE.
Command and method reference
Each row shows the same operation three ways: raw curl, the SDK method, and the CLI command.
Tenant and account
| Operation | curl | SDK | CLI |
|---|---|---|---|
| Get tenant profile | GET /tenants/me | client.tenants.me() | aether365 tenant me |
| List connections | GET /tenants/me/connections | client.connections.list() | aether365 tenant connections |
| Notification prefs | GET /tenants/me/notifications | client.notifications.get() | aether365 tenant notifications |
| Scheduled scans | GET /tenants/me/scheduled-scans | client.scheduled_scans.list() | aether365 tenant scheduled-scans |
Scans
| Operation | curl | SDK | CLI |
|---|---|---|---|
| List scans | GET /tenants/me/scans | client.scans.list() | aether365 scan list |
| Trigger a scan | POST /tenants/me/scans | client.scans.create("compliance") | aether365 scan run --type compliance |
| Get a scan | GET /scans/{id} | client.scans.get(id) | aether365 scan get <id> |
| Read results | GET /scans/{id}/results | client.scans.results(id) | aether365 scan results <id> |
| Cancel a scan | POST /scans/{id}/cancel | client.scans.cancel(id) | aether365 scan cancel <id> |
| Hide a scan | PATCH /scans/{id}/hide | client.scans.hide(id) | aether365 scan hide <id> |
| Unhide a scan | PATCH /scans/{id}/unhide | client.scans.unhide(id) | aether365 scan unhide <id> |
| Delete a scan | DELETE /scans/{id} | client.scans.delete(id) | aether365 scan delete <id> |
The curl for triggering a scan:
bash
curl -X POST https://api.aether365.io/tenants/me/scans \
-H "Authorization: Bearer $AETHER365_API_KEY" \
-H "Content-Type: application/json" \
-d '{"scanType": "compliance"}'Reports
| Operation | curl | SDK | CLI |
|---|---|---|---|
| Report status | GET /scans/{id}/report | client.reports.status(id) | aether365 report status <id> |
| Generate a report | POST /scans/{id}/report/generate | client.reports.generate(id) | aether365 report generate <id> |
| List reports | GET /tenants/me/reports | client.reports.list() | aether365 report list |
| Download a report | GET /scans/{id}/report (presigned) | client.reports.download(id, path) | aether365 report download <id> -o out.pdf |
Report status is one of ready, rendering, orgNameRequired, or locked. Generating a report may consume a report credit (a one-time charge when no allowance is included) - the key owner opts into this by calling generate.
Report listing is offset paginated: pass meta["nextCursor"] (an integer row offset, or None on the last page) back as cursor. client.reports.iter_all() follows the cursor for you.
Attack surface
| Operation | curl | SDK | CLI |
|---|---|---|---|
| Overview | GET /tenants/me/attack-surface | client.attack_surface.summary() | aether365 eas summary |
| History | GET /tenants/me/attack-surface/history | client.attack_surface.history() | aether365 eas history |
| List targets | GET /tenants/me/attack-surface/targets | client.attack_surface.targets() | aether365 eas targets list |
| Add a target | POST /tenants/me/attack-surface/targets | client.attack_surface.add_target(host) | aether365 eas targets add <host> |
| Update a target | PATCH /tenants/me/attack-surface/targets/{id} | client.attack_surface.update_target(id, ...) | aether365 eas targets update <id> --label ... |
| Delete a target | DELETE /tenants/me/attack-surface/targets/{id} | client.attack_surface.remove_target(id) | aether365 eas targets remove <id> |
| Verify a target | POST /tenants/me/attack-surface/targets/{id}/verify | client.attack_surface.verify_target(id) | aether365 eas targets verify <id> |
| Run an EAS scan | POST /tenants/me/attack-surface/scan | client.attack_surface.scan() | aether365 eas scan |
Posture, remediation, and AI Pilot
| Operation | curl | SDK | CLI |
|---|---|---|---|
| Threat / risk view | GET /tenants/me/threats | client.threats.list() | aether365 threats |
| Policy posture | GET /tenants/me/policies | client.policies.list() | aether365 policies |
| Remediation capabilities | GET /tenants/me/remediation/capabilities | client.remediation.capabilities() | aether365 remediation capabilities |
| List remediation plans | GET /tenants/me/remediation-plans | client.remediation.plans() | aether365 remediation plans |
| Get a remediation plan | GET /tenants/me/remediation-plans/{id} | client.remediation.plan(id) | aether365 remediation plan <id> |
| AI Pilot remediable | GET /tenants/me/ai-pilot/remediable | client.ai_pilot.remediable() | aether365 ai-pilot remediable |
| AI Pilot break-glass | GET /tenants/me/ai-pilot/break-glass | client.ai_pilot.break_glass() | aether365 ai-pilot break-glass |
| AI Pilot CA policies | GET /tenants/me/ai-pilot/conditional-access | client.ai_pilot.conditional_access() | aether365 ai-pilot conditional-access |
Remediation is read only for API keys. Applying a plan is not exposed: a plan's action-registry items can include Conditional-Access / authorization-policy patches that would lock a tenant out of its own directory (AADSTS50097), so apply stays in the dashboard with an operator in the loop. AI Pilot conditional-access and break-glass are likewise exposed read only for the same reason.
Error handling and retries
Every API error maps to a typed exception subclass of Aether365Error:
python
from aether365 import Aether365Client
from aether365.exceptions import PermissionError_, PlanLimitError, NotFoundError
with Aether365Client() as client:
try:
client.scans.create("compliance")
except PlanLimitError:
print("Scan quota reached for this plan.")
except PermissionError_:
print("Route not allowed for this key, or plan lacks API access.")
except NotFoundError:
print("Resource not found.")The client automatically retries 429 RATE_LIMITED and 503 SERVICE_STARTING (the dev cold-database warm-up) with exponential backoff, honoring the Retry-After header. Quota errors such as SCAN_PLAN_LIMIT_REACHED are raised immediately - they are not retried.
Using the CLI in CI
aether365 scan results <id> --fail-on-findings exits with code 2 when any failed finding is present, so a pipeline can gate on scan results:
bash
aether365 scan run --type compliance --watch
aether365 scan results "$SCAN_ID" --fail-on-findingsThe CLI writes errors to stderr and exits 1 on any API error, 2 on the findings gate.