Metadata-Version: 2.5
Name: happy-engineering-sdk
Version: 0.6.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, *, permission_mode=None, environment_variables=None) → str` | Create a new agent session — returns the session ID. Pass `name=` to label it in the Happy apps (applied right after spawn). `permission_mode=` sets the agent CLI's spawn-time permission mode; `environment_variables=` (a `dict[str, str]`) adds env vars to the spawned process. Both are omitted from the spawn RPC when not passed |
| `stop_session` | `(session_id)` | Stop the session's underlying process on its host machine (via the machine-scoped stop RPC → daemon SIGTERM) |
| `delete_session` | `(session_id)` | Permanently delete a session |

`permission_mode` is the spawn-time mode the daemon passes to the agent CLI
(`--permission-mode`) — e.g. `"bypassPermissions"` for an unattended agent that
must create infrastructure, `"default"` to keep prompting. It is distinct from
`send_message`'s message-level `permission_mode`. An unrecognised value raises
`ValueError` before anything is spawned. `environment_variables` apply to that
one spawned session's process only.

#### 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", *, confirm=True, confirm_timeout=10.0, poll_interval=1.5, max_attempts=3)` | Send a message and **confirm it was delivered**. Returns once the server has persisted the message, or raises `MessageDeliveryError` if it can't be confirmed after retrying. Pass `confirm=False` for best-effort fire-and-forget |

**Confirmed delivery.** By default `send_message` doesn't just fire the message —
it verifies the server actually received and stored it, retrying transparently
if not. This matters most right after `spawn_session`: a naive send there is
easily lost, but the confirmed send (also used by `run_task` for its initial
prompt) reliably lands. "Delivered" means *persisted by the server* — the agent
reads persisted messages on its own; it does not mean the agent has replied yet.
Tune the bounds with the keyword-only params, or set `confirm=False` to opt out
(that path still flushes the socket so it won't silently drop).

#### Streaming a live session

| Method | Signature | Description |
|--------|-----------|-------------|
| `subscribe` | `(session_id, *, after_seq=None, buffer_size=1000) → SessionSubscription` | Follow a session's updates live, losslessly and decrypted. **Not a coroutine** — the returned object is both an async context manager and an async iterator |

This is the primary pattern for embedding a live session in another app (a web
console, a dashboard, a TUI). Everything the session emits — user messages,
assistant text, tool calls, turn ends, agent-state changes — arrives on one
ordered stream.

```python
# First paint: the tail of the history, cheaply.
history = await client.get_messages(sid, limit=50)
cursor = history[-1].seq if history else None

async with client.subscribe(sid, after_seq=cursor) as subscription:
    async for update in subscription:
        if update.kind is UpdateKind.NEW_MESSAGE:
            render(update.role, update.body)      # decrypted content
            cursor = update.seq                   # durable resume cursor
        elif update.kind is UpdateKind.UPDATE_SESSION:
            if update.body and update.body.get("requests"):
                show_waiting_for_permission(update.body["requests"])
        elif update.kind is UpdateKind.OVERFLOW:
            # We fell behind; re-backfill from the cursor and carry on.
            for message in await client.get_messages(sid, after_seq=cursor):
                render(message.content.get("role"), message.content)
                cursor = message.seq
        elif update.kind is UpdateKind.RECONNECTING:
            show_reconnecting()
        elif update.kind is UpdateKind.TERMINATED:
            break   # the iterator ends by itself here too
```

Exiting the `async with` block closes the underlying socket and awaits every
task the subscription started — nothing survives it, so opening and closing one
per UI tab does not leak. `subscribe` is **async-only**: `SyncHappyClient` has no
`subscribe`, because a stream has a lifetime and the sync facade runs one event
loop per call. Sync callers should drive `HappyClient.subscribe` on their own
loop.

##### Delivery contract

- **Ordering.** Items arrive in the order the socket delivered them; for
  `NEW_MESSAGE` that is ascending `seq`.
- **`seq`** is per-session, monotonic, and stable across process restarts, so it
  is safe as a durable cursor. It is dense in practice, but **only monotonicity
  is guaranteed** — never infer "a message is missing" from a gap.
- **`NEW_MESSAGE` is exactly-once for the lifetime of one subscription object.**
  Frames at or below the cursor — including anything a reconnect backfill
  replays — are suppressed. Across a *caller-driven* resubscribe it is
  **at-least-once**: pass the last seen `seq` as `after_seq` and dedupe on `seq`.
- **`UPDATE_SESSION` carries no `seq`, is not de-duplicated, and is
  at-least-once.** It is a state snapshot, so re-applying one is harmless.
- **Losslessness.** Updates are queued per subscription, never overwritten. A
  consumer that stalls for a second still receives everything that arrived in
  that second, in order.
- **Overflow is signalled, never silent.** If the consumer falls far enough
  behind to fill `buffer_size`, the newest updates are dropped and an `OVERFLOW`
  item reporting `dropped` is yielded at the point in the stream where the gap
  is. Recover with `get_messages(sid, after_seq=<last seen seq>)`; the stream
  keeps running.
- **Reconnect is automatic and visible.** On a socket drop the SDK yields
  `RECONNECTING`, retries with exponential backoff (0.5s → 30s, ±20% jitter),
  backfills everything missed via the cursor, then yields `RECONNECTED`. The
  union of backfill and stream is gap-free and duplicate-free by construction.
- **Termination.** When the session is deleted upstream, the subscription yields
  `TERMINATED` and the iterator stops — it never spins reconnecting at a session
  that no longer exists, and it never hangs. Deletion is silent on the wire: the
  Happy server emits no frame for it and leaves the socket connected, so the SDK
  detects it by re-checking the session record whenever the stream has been idle
  for a minute. Expect up to that much delay between the delete and `TERMINATED`.
  A busy session never pays for the check, since it only runs while nothing is
  arriving.
- **Undecryptable frames are yielded, not dropped**, with `body=None` and the
  envelope on `raw`, so one unreadable frame cannot end a console session.
- **Concurrency.** Each subscription owns its socket, so many subscriptions to
  different sessions — or two to the same session — are independent.

##### Message content shapes

`SessionUpdate.body` for a `NEW_MESSAGE` is Happy's decrypted message content:

- **User messages:** `{"role": "user", "content": {"type": "text", "text": ...}}`
- **Everything the agent emits:** `{"role": "session", "content": {"turn": "<turn id>", "ev": {...}}}`,
  where `ev.t` is the discriminator:

| `ev.t` | Meaning |
|--------|---------|
| `text` | Assistant text; reasoning carries `thinking: true` |
| `tool-call-start` | A tool invocation — `call`, `name`, `title`, `description`, `args` |
| `tool-call-end` | That invocation finished — correlate to its start by `ev.call` |
| `turn-end` | The agent finished its turn |

##### Permission requests

Permission requests are **not messages**. They live in agent state, so they
arrive as `UPDATE_SESSION` frames whose decrypted `body` has a non-empty
`requests` map. Only *changes* are streamed, so a session that was already
waiting when you subscribed shows up in `Session.agent_state_data` (from
`get_session`) rather than on the stream. The SDK deliberately provides no way
to *answer* a permission request — observation only.

#### 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, *, after_seq=None, limit=None) → list[Message]` | Fetch a session's messages in **ascending `seq`** order. `after_seq=N` returns only `seq > N` (exclusive) — a resume cursor; `limit=N` returns the **most recent** N (the tail), still ascending. Only the returned window is decrypted, but the fetch is still a full-history GET (the server ignores cursor/limit params) |
| `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`, `claude_session_id: str \| None`, `machine_id: str \| None`, `directory: str \| None`, `agent_state_data: dict \| None` |
| `Machine` | `id: str`, `active: bool`, `metadata: dict` |
| `Message` | `id: str`, `seq: int`, `content: dict`, `created_at: int` |
| `SessionUpdate` | `kind: UpdateKind`, `session_id: str`, `seq: int \| None`, `body: dict \| None` (decrypted), `role: str \| None`, `message_id: str \| None`, `local_id: str \| None`, `created_at: int \| None`, `dropped: int` (OVERFLOW only), `raw: dict \| None` |
| `UpdateKind` | `NEW_MESSAGE`, `UPDATE_SESSION`, `RECONNECTING`, `RECONNECTED`, `OVERFLOW`, `TERMINATED` |
| `Agent` | `Literal["claude", "codex", "gemini", "openclaw"]` |
| `PermissionMode` | `Literal["yolo", "default"]` (message-level, for `send_message`) |
| `SpawnPermissionMode` | `Literal["default", "acceptEdits", "bypassPermissions", "plan", "read-only", "safe-yolo", "yolo"]` (spawn-time, for `spawn_session`) |

### 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 |
| `StopSessionError` | Stopping a session failed (the machine-scoped stop RPC errored/was unreachable, or the session has no owning machine) |
| `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 |
| `MessageDeliveryError` | A message could not be confirmed delivered after the retry budget (see `send_message`) |
| `SubscriptionError` | A session subscription could not be established or could not recover |
| `SubscriptionClosed` | An operation was attempted on a subscription that is already closed |

## License

MIT — see [LICENSE](LICENSE).
