Welcome
The official Python SDK for AnyFrame — a control plane for AI agent sandboxes. Point an agent at a repo, get a sandbox running Claude Code inside, and drive the whole lifecycle from Python.
uv add anyframe
A thin, typed wrapper over the AnyFrame REST API — same surface, same semantics, no extras. Python 3.10+, fully typed (py.typed), every sync method has an async counterpart on AsyncAnyFrame.
Quickstart
Take over a web session
import anyframe
af = anyframe.AnyFrame()
# Grab the session id from the web UI's URL, or list and pick one:
session = next(s for s in af.sessions.list() if s.status == "running")
# Send a turn - same channel the web UI uses.
af.sessions.message(session.id, {"text": "summarize what you've done so far"})
# Watch the agent respond. Ctrl-C when you've seen enough.
for event in af.sessions.events(session.id):
print(event.event, event.json())
export ANYFRAME_API_KEY=afm_...
python takeover.py
Already have an agent and session running in the web UI? Skip building and just talk to it. Both clients hit the same chat channel, so they stay in sync.
Power a chat widget on your site
import anyframe
af = anyframe.AsyncAnyFrame() # async client - this is a hot path
async def on_visitor_message(session_id: int, text: str):
# Forward each visitor turn to the agent's chat bridge.
await af.sessions.message(session_id, {"prompt": text})
# Stream the reply back to the browser as SSE. last_event_id lets
# the browser resume mid-stream after a reconnect.
async for event in af.sessions.events(session_id):
yield event.json()
# A drop-in deployable reference of this pattern:
# https://github.com/tinyhq/anyframe-web-chat
git clone https://github.com/tinyhq/anyframe-web-chat
One async client, one session per visitor, SSE back to the browser. Keep the afm_ token on your server; the browser only talks to your origin.
Build a fresh agent from scratch
import anyframe
af = anyframe.AnyFrame()
# A template is the reusable blueprint — repo, install, system prompt.
template = af.templates.create(
name="box",
repo_url="tinyhq/box",
install_cmd="bun install",
system_prompt="You are a careful, terse engineer.",
)
# An agent is a thin binding to a template. Many agents can share one.
agent = af.agents.create(name="demo", template_id=template.id)
af.agents.build(agent.id)
af.agents.wait_for_build(agent.id)
session = af.sessions.create(agent_id=agent.id)
session = af.sessions.wait_until_running(session.id)
print(session.sandbox_url)
export ANYFRAME_API_KEY=afm_...
python quickstart.py
Template → agent → build → session → wait. Builds are cached by (repo, ref, install_cmd, runtime), so a re-run of the same agent skips straight past wait_for_build.
Setup
Install
uv add anyframe
# or
pip install anyframe
Python 3.10+. Ships fully typed (py.typed) so mypy and pyright resolve out of the box.
| Requirement | Version |
|---|---|
| Python | >= 3.10 |
| httpx | >= 0.27 |
| pydantic | >= 2.6 |
| python-dotenv | >= 1.0 |
Get an API key
# 1. Sign in at https://anyframe.dev
# 2. Dashboard → Settings → API keys → Create key
# 3. Copy the afm_... token (shown once)
# 4. Save it to .env next to your script:
echo 'ANYFRAME_API_KEY=afm_...' >> .env
# Already authed in another script? Mint a new key programmatically:
created = af.tokens.create(name="ci-bot")
print(created.token) # afm_... one-time
Tokens prefix afm_ and the dashboard shows the plaintext once. Drop it into .env next to your script, or export ANYFRAME_API_KEY.
Authentication
import anyframe
# Implicit - reads ANYFRAME_API_KEY from env / .env
af = anyframe.AnyFrame()
# Explicit
af = anyframe.AnyFrame(api_key="afm_...")
# .env in your project root (auto-loaded)
ANYFRAME_API_KEY=afm_...
ANYFRAME_BASE_URL=https://api.anyframe.dev # optional
ANYFRAME_LOG_LEVEL=INFO # set DEBUG for request tracing
Resolution order: api_key= kwarg → ANYFRAME_API_KEY env var → ANYFRAME_API_KEY in .env. None resolved → AuthError.
Environment variables
| Variable | Default | Purpose |
|---|---|---|
ANYFRAME_API_KEY |
- | Personal API token. Required. |
ANYFRAME_BASE_URL |
https://api.anyframe.dev |
Control-plane URL. |
ANYFRAME_LOG_LEVEL |
INFO |
DEBUG enables per-request tracing. |
.env loading
# Library code that shouldn't touch the user's environment:
af = anyframe.AnyFrame(api_key=settings.key, load_dotenv=False)
Auto-loads .env from cwd. Shell env wins; .env fills gaps. Pass load_dotenv=False when embedding the SDK in a library.
Concepts
AnyFrame builds an image from your agent's repo and boots a sandbox running Claude Code inside. The SDK is the Python entry point — everything in the dashboard is callable here.
┌──────────────────────────────────────────┐
│ Agent (repo · system prompt · skills) │
│ └── MCPs · Connector toggles │
┌─────┐ └────────────────────┬─────────────────────┘
│ you │ ─── anyframe SDK ──▶ │ build
└─────┘ ┌────────────────────▼─────────────────────┐
│ Session (sandbox · chat · serve) │
└──────────────────────────────────────────┘
Mental model
# The objects you'll touch, in dependency order:
#
# User ← af.me() (hydrated identity + org memberships)
# Token ← af.tokens
# Credit ← af.credits.get() (free-trial pool, scope-aware)
# Connector ← af.connectors (user/org-scoped MCP registrations)
# + af.connectors.list_catalog() / install_catalog_*
# Integration ← af.integrations (GitHub App installs, provider apps)
# Template ← af.templates (repo, system prompt, install/serve, perms, env)
# ├─ Skill ← af.templates.skills
# ├─ MCP ← af.templates.mcps
# └─ Toggle ← af.templates.connectors (per-template on/off for user connectors)
# Agent ← af.agents (template binding + runtime + per-agent overrides)
# Build ← af.agents.build / .builds / .wait_for_build
# Session ← af.sessions (a live sandbox)
# ├─ Chat ← af.sessions.message / .transcript / .events
# ├─ Preview← af.sessions.previews_start / .previews_stop / .previews_list
# ├─ Setup ← af.sessions.create(is_setup_session=True) → .save_as_base
# ├─ Collab ← af.sessions.presence / .handoff / .take_over / .set_privacy
# └─ Snap ← af.sessions.snapshots
# Attention ← af.attention.list (pending / idle / paused — needs you)
# Org ← af.orgs (workspaces — members, invitations, audit)
Before reading the reference, seven concepts:
Template. The reusable blueprint: a repo, a system prompt, install / serve commands, baseline permissions, and baseline env vars — plus skills, MCPs, and connector toggles. Templates are where the what of an agent lives.
Agent. A thin binding to a template plus per-agent overrides — which runtime runs it, any permissions_override or env_vars_override. Many agents can share one template; cached build images key off the (template, runtime) pair.
Build. A container image baked from the bound template's repo at a specific ref. Builds are cached by (repo + ref + install_cmd + runtime). Calling build() on a cached config returns immediately with queued=False.
Session. A live sandbox running the agent's image. Each has its own filesystem, chat thread, and snapshot history. Sessions start booting, become running, can be paused (snapshotted + idle), and eventually terminated.
Snapshot. A point-in-time capture of a session's filesystem and chat state. Sessions snapshot automatically when they go idle (see idle_timeout_s). You can resume() from any snapshot.
Connector. A user- or org-scoped MCP server registration — Linear, Sentry, Slack, anything that speaks MCP. Configured once, then toggled on or off per template.
Org. An optional shared workspace: every member sees the same templates, agents, sessions, and connectors, and shares one runtime credit pool. Switch in and out of an org with af.set_active_org(org_id).
The client
import anyframe
# Synchronous
af = anyframe.AnyFrame(
api_key=None, # falls back to ANYFRAME_API_KEY
base_url=None, # falls back to ANYFRAME_BASE_URL
timeout=30.0, # per-request seconds
load_dotenv=True, # set False to skip .env autoload
)
# Asynchronous (same constructor signature)
from anyframe import AsyncAnyFrame
af = AsyncAnyFrame()
# Context-managed (preferred - guarantees the connection pool closes)
with anyframe.AnyFrame() as af:
me = af.me()
# Identity + workspace
me = af.me()
print(me.email, me.active_org_id)
af.set_active_org(org_id) # switch into an org workspace
af.set_active_org(None) # back to personal
af.public_config() # server feature flags (unauthenticated)
# Resources
af.tokens # API token management
af.credentials # Claude / Codex runtime credentials (personal)
af.credits # Free-trial credit balance (personal or active org)
af.connectors # User/org MCP registrations + curated catalog
af.templates # Templates + nested skills, mcps, connector toggles
af.agents # Agents (template binding + overrides) + builds
af.sessions # Live sandboxes (chat, previews, snapshots, collab)
af.attention # Items needing the operator (pending / idle / paused)
af.integrations # GitHub App installs, provider apps, webhook bindings
af.orgs # Organisations — members, invitations, audit log
AnyFrame and AsyncAnyFrame share the same constructor signature and the same resource attributes — write code once, swap clients.
Constructor parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
api_key |
str | None |
env | Personal token (afm_...). Falls back to ANYFRAME_API_KEY. |
base_url |
str | None |
env | Control-plane URL. Falls back to ANYFRAME_BASE_URL, then https://api.anyframe.dev. |
timeout |
float |
30.0 |
Per-request timeout in seconds. |
load_dotenv |
bool |
True |
Auto-load .env from the working directory before reading env vars. |
Identity
me = af.me()
# User(id=42, login='you', email='[email protected]', name='You',
# is_superadmin=False,
# memberships=[OrgMembership(org=Org(slug='acme', …), role='owner')],
# active_org_id=100,
# suggested_orgs=[], # auto_join_domain matches
# pending_join_requests=[],
# pending_invitations=[]) # GitHub-login invites to accept inline
af.set_active_org(100) # switch into Acme's workspace
af.set_active_org(None) # back to personal
cfg = af.public_config() # unauthenticated server feature flags
# PublicConfig(free_trial_enabled=True, chat_widget_enabled=False, google_enabled=True)
me() returns the hydrated identity for the authenticated caller. When the server has organisations enabled, the response also carries every membership, the currently-active workspace, any auto-join-domain suggested_orgs, pending_join_requests you've opened, and any pending_invitations addressed to your GitHub login (which you can accept in place with af.orgs.invitations.accept_for_me(id)).
set_active_org(org_id) flips the active workspace — every resource call afterwards (templates, agents, sessions, …) is scoped to that org. Pass None to switch back to personal.
Lifecycle
af = anyframe.AnyFrame()
try:
...
finally:
af.close()
# Or with a context manager:
with anyframe.AnyFrame() as af:
...
The client holds an internal httpx connection pool. Always close it - either with close() or by using the client as a context manager.
For the async client, the equivalent is await af.aclose() / async with AsyncAnyFrame() as af.
Reference
Templates
# Create — the blueprint behind every agent
template = af.templates.create(
name="box",
description="Bun + React preview stack",
system_prompt="You are a careful, terse engineer.",
repo_url="tinyhq/box",
repo_ref="main",
install_cmd="bun install",
serve_cmd="bun dev",
preview_ports=[3000],
install_id=42, # GitHub App install for the repo
)
# List / get / update / delete
af.templates.list()
detail = af.templates.get(template.id) # includes skills, mcps, connector toggles, agent_count
af.templates.update(template.id, system_prompt="Be brief.")
af.templates.delete(template.id) # 409 if any agent is still bound
Templates own the what: the repo binding, install/serve commands, system prompt, baseline permissions, baseline env vars, and the attached skills + MCPs + connector toggles. One template can back many agents.
Create a template
| Field | Type | Description |
|---|---|---|
name |
str |
Required. 1-255 chars. |
description |
str | None |
Free-text description. |
system_prompt |
str | None |
Prefix injected into the runtime's system prompt. |
repo_url |
str | None |
owner/name GitHub repo. Omit for a general-purpose template with no repo. |
repo_ref |
str | None |
Branch / tag / SHA. Server default: main. |
install_cmd |
str | None |
Shell command run during build to install deps. |
serve_cmd |
str | None |
Preview-server command (e.g. bun dev). |
preview_ports |
list[int] | None |
Ports allowed via the previews API. |
permissions |
dict | None |
Baseline permissions preset. |
env_vars |
dict[str, str] | None |
Baseline env vars. Keys must match [A-Z_][A-Z0-9_]*. Encrypted at rest, masked in responses. |
install_id |
int | None |
Required when repo_url is set. ID of the GitHub App install that grants access (see Integrations). |
Changing repo_url, repo_ref, or install_cmd invalidates the warmup snapshot on every bound agent and re-warms them in the background.
Skills, MCPs, Connector toggles
# Skills — Claude Code skills (markdown with frontmatter)
af.templates.skills.list(template.id)
af.templates.skills.create(
template.id,
name="repo-tour",
source="inline",
content={"markdown": "..."},
)
af.templates.skills.update(template.id, skill.id, enabled=False)
af.templates.skills.delete(template.id, skill.id)
# MCPs — inline MCP servers defined on this template
af.templates.mcps.list(template.id)
af.templates.mcps.create(
template.id,
name="local-fs",
transport="stdio",
config={"command": "npx", "args": ["@modelcontextprotocol/server-filesystem", "/work"]},
)
# Connector toggles — flip user/org connectors on or off for this template
af.templates.connectors.list(template.id)
af.templates.connectors.set(template.id, connector_id=7, enabled=True)
Three nested managers, mirroring the v1 agent sub-resources but rooted on the template:
templates.skills— Claude Code skills, scoped to the template.templates.mcps— inline MCP servers (use when there's no point sharing the config across templates).templates.connectors— toggles for user/org-scoped connectors (see Connectors).
Agents
# Create — bind to a template, optionally override
agent = af.agents.create(
name="prod-bot",
template_id=template.id,
runtime="claude",
permissions_override={"preset": "full_trust"},
env_vars_override={"DEBUG": "1"},
)
# List / get / update / delete
af.agents.list()
detail = af.agents.get(agent.id) # embeds the bound template + image
af.agents.update(agent.id, runtime="codex")
af.agents.update(agent.id, permissions_override=None) # clear → fall back to template
af.agents.delete(agent.id) # cascades to sessions + builds
An agent is a thin binding to a template plus per-agent overrides. The permissions and env_vars fields on the response show the effective values; permissions_override and env_vars_override show what's set directly on the agent so callers can tell inherited from overridden apart.
Create an agent
| Field | Type | Description |
|---|---|---|
name |
str |
Required. 1-255 chars. |
template_id |
int |
Required. The template to bind to. |
description |
str | None |
Free-text description. |
runtime |
"claude" | "codex" | None |
Coding-agent runtime. Server default: "claude". |
permissions_override |
dict | None |
If set, replaces the template's permissions for this agent. Pass None (the default) to inherit. |
env_vars_override |
dict[str, str] | None |
Per-agent env-var overlay merged onto the template's vars. Same key constraints, same masking. |
Update an agent
update(agent_id, **fields) forwards only the fields you pass. The override fields are nullable — pass None to clear, omit to leave alone:
af.agents.update(agent.id, permissions_override=None) # clear → inherit template
af.agents.update(agent.id, env_vars_override={}) # clear → no overlay
af.agents.update(agent.id, env_vars_override={"DEBUG": "1"}) # merge into existing
Build
queued = af.agents.build(agent.id)
# BuildQueued(queued=True, build_id=128) — or queued=False with a reason
# if a cached image already exists for this (template recipe + runtime).
status = af.agents.wait_for_build(agent.id, timeout=600.0)
# BuildStatus(state='succeeded', built_image_id='im_abc123', …)
# Streaming the live log
for event in af.agents.stream_build(agent.id, queued.build_id):
print(event.event, event.json())
Builds are cached by the combination of the bound template's (repo_url, repo_ref, install_cmd) and the agent's runtime. Pass force=True to rebuild from scratch.
wait_for_build polls build_status until the build reaches a terminal state. It raises AnyFrameError on failed and TimeoutError if the deadline is exceeded.
Sessions
# Boot
session = af.sessions.create(agent_id=agent.id, idle_timeout_s=300)
session = af.sessions.wait_until_running(session.id)
print(session.sandbox_url)
# Inspect
af.sessions.list()
af.sessions.get(session.id)
# Terminate / resume
af.sessions.terminate(session.id) # snapshot + stop
af.sessions.resume(session.id) # rehydrate from latest snapshot
af.sessions.delete(session.id) # hard-delete the row
Sessions are sandboxes. Boot one, talk to it, snapshot it, throw it away.
Session lifecycle
create()
│
▼
┌─────────────┐
│ booting │
└──────┬──────┘
│ wait_until_running()
▼
┌─────────────┐ serve_start()
│ running │ ◀────────────────┐
└──────┬──────┘ │
│ idle_timeout_s │
▼ │
┌─────────────┐ resume() │
│ paused │ ────────────────▶┘
└──────┬──────┘
│ terminate()
▼
┌─────────────┐
│ terminated │
└─────────────┘
wait_until_running blocks until the session reaches running or hits a terminal non-running state. It raises TimeoutError if neither happens within timeout=180.0 seconds.
Create a session
| Parameter | Type | Default | Description |
|---|---|---|---|
agent_id |
int |
- | Required. The agent to run. |
idle_timeout_s |
int |
300 |
Snapshot after this many idle seconds. |
unsafe |
bool |
False |
Pass --dangerously-skip-permissions to Claude. Leave off. |
resume_from_snapshot_id |
int | None |
None |
Hydrate from a snapshot instead of booting fresh. |
Chat
# Send a message - body is forwarded verbatim to the in-sandbox chat bridge
af.sessions.message(session.id, {"role": "user", "content": "list files"})
# Reply to a permission prompt
af.sessions.respond(session.id, {"prompt_id": "p-...", "approve": True})
# Replay the persisted transcript
for evt in af.sessions.transcript(session.id, since=0, limit=1000):
print(evt.seq, evt.kind, evt.data)
# Subscribe to live events (SSE)
for evt in af.sessions.events(session.id):
print(evt.event, evt.json())
The chat bridge speaks two flavours of API:
message/respond- POST endpoints. The body is forwarded verbatim to the in-sandbox chat server, so the exact schema lives there.transcript/events- replay vs subscribe.transcriptreturns persisted events ordered byseq.eventsstreams them live as SSE - passlast_event_idto resume from a checkpoint.
Previews (in-sandbox dev servers)
# Start one preview - port is optional; the control plane picks from
# preview_ports or allocates a new one (restart_pending=True if it does).
result = af.sessions.previews_start(session.id, cmd="bun dev", port=3000, name="web")
print(result.url) # tunnel URL once running
af.sessions.previews_status(session.id, name="web")
af.sessions.previews_list(session.id) # → list[Preview]
af.sessions.previews_logs(session.id, name="web", tail=200)
af.sessions.previews_stop(session.id, name="web")
# Atomic batch - restarts the sandbox at most once if new ports are allocated.
af.sessions.previews_batch_start(session.id, [
anyframe.PreviewSpec(cmd="bun dev", port=3000, name="web"),
anyframe.PreviewSpec(cmd="bun api", port=4000, name="api"),
])
Launch dev servers inside the sandbox and tunnel their ports out. Multiple previews can coexist per session - address them by port or name. The live list lives on session.previews (a list[Preview]); the older serve_status / serve_port / serve_url triple was retired in favour of this list.
| Method | Action | Returns |
|---|---|---|
previews_list |
list |
list[Preview] |
previews_start |
start |
PreviewActionResult |
previews_stop |
stop |
PreviewActionResult |
previews_status |
status |
PreviewActionResult |
previews_logs |
logs |
raw JSON ({"lines": [...]}) |
previews_batch_start |
batch_start |
PreviewBatchResult |
Setup sessions (save_as_base)
session = af.sessions.create(agent_id=agent.id, is_setup_session=True)
af.sessions.wait_until_running(session.id)
# ... do interactive seeding ...
result = af.sessions.save_as_base(session.id)
# SaveAsBaseResult(warmup_image_id='im_abc', warmup_inputs_hash='sha256:...')
Setup sessions are user-driven sandboxes you use to clone, install, and warm caches before promoting the result to the agent's warmup image. Future normal sessions for the same agent hydrate from the promoted snapshot. Setup sessions can re-promote multiple times - each call overwrites the saved base.
Snapshots
snapshots = af.sessions.snapshots(session.id)
af.sessions.resume(latest_snapshot_session_id)
Snapshots happen automatically on idle. Each captures the filesystem and chat state. Resume from any snapshot to fork a session.
Collaboration (org sessions)
# Who's currently watching this session?
for p in af.sessions.presence(session.id):
print(p.login, "driver" if p.is_driver else "watcher")
# A watcher asks the driver to hand off.
req = af.sessions.request_control(session.id, message="taking over deploy")
# ControlRequest(id=42, status='pending')
# Driver (or an admin) hands the seat to another member.
af.sessions.handoff(session.id, to_user_id=5, request_id=req.id)
# HandoffResult(driver_user_id=5)
# Admin / owner takes the seat without the current driver's consent.
af.sessions.take_over(session.id)
# Toggle a session's visibility — private sessions disappear from other
# members' lists and the activity feed (admins can still see them).
af.sessions.set_privacy(session.id, private=True)
In an org workspace, multiple members can watch a session over the SSE stream. Only one member at a time — the driver — can send messages. The collab endpoints are no-ops in personal mode (the creator is always the driver).
| Endpoint | Who can call it | Audit kind |
|---|---|---|
presence(session_id) |
Any member with access | — |
request_control(session_id, *, message=None) |
Any watcher | session.handoff_requested |
handoff(session_id, *, to_user_id, request_id=None) |
Current driver, or admin | session.handoff_completed |
take_over(session_id) |
Admin / owner | session.taken_over |
set_privacy(session_id, *, private) |
Session creator (or admin) | session.privacy_changed |
Connectors
# User-scoped: configure once, reuse across agents
af.connectors.list()
# Inspect an MCP URL before saving
af.connectors.discover("https://mcp.linear.app")
# ConnectorDiscovery(auth_scheme='oauth2', server_name='Linear', …)
# Register
oauth = af.connectors.create_oauth(mcp_url="https://mcp.linear.app", display_name="Linear")
print(oauth.authorize_url) # send the user here to complete OAuth
bearer = af.connectors.create_bearer(
mcp_url="https://mcp.example.com",
display_name="Example",
token="bearer-secret",
)
af.connectors.reauthorize(connector.id) # fresh OAuth URL when a token expires
af.connectors.delete(connector.id)
Connectors are user- or org-scoped MCP registrations. Configure them once, then flip them on per-template with af.templates.connectors.set(...) — every agent bound to the template inherits the resolved set. Four auth schemes are supported:
- OAuth 2.0 (
create_oauth) — returns an authorization URL; the user finishes the flow in a browser. - Bearer (
create_bearer) — accepts anAuthorization: Bearer …token directly. - Custom header (
create_custom_header) — for servers that expect a non-standard header (e.g.X-API-Key). - Stdio (
create_stdio) — spawns a localcommand args…inside each sandbox and speaks MCP over its stdio.
# Bearer — pre-issued tokens
af.connectors.create_bearer(
mcp_url="https://mcp.example.com",
display_name="Example",
token="bearer-secret",
)
# Custom header — servers that don't speak Bearer
af.connectors.create_custom_header(
mcp_url="https://api.example.com/mcp",
display_name="Example",
header_name="X-API-Key",
token="sk_live_…",
)
# Stdio — spawn a local MCP server inside the sandbox
af.connectors.create_stdio(
display_name="local-fs",
command="npx",
args=["@modelcontextprotocol/server-filesystem", "/work"],
env={"NODE_ENV": "production"},
)
Catalog
catalog = af.connectors.list_catalog() # ConnectorCatalogItem[]
linear = next(c for c in catalog if c.slug == "linear")
print(linear.setup_kind, linear.installed) # "oauth_dcr", False
# Install by slug - the catalog entry supplies the MCP URL + display name.
af.connectors.install_catalog_oauth("linear") # returns ConnectorAuthorize
af.connectors.install_catalog_bearer("sentry", token="sntrys_...")
The control plane ships a curated catalog (Linear, Sentry, Google, …). Each entry's setup_kind (oauth_dcr, oauth_preregistered, bearer_token, custom_mcp) tells you which install method to call. Entries with coming_soon=True reject install attempts.
Attention rail
for item in af.attention.list(limit=20):
if item.kind == "pending":
# agent is blocked on a permission_request or ask_user_question
...
elif item.kind == "idle":
# running session waiting on the next user prompt
...
elif item.kind == "paused":
# session paused within the last 24h - candidate to resume
...
af.attention.list() returns the rail's curated, newest-first list of items needing the operator. Three discriminated-union members - AttentionPendingItem, AttentionIdleItem, AttentionPausedItem - share the same parent type AttentionItem. Pending always sorts above idle and paused.
Credentials
view = af.credentials.get()
# Credentials(claude=CredentialPart(set=True, last4='abcd'),
# codex=CredentialPart(set=False, last4=None))
af.credentials.set_claude("sk-...") # Claude OAuth token (Claude runtime)
af.credentials.set_codex("sk-...") # OpenAI Codex token (Codex runtime)
af.credentials.clear_claude()
af.credentials.clear_codex()
The control plane stores two personal runtime credentials:
- Claude OAuth token — required for agents on the Claude runtime.
- Codex token — required for agents on the Codex (OpenAI) runtime.
The SDK only ever surfaces redacted views (set=True + last4=…). Plaintext leaves your machine once, when you call set_*.
When you're in an org workspace, runtime credentials are managed at the org level — see Orgs › Credentials. Org credentials, when set, win over personal ones for every member.
Credits
bal = af.credits.get()
# CreditBalance(limit=1000, used=250, remaining=750, exhausted=False,
# scope='personal', org_token_active=False, checked_at=…)
The free-trial credit pool. scope reflects whether you're looking at the personal pool or the active org's shared pool — switch contexts with af.set_active_org(...). When the active org has its own runtime token set, org_token_active=True and sessions don't draw from the credit pool at all.
Tokens
af.tokens.list()
# [Token(id=1, name='ci-bot', last_used_at=..., created_at=...)]
created = af.tokens.create(name="ci-bot")
print(created.token) # afm_... - visible once, store it now
af.tokens.revoke(created.id)
API tokens are how the SDK authenticates. create() is the one moment the raw token value is visible - every subsequent listing shows only metadata.
Integrations
# Every install in the current scope (personal or org)
af.integrations.list()
# GitHub-side picker — populate the template-create form
installs = af.integrations.list_github_installs()
repos = af.integrations.list_github_repos(installs[0].id)
# Bind an install's webhook events to one agent
af.integrations.set_binding(installs[0].id, agent_id=42)
af.integrations.delete_binding(installs[0].id)
af.integrations.delete(installs[0].id) # revoke the install entirely
# Advanced — provider apps (the AnyFrame side of the OAuth/App config)
af.integrations.list_provider_apps()
An integration install is one OAuth/App install of a third-party service — a GitHub App on an org, a Slack workspace bot, a Discord app. The control plane uses installs to mint short-lived tokens at sandbox boot time and route incoming webhook events to a bound agent.
The most common path: install a GitHub App via the dashboard, then pass its install_id to templates.create() to bind a repo. The OAuth dance itself runs in a browser; this resource is the read / delete / binding surface around it.
| Method | Scope | Use case |
|---|---|---|
list() |
personal / org | Every install in scope. |
list_github_installs() |
personal / org | Slim picker shape for template-create. |
list_github_repos(install_id) |
personal / org | Server-side GitHub repo listing for the picker. |
set_binding(install_id, *, agent_id) |
personal / org | Route this install's webhooks to one agent (1:1, "steal" semantics). |
delete_binding(install_id) |
personal / org | Unbind — install stays connected but events are dropped. |
delete(install_id) |
personal / org | Revoke the install entirely. |
list_provider_apps() |
personal / org | The AnyFrame side of the OAuth/App config (advanced). |
Orgs
# Where am I a member?
for m in af.orgs.list():
print(m.org.slug, m.role)
# Create / get / update / delete (slug = URL handle)
af.orgs.check_slug("acme-2") # SlugAvailability(available=True, reason='ok')
org = af.orgs.create(slug="acme", name="Acme", auto_join_domain="acme.com")
af.orgs.get("acme")
af.orgs.update("acme", name="Acme Corp")
af.orgs.transfer_ownership("acme", new_owner_user_id=42)
af.orgs.delete("acme") # archive (owner only)
# Switch into an org workspace for subsequent calls
af.set_active_org(org.id)
af.set_active_org(None) # back to personal
An organisation is a shared workspace: every member sees the same templates, agents, sessions, and connectors, and shares one runtime credit pool. The whole surface is gated server-side behind an ORGS_ENABLED flag — every endpoint returns 404 when the flag is off.
Members and join requests
af.orgs.members.list("acme")
af.orgs.members.change_role("acme", user_id, role="admin")
af.orgs.members.remove("acme", user_id)
af.orgs.members.leave("acme") # leave as the current user
# Domain-based join requests (auto_join_domain matches the user's email)
af.orgs.join_requests.list("acme") # admin-only
af.orgs.join_requests.create("acme") # as the requesting user
af.orgs.join_requests.approve("acme", request_id, role="member")
af.orgs.join_requests.reject("acme", request_id)
Invitations
# Invite by GitHub login — shows up inline in the invitee's org switcher
inv = af.orgs.invitations.create("acme", github_login="alice", message="join us")
# OrgInvitationCreated(invitation=…, url='https://anyframe.dev/invites/tok_xyz')
# …or by email — the URL is the one-time invite link
inv = af.orgs.invitations.create("acme", email="[email protected]", role="admin")
af.orgs.invitations.list("acme") # admin-only
af.orgs.invitations.revoke("acme", invitation_id)
af.orgs.invitations.resend("acme", invitation_id) # mints a fresh token
# Invitee side — by plaintext token (anonymous-friendly)
view = af.orgs.invitations.view_by_token("tok_xyz")
af.orgs.invitations.accept_by_token("tok_xyz")
# Or accept a github_login invite inline, no token needed
me = af.me()
for pending in me.pending_invitations or []:
af.orgs.invitations.accept_for_me(pending.id)
Org credentials
af.orgs.credentials.get("acme")
af.orgs.credentials.set_claude("acme", "sk-...")
af.orgs.credentials.set_codex("acme", "sk-...")
af.orgs.credentials.clear_claude("acme")
af.orgs.credentials.clear_codex("acme")
Org credentials win over each member's personal credentials when the active workspace is this org. Admin-only; the same redacted view as personal credentials, never the plaintext.
Audit log + activity feed
events = af.orgs.audit.list("acme", kind="agent.created", limit=50)
csv_bytes = af.orgs.audit.export_csv("acme") # full log, server-streamed
summary = af.orgs.activity("acme") # dashboard aggregates
Audit events span agent.*, template.*, connector.*, session.handoff_completed, session.taken_over, session.privacy_changed, and the membership lifecycle. Admin-only.
Streaming (SSE)
# Stream a live build log
for event in af.agents.stream_build(agent.id, build_id):
if event.event == "line":
print(event.json()["text"], end="")
elif event.event == "state":
print("\n[state]", event.json())
# Subscribe to chat events from a running session
for event in af.sessions.events(session.id, last_event_id=checkpoint):
print(event.event, event.json())
checkpoint = event.id # for reconnect resume
Two endpoints stream Server-Sent Events:
| Stream | Method | Use case |
|---|---|---|
| Build log | agents.stream_build(agent_id, build_id) |
Tail the Docker build live. |
| Chat events | sessions.events(session_id) |
Tail the chat thread live. |
Both return an iterator of SSEEvent. Each event has .id, .event, .data (raw string), and .json() (parsed payload). For chat events, pass last_event_id= to resume after a disconnect - the server replays missed frames.
Async
import asyncio
from anyframe import AsyncAnyFrame
async def main():
async with AsyncAnyFrame() as af:
agent = await af.agents.create(name="demo", repo_url="tinyhq/box", install_cmd="bun install")
await af.agents.build(agent.id)
await af.agents.wait_for_build(agent.id)
session = await af.sessions.create(agent_id=agent.id)
session = await af.sessions.wait_until_running(session.id)
async for event in af.sessions.events(session.id):
print(event.event, event.json())
asyncio.run(main())
AsyncAnyFrame mirrors AnyFrame 1:1. Every method exists on both with the same signature - just await it. Streaming methods become async for iterators.
Use it when:
- You're inside an existing
asyncioevent loop (FastAPI, aiohttp, etc.). - You need to fan out many calls in parallel -
asyncio.gather()overAsyncAnyFramecalls is the right primitive.
Configuration
Settings
| Env var | Constructor kwarg | Default | Purpose |
|---|---|---|---|
ANYFRAME_API_KEY |
api_key |
- | Personal token, required. |
ANYFRAME_BASE_URL |
base_url |
https://api.anyframe.dev |
Control-plane URL. |
ANYFRAME_LOG_LEVEL |
- | INFO |
DEBUG enables per-request tracing. |
| - | timeout |
30.0 |
Per-request seconds. |
| - | load_dotenv |
True |
Auto-load .env from cwd. |
Logging
import logging
logging.getLogger("anyframe").setLevel(logging.DEBUG)
The SDK logs under the anyframe logger. Set ANYFRAME_LOG_LEVEL=DEBUG for one-line traces of every request (method, path, status, elapsed ms).
Errors & support
Errors
import anyframe
try:
af.agents.get(999)
except anyframe.NotFoundError:
print("no such agent")
except anyframe.AuthError:
print("check ANYFRAME_API_KEY")
except anyframe.AnyFrameError as e:
# base class - catches everything above
print(f"unexpected {e!r}")
# Exception hierarchy
anyframe.AnyFrameError # base - one except catches all
├── anyframe.APIError # any non-2xx (.status_code, .message)
│ ├── anyframe.AuthError # 401 - bad / missing API key
│ ├── anyframe.NotFoundError # 404
│ ├── anyframe.ConflictError # 409 - e.g. delete on a running session
│ ├── anyframe.ValidationError # 400 / 422 (.errors carries field details)
│ ├── anyframe.RateLimitError # 429 (.retry_after seconds)
│ └── anyframe.ServerError # 5xx
Every HTTP error rises through AnyFrameError, so one except catches the entire failure surface. Most callers will want narrower clauses:
| Exception | HTTP | When |
|---|---|---|
AuthError |
401 |
Missing or revoked API key. |
NotFoundError |
404 |
Resource doesn't exist (or isn't yours). |
ConflictError |
409 |
State conflict - e.g. delete() on a running session. |
ValidationError |
400 / 422 |
Bad request body. .errors carries the field-level detail. |
RateLimitError |
429 |
Rate limited. .retry_after (seconds) is set when the server provides it. |
ServerError |
5xx |
Server-side failure. Always safe to retry idempotent reads. |
APIError |
any other non-2xx | Fallback. .status_code and .message are set. |
TimeoutError (built-in) is raised by wait_for_build and wait_until_running when their deadlines elapse - it's not part of the AnyFrameError tree.
Support
import anyframe
print(anyframe.__version__)
# When opening an issue, include the SDK version and a minimal repro.
Found a bug, have a question, or want to share what you're building? Join us on Discord - the team hangs out in #sdk. When reporting a bug, include the SDK version (anyframe.__version__), the call that failed, and the response status.
For dashboard / billing / account issues, head to anyframe.dev.
Migrating from 1.x
# v1.x
agent = af.agents.create(
name="demo",
repo_url="tinyhq/box",
install_cmd="bun install",
system_prompt="…",
permissions={"preset": "standard"},
env_vars={"NODE_ENV": "production"},
)
af.agents.skills.create(agent.id, name="…", source="inline", content={…})
# v2.0
template = af.templates.create(
name="box",
repo_url="tinyhq/box",
install_cmd="bun install",
system_prompt="…",
permissions={"preset": "standard"},
env_vars={"NODE_ENV": "production"},
install_id=42, # NEW — required for repo-bound templates
)
af.templates.skills.create(template.id, name="…", source="inline", content={…})
agent = af.agents.create(name="demo", template_id=template.id)
2.0 is the first breaking release. Three structural changes:
Agent ↔ Template split. Every field on a v1 agent that described what it does (repo, install/serve, system prompt, skills, MCPs, connector toggles, baseline permissions, baseline env vars) moved to a new Template resource. An agent now binds to a template plus optional
runtime,permissions_override,env_vars_override. Pull your agent-create call apart along that seam.Repo access via integrations, not credentials.
credentials.set_github/clear_githubare gone, and theCredentialsmodel no longer has agithubfield. Install a GitHub App through the dashboard, then pass itsinstall_idtotemplates.create(). See Integrations.Optional org workspace. The new Orgs resource lets you share templates, agents, sessions, and connectors with teammates.
af.me()now hydrates membership data;af.set_active_org(org_id)swaps the active scope. Personal-only callers don't need to do anything — every personal endpoint behaves identically.
| 1.x call | 2.0 call |
|---|---|
af.agents.create(name=…, repo_url=…, install_cmd=…, system_prompt=…) |
tpl = af.templates.create(...); af.agents.create(name=…, template_id=tpl.id) |
af.agents.skills.*(agent.id, …) |
af.templates.skills.*(template.id, …) |
af.agents.mcps.*(agent.id, …) |
af.templates.mcps.*(template.id, …) |
af.agents.connectors.*(agent.id, …) |
af.templates.connectors.*(template.id, …) |
af.credentials.set_github(token) |
install a GitHub App; pass install_id= to templates.create() |
creds.github |
(removed — no GitHub credential field) |
User(id, login, name, avatar_url) |
User(id, login, email, name, avatar_url, is_superadmin, memberships, active_org_id, …) |
New surfaces in 2.0: af.templates, af.credits, af.integrations, af.orgs, af.set_active_org(...), af.public_config(), connectors.create_custom_header(...), connectors.create_stdio(...), and the org-collab endpoints on af.sessions (presence, request_control, handoff, take_over, set_privacy).