Metadata-Version: 2.4
Name: happy-engineering-sdk
Version: 0.3.0
Summary: Python SDK for controlling Happy agent sessions
Project-URL: Homepage, https://happy.engineering
Author-email: Scott Fraser <scott@jascro.com>
License: MIT
Keywords: agents,ai,engineering,happy,llm,sdk
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.11
Requires-Dist: cryptography>=43.0
Requires-Dist: httpx>=0.27
Requires-Dist: pynacl>=1.5
Requires-Dist: python-socketio[asyncio-client]>=5.11
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Description-Content-Type: text/markdown

# happy-engineering-sdk

[![PyPI version](https://img.shields.io/pypi/v/happy-engineering-sdk)](https://pypi.org/project/happy-engineering-sdk/)
[![Python versions](https://img.shields.io/pypi/pyversions/happy-engineering-sdk)](https://pypi.org/project/happy-engineering-sdk/)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)

Python SDK for controlling Happy agent sessions.

## Installation

```bash
pip install happy-engineering-sdk
```

## Credentials

The SDK supports three ways to supply credentials.

### 1. Environment variables (recommended for containers)

```bash
export HAPPY_SERVER_URL=https://api.happy.engineering
export HAPPY_TOKEN=eyJ...
export HAPPY_SECRET=DroKzo0w...==
```

`HAPPY_TOKEN` is the bearer token and `HAPPY_SECRET` is the raw base64
`machineKey` string from your `access.key` file.

### 2. Key file (default for local use)

Download `access.key` (or `agent.key`) from the Happy dashboard and place it at:

```
~/.happy/access.key   # written by the Happy CLI
~/.happy/agent.key    # legacy location
```

The SDK understands both formats — the CLI-written `access.key` format
(`encryption.machineKey`) and the older `agent.key` format (`secret`).

Set the server URL:

```bash
export HAPPY_SERVER_URL=https://api.happy.engineering
```

### 3. Inline kwargs

```python
client = HappyClient(
    server_url="https://api.happy.engineering",
    token="eyJ...",
    secret_b64="DroKzo0w...==",
)
```

## Quick start — async (`HappyClient`)

```python
import asyncio
from happy_sdk import HappyClient

async def main():
    client = HappyClient()          # reads ~/.happy/agent.key + HAPPY_SERVER_URL
    session_id = await client.run_task(
        machine_id="my-machine",
        directory="/home/user/project",
        prompt="Summarise this week's PRs",
    )
    print(f"Task complete — session {session_id}")

asyncio.run(main())
```

Using environment variables:

```python
client = HappyClient.from_env()    # reads HAPPY_TOKEN, HAPPY_SECRET, HAPPY_SERVER_URL
```

## Quick start — sync (`SyncHappyClient`)

For Django management commands, CLI scripts, or any sync context — use
`SyncHappyClient`. It has the same API as `HappyClient` but wraps every call
with `asyncio.run()` internally so you never touch async machinery:

```python
from happy_sdk import SyncHappyClient

# From environment variables
client = SyncHappyClient.from_env()

session_id = client.run_task(
    machine_id="my-machine",
    directory="/home/user/project",
    prompt="Summarise this week's PRs",
)
print(f"Task complete — session {session_id}")
```

All three constructor styles work with `SyncHappyClient`:

```python
# From env vars
client = SyncHappyClient.from_env()
client = SyncHappyClient.from_env(server_url="https://...")

# From inline kwargs
client = SyncHappyClient(server_url="...", token="...", secret_b64="...")

# From key file
client = SyncHappyClient(server_url="...", credentials_path="~/.happy/access.key")
```

## Manual session lifecycle

```python
import asyncio
from happy_sdk import HappyClient

async def main():
    client = HappyClient()

    session_id = await client.spawn_session(
        machine_id="my-machine",
        directory="/home/user/project",
    )
    await client.send_message(session_id, "Hello")
    await client.wait_for_turn_completion(session_id)
    messages = await client.get_messages(session_id)
    await client.stop_session(session_id)

asyncio.run(main())
```

## API reference

### `HappyClient` (async) / `SyncHappyClient` (sync)

Both classes expose identical method signatures. `HappyClient` methods are
`async`; `SyncHappyClient` methods are regular (blocking) functions.

#### Constructors

| Constructor | Description |
|-------------|-------------|
| `HappyClient(server_url=None, credentials_path=None, token=None, secret_b64=None)` | File or kwargs. `token`+`secret_b64` take precedence over `credentials_path`. `server_url` falls back to `HAPPY_SERVER_URL`. |
| `HappyClient.from_env(server_url=None)` | Reads `HAPPY_TOKEN`, `HAPPY_SECRET`, `HAPPY_SERVER_URL`. Raises `AuthenticationError` if any are missing. |
| `SyncHappyClient(...)` | Same arguments as `HappyClient`. |
| `SyncHappyClient.from_env(server_url=None)` | Same as `HappyClient.from_env`. |

#### Session lifecycle

| Method | Signature | Description |
|--------|-----------|-------------|
| `spawn_session` | `(machine_id, directory, agent="claude", create_dir=False, name=None) → str` | Create a new agent session — returns the session ID. Pass `name=` to label it in the Happy apps (applied right after spawn) |
| `stop_session` | `(session_id)` | Stop a running session |
| `delete_session` | `(session_id)` | Permanently delete a session |

#### Naming & metadata

| Method | Signature | Description |
|--------|-----------|-------------|
| `set_session_name` | `(session_id, name) → Session` | Set the session's human-visible name (shown in the Happy web/mobile apps) |
| `update_session_metadata` | `(session_id, changes: dict) → Session` | Merge `changes` into the session's metadata and persist it. Shallow merge (your keys win, others preserved), with optimistic-concurrency retries |

A session's name lives in its encrypted metadata rather than being a spawn-time
argument, so naming is a quick follow-up write after the session exists. The
Happy apps show `metadata.summary.text` as the session title, so
`set_session_name` writes the name there (and mirrors it to `metadata.name` for
read-back):

```python
sid = await client.spawn_session(machine_id, "/repo", name="Nightly build")
# ...or rename later:
await client.set_session_name(sid, "Nightly build (retry)")
# read it back:
session = await client.get_session(sid)
print(session.metadata["summary"]["text"], session.metadata_version)
```

#### Messaging

| Method | Signature | Description |
|--------|-----------|-------------|
| `send_message` | `(session_id, text, permission_mode="yolo")` | Send a message to an active session |

#### Waiting

| Method | Signature | Description |
|--------|-----------|-------------|
| `wait_for_turn_completion` | `(session_id, timeout_seconds=300)` | Block until the agent finishes its current turn |
| `wait_for_idle` | `(session_id, timeout_seconds=300)` | Block until the session enters an idle state |

#### Query

| Method | Signature | Description |
|--------|-----------|-------------|
| `list_sessions` | `(active_only=False) → list[Session]` | List all (or only active) sessions |
| `get_session` | `(session_id) → Session` | Fetch a single session — raises `SessionNotFound` if it doesn't exist |
| `is_alive` | `(session_id) → bool` | Whether the session is currently active on the server |
| `get_messages` | `(session_id) → list[Message]` | Fetch all messages for a session |
| `list_machines` | `(active_only=False) → list[Machine]` | List all (or only active) machines |
| `get_machine` | `(machine_id) → Machine` | Fetch a single machine |

#### Convenience

| Method | Signature | Description |
|--------|-----------|-------------|
| `run_task` | `(machine_id, directory, prompt, agent="claude", timeout_seconds=600) → str` | Spawn, send, wait, stop — returns session ID |

#### Cleanup

| Method | Signature | Description |
|--------|-----------|-------------|
| `close` | `()` | Release any held resources (no-op in the current implementation) |

### Types

| Type | Fields |
|------|--------|
| `Session` | `id: str`, `active: bool`, `created_at: int`, `metadata: dict` (decrypted; the name is `metadata["name"]`), `agent_state: str \| None`, `metadata_version: int` |
| `Machine` | `id: str`, `active: bool`, `metadata: dict` |
| `Message` | `id: str`, `seq: int`, `content: dict`, `created_at: int` |
| `Agent` | `Literal["claude"]` |
| `PermissionMode` | `Literal["yolo"]` |

### Exceptions

All exceptions inherit from `HappyError`.

| Exception | Raised when |
|-----------|-------------|
| `AuthenticationError` | Credentials missing, expired, or malformed |
| `MachineOfflineError` | Target machine is not connected to the server |
| `SessionNotFound` | No session with the given id exists on the server |
| `SpawnError` | Session spawn failed |
| `TimeoutError` | Wait exceeded the specified timeout |
| `EncryptionError` | Encrypt or decrypt operation failed |
| `ConnectionError` | Socket connection failed or disconnected unexpectedly |
| `MetadataUpdateError` | The server rejected a session metadata update (or returned a malformed ack) |
| `MetadataConflictError` | A metadata update lost too many optimistic-concurrency races to complete |

## License

MIT — see [LICENSE](LICENSE).
