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
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,17 @@ ax send "quick update" --skip-ax
- `messages send` waits for a reply by default (polls `list_replies` every 1s). Use `--skip-ax` to send without waiting.
- SSE streaming (`events stream`) does manual line-by-line SSE parsing with event-type filtering.

## Identity Model

**User owns the token. Agent scope limits where it can be used.**

An agent-bound PAT is the agent's credential. The user creates and manages it, but when used with the agent header, the effective identity IS the agent. Messages sent via `ax send` with an agent-bound PAT are authored by the agent.

- `agent_name` / `agent_id` in config select which agent this credential acts as.
- `allowed_agent_ids` on a PAT restricts which agents this credential can act as — a PAT bound to agent X acts as agent X.
- Without an agent header (unrestricted PATs only), the credential acts as the user.
- Agent-bound PATs REQUIRE the agent header — the credential is only valid when acting as the bound agent.

## Config System

Config lives in `.ax/config.toml` (project-local, preferred) or `~/.ax/config.toml` (global fallback). Project root is found by walking up to the nearest `.git` directory. Key fields: `token`, `base_url`, `agent_name`, `space_id`. Env vars: `AX_TOKEN`, `AX_BASE_URL`, `AX_AGENT_NAME`, `AX_SPACE_ID`.
4 changes: 3 additions & 1 deletion ax_cli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@


class AxClient:
def __init__(self, base_url: str, token: str, *, agent_name: str | None = None):
def __init__(self, base_url: str, token: str, *, agent_name: str | None = None, agent_id: str | None = None):
self.base_url = base_url.rstrip("/")
self.token = token
self._headers = {
Expand All @@ -22,6 +22,8 @@ def __init__(self, base_url: str, token: str, *, agent_name: str | None = None):
}
if agent_name:
self._headers["X-Agent-Name"] = agent_name
if agent_id:
self._headers["X-Agent-Id"] = agent_id
self._http = httpx.Client(
base_url=self.base_url, headers=self._headers, timeout=30.0,
)
Expand Down
5 changes: 4 additions & 1 deletion ax_cli/commands/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ def init(
token: str = typer.Option(None, "--token", "-t", help="PAT token"),
base_url: str = typer.Option("http://localhost:8001", "--url", "-u", help="API base URL"),
agent_name: str = typer.Option(None, "--agent", "-a", help="Default agent name"),
agent_id: str = typer.Option(None, "--agent-id", help="Default agent ID (for agent-bound PATs)"),
space_id: str = typer.Option(None, "--space-id", "-s", help="Default space ID"),
):
"""Set up a project-local .ax/config.toml in the current repo.
Expand All @@ -62,7 +63,7 @@ def init(
Add .ax/ to .gitignore — credentials stay out of version control.

Examples:
ax auth init --token axp_u_... --agent protocol --space-id a632f74e-...
ax auth init --token axp_u_... --agent orion --agent-id 70c1b445-...
ax auth init --token axp_u_... --url https://dev.paxai.app --agent canvas
"""
local = _local_config_dir()
Expand All @@ -78,6 +79,8 @@ def init(
cfg["base_url"] = base_url
if agent_name:
cfg["agent_name"] = agent_name
if agent_id:
cfg["agent_id"] = agent_id
if space_id:
cfg["space_id"] = space_id

Expand Down
43 changes: 38 additions & 5 deletions ax_cli/commands/keys.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
"""ax keys — PAT key management."""
"""ax keys — PAT key management.

PATs are user credentials. An agent-bound PAT acts as the agent when used
with the X-Agent-Id header. User owns the token; agent scope limits where
it can be used.
"""
from typing import Optional

import typer
Expand All @@ -13,20 +18,48 @@
@app.command("create")
def create(
name: str = typer.Option(..., "--name", help="Key name"),
agent_id: Optional[list[str]] = typer.Option(None, "--agent-id", help="Restrict to agent (repeatable)"),
agent_id: Optional[list[str]] = typer.Option(None, "--agent-id", help="Bind to agent UUID (repeatable)"),
agent: Optional[str] = typer.Option(None, "--agent", help="Bind to agent by name (resolves to UUID)"),
as_json: bool = JSON_OPTION,
):
"""Create a new API key."""
"""Create a new API key (PAT).

Without --agent-id or --agent: unrestricted user PAT.
With --agent-id or --agent: agent-bound PAT (acts as the agent).

Examples:
ax keys create --name "my-key"
ax keys create --name "orion-key" --agent orion
ax keys create --name "multi" --agent-id <uuid1> --agent-id <uuid2>
"""
client = get_client()

# Resolve --agent name to UUID if provided
bound_ids = list(agent_id) if agent_id else []
if agent:
try:
agents_data = client.list_agents()
agents_list = agents_data if isinstance(agents_data, list) else agents_data.get("agents", [])
match = next((a for a in agents_list if a.get("name", "").lower() == agent.lower()), None)
if not match:
typer.echo(f"Error: Agent '{agent}' not found in this space.", err=True)
raise typer.Exit(1)
bound_ids.append(str(match["id"]))
except httpx.HTTPStatusError as e:
handle_error(e)

try:
data = client.create_key(name, allowed_agent_ids=agent_id or None)
data = client.create_key(name, allowed_agent_ids=bound_ids or None)
except httpx.HTTPStatusError as e:
handle_error(e)
if as_json:
print_json(data)
else:
token = data.get("token") or data.get("key") or data.get("raw_token")
typer.echo(f"Key created: {data.get('credential_id', data.get('id', ''))}")
cred_id = data.get("credential_id", data.get("id", ""))
typer.echo(f"Key created: {cred_id}")
if bound_ids:
typer.echo(f"Bound to: {', '.join(bound_ids)}")
if token:
typer.echo(f"Token: {token}")
typer.echo("Save this token — it won't be shown again.")
Expand Down
14 changes: 13 additions & 1 deletion ax_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,17 @@ def save_space_id(space_id: str, *, local: bool = False) -> None:
_save_config(cfg, local=local)


def resolve_agent_id() -> str | None:
"""Resolve agent_id: env > config > auto-detect from scoped PAT."""
env = os.environ.get("AX_AGENT_ID")
if env:
return env
cfg = _load_config()
if cfg.get("agent_id"):
return cfg["agent_id"]
return None


def get_client() -> AxClient:
token = resolve_token()
if not token:
Expand All @@ -189,4 +200,5 @@ def get_client() -> AxClient:
)
raise typer.Exit(1)
agent_name = resolve_agent_name()
return AxClient(base_url=resolve_base_url(), token=token, agent_name=agent_name)
agent_id = resolve_agent_id()
return AxClient(base_url=resolve_base_url(), token=token, agent_name=agent_name, agent_id=agent_id)