Metadata-Version: 2.4
Name: pipbit
Version: 0.10.0
Summary: Python SDK for connecting text-based agents to the Pipbit platform.
Author-email: Pipbit <pipbit@pipworks.ai>
License-Expression: MIT
Project-URL: Homepage, https://pipworks.ai/developer
Project-URL: Documentation, https://pipworks.ai/developer/docs/sdk
Keywords: pipbit,ai,agent,sdk,flask,websocket
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Framework :: Flask
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: Flask<4.0,>=3.0
Requires-Dist: requests<3.0,>=2.31
Provides-Extra: connect
Requires-Dist: websockets<18,>=13; extra == "connect"
Provides-Extra: dev
Requires-Dist: build<2.0,>=1.2; extra == "dev"
Requires-Dist: twine<7.0,>=6.2; extra == "dev"
Requires-Dist: pytest<10.0,>=8.0; extra == "dev"
Requires-Dist: jsonschema<5.0,>=4.0; extra == "dev"
Dynamic: license-file

# Pipbit Python SDK

`pipbit` is the Python SDK for building text-based developer agents that Pip can hand conversations to.

Pipbit keeps the voice stack on the platform side:
- microphone and speaker handling
- speech-to-text and text-to-speech
- device transport
- request signing
- handoff session management

Your agent stays text-first and only needs to implement a webhook handler.

## Installation

```bash
pip install pipbit
```

An agent on the connect transport (see "Connect transport (beta)" below)
installs the `[connect]` extra, which adds the `websockets` package:

```bash
pip install 'pipbit[connect]'
```

For local package work:

```bash
pip install -e '.[dev]'
```

Upgrading an agent in place: use a versioned upgrade for released use
(`pip install -U 'pipbit==0.10.0'`, or `pip install -U 'pipbit[connect]==0.10.0'`
for a connect agent), or an editable install of the SDK checkout
when dogfooding from the monorepo (`pip install -e path/to/SDK/pipbit_sdk` —
so the venv tracks the source instead of freezing a stale copy). Either way,
**restart the serving process after upgrading**: a long-lived worker keeps the
old module in memory, and agent code written against a newer SDK will crash
against the stale import.

## Hello world

```python
from pipbit import Agent, CONNECT_INTRO_UTTERANCE

agent = Agent(
    name="george",
    secret="pb_live_sk_XXXXXXXXXXXXXXXX",
    base_url="https://api.pipbit.ai",
)

@agent.handle
def handle(req):
    if req.is_connect_intro():
        return "George here. How can I help with your home DIY today?"
    return f"You said: {req.text}"

agent.run(host="0.0.0.0", port=3000)
```

That is the webhook shape. An agent with no public HTTPS address (a laptop at
home) keeps the same handler and swaps `agent.run(...)` for
`transport="connect"` and `agent.serve()`; see "Connect transport (beta)"
below.

Pipbit sends signed JSON requests to:

```text
POST /pipbit/agent
```

`CONNECT_INTRO_UTTERANCE` is exported for agent authors who want the raw token,
but `req.is_connect_intro()` is the preferred helper.

`Agent(name=...)` must be your **lowercase canonical handle** from
registration ("George" registers as `george`): every webhook carries it in
`X-Pipbit-Agent`, and the SDK requires a byte-identical match — a case
mismatch fails authentication on every turn.

### Environment variables

`Agent.from_env()` builds an agent from the standard variables —
`PIPBIT_AGENT_NAME`, `PIPBIT_AGENT_SECRET`, and optionally `PIPBIT_BASE_URL`,
`PIPBIT_API_VERSION`, `PIPBIT_TRANSPORT` and `PIPBIT_CONNECT_URL` (see
"Connect transport" below). Keyword arguments override the environment and pass
through to `Agent()` (e.g. `oauth=`, `token_store=`):

```python
agent = Agent.from_env()
```

The SDK does not load a `.env` file itself — pair with `python-dotenv` if you
keep the variables in one.

## Webhook protocol

Each request carries three headers:

```text
X-Pipbit-Agent:      george            <- your canonical handle
X-Pipbit-Timestamp:  1753212345        <- unix seconds
X-Pipbit-Signature:  v1=<hex digest>
```

The signature is HMAC-SHA256, keyed by your agent secret, over the exact
bytes `"<timestamp>." + raw_request_body`, hex-encoded. The SDK verifies the
name, the timestamp (a **60-second replay window** — keep your clock synced),
and the signature before your handler runs, answering 401 otherwise.

To hand-build a signed request in tests, the SDK exports its signing helper
and header constants:

```python
import json, time
from pipbit import (
    build_signature,
    PIPBIT_AGENT_HEADER, PIPBIT_TIMESTAMP_HEADER, PIPBIT_SIGNATURE_HEADER,
)

body = json.dumps({
    "request_id": "req_1", "reply_token": "pb_rt_1",
    "utterance": "hello", "subject_id": "pb_sub_1", "conversation": [],
}).encode("utf-8")
ts = str(int(time.time()))
headers = {
    "Content-Type": "application/json",
    PIPBIT_AGENT_HEADER: "george",
    PIPBIT_TIMESTAMP_HEADER: ts,
    PIPBIT_SIGNATURE_HEADER: "v1=" + build_signature("pb_live_sk_test", ts, body),
}
```

## Connect transport (beta)

Webhooks need a public HTTPS address. **Connect** is the second transport for
the same contract: your agent dials out to the platform over one persistent
WebSocket (a two-way connection carried over HTTPS) and receives turns on it,
so a laptop behind a home router can host an agent with no public URL, no TLS
certificate and no open port. The handler is the same code: the turn it sees
is the webhook body byte for byte.

```bash
pip install 'pipbit[connect]'
```

```python
from pipbit import Agent

agent = Agent(
    name="george",
    secret="pb_live_sk_XXXXXXXXXXXXXXXX",
    base_url="https://api.pipbit.ai",
    transport="connect",
)

@agent.handle
def handle(req):
    if req.is_connect_intro():
        return "George here. How can I help with your home DIY today?"
    return f"You said: {req.text}"

agent.serve()
```

`serve()` opens the link and blocks until `stop()` is called from another
thread, or until the platform refuses the link for good (then it raises
`ConnectUnavailableError`, whose `.reason` says why). Programs with their own
main loop call `start()` and `stop()` instead. `connection_state()` returns
the link's `state` (`connecting`, `connected`, `reconnecting`, `stopped` or
`failed`), the platform's last `reason`, and the `connection_id` the platform
gave this socket.

With `Agent.from_env()`, set `PIPBIT_TRANSPORT=connect`. The socket URL is
derived from `PIPBIT_BASE_URL` (`https://api.pipbit.ai` becomes
`wss://api.pipbit.ai/v1/connect`); set `PIPBIT_CONNECT_URL` (or pass
`connect_url=`, a `ws://` or `wss://` URL) only when it differs.

**The platform must have connect switched on for your agent** (its
`connect_policy`). Until it is, the first hello is refused with
`connect_not_enabled` and `serve()` raises; `transport_mismatch` means the
agent is registered as a webhook agent.

What connect mode does not have:

- no HTTP server: `app()` and `run()` raise `ConfigurationError`;
- no inbound request signing to verify: the socket was authenticated once,
  at hello, by an HMAC proof (your secret never goes over the wire, and the
  session token the platform hands back is kept in memory only);
- no public URL and no `/health` route: read `health_report()` or
  `connection_state()` yourself (a required `connect_link` check replaces
  `platform_health`);
- no OAuth account linking in this release: the callback needs a public URL,
  so `oauth=` raises `ConfigurationError`.

**Pushes ride the socket.** In connect mode `push_reply` and `push_message`
travel as `reply` and `message` frames on the same link and are answered by
a `result` frame that carries the platform's verdict exactly as the HTTP
route would have given it, so they return the same body and raise the same
`SessionGoneError` / `InvalidRequestError` / `AuthenticationError` for the
same platform reason, and a handler written against the HTTP behaviour needs
no change. Three errors are new because the socket can be down when HTTPS
would not be, and the first is the only one that is safe to retry:

- `ConnectUnavailableError` with `.reason == "not_connected"`: the link is
  down right now (not started yet, or reconnecting), so the push was refused
  before anything went out. Nothing was sent and nothing is retried for you;
  the link comes back on its own, so retry after it does or give up. It is
  not a `SessionGoneError`: the conversational moment may still be open.
- `ConnectError` with `.reason == "link_lost"`: the frame went out and the
  link dropped before the platform's answer arrived. The platform applies a
  push even when it cannot deliver the answer, so the push may or may not
  have landed; do not blindly send it again.
- `ConnectError` with `.reason == "ack_timeout"`: the frame went out but no
  answer came within 10 s. The reply may have landed; do not blindly send it
  again. (A broker that does not serve the frame answers at once instead,
  as `ConnectError` with the platform's own code, `unsupported_frame` or
  `invalid_frame`; a `result` frame that carries no integer `status_code`
  fails the push as `invalid_frame` too, unless it reports success.)

There is no HTTPS fallback for a push in connect mode, on purpose: the secret
never rides the socket (the hello carries a signed proof of it, not the secret
itself), and a revoked agent loses every path at once when its socket is
closed. The LAN verbs and `start_device_link` stay
on HTTPS with the secret on both transports. The two push paths count
`connect_reply_frames_total` and `connect_message_frames_total` beside the
existing `push_reply_calls_total` / `push_message_calls_total`.

**Timing.** The platform waits 3 s for the SDK to acknowledge a turn (the SDK
does that on its own, before your handler runs) and then up to 6 s for the
answer. Answer inline within those 6 s, or `return req.defer()` and call
`push_reply` when the work is done, exactly as over webhooks; an inline
answer that arrives after the window is delivered as if it were a
`push_reply`.

**Reconnecting.** The SDK pings the platform every 30 s to keep the link (and
your router's memory of it) alive. If the link drops for any reason it dials
again after a short random wait that grows with each failure, up to a minute,
so a platform restart does not bring every agent back at the same instant.
If a second copy of your agent connects, the newer one wins and the older one
logs "another instance of this agent is connected" and waits at least 30 s
before trying again. Only a refusal by the platform (bad credentials, connect
not enabled, a suspended agent, an unsupported protocol version) makes it
stop; those surface as `ConnectUnavailableError`. When the platform revokes
the link it says why before closing it, and the close code, not the word,
tells the client what to do. A close with code 4002 is final: the client
stops and raises with `.reason == "revoked"` and the word as `.detail`.
That is how `suspended`, `unpublished`, `deleted` and `secret_rotated`
always arrive, and also how an operator's explicit revoke arrives (`POST
/v1/agents/<name>/connect/revoke`, default word `grant_revoked`): that
route switches the connect grant off as well, so a restart is refused at
hello with `connect_not_enabled` until an admin re-enables the agent. A
close with code 4003 is a hold-off: the client waits (its usual backoff, or
`retry_after_s` when a refusal carried one) and dials again, and
`connection_state().reason` reads `revoked` (with the word in
`connection_state().detail`) until that redial. That is how
`grant_revoked` arrives when an admin PATCH switched the grant off, and how
`policy` arrives after a transport switch; the redial then learns the
verdict at hello, `connect_not_enabled` (the client stops with that reason
and no `.detail`) or `transport_mismatch`. So `grant_revoked` can reach you
with either code: go by the exception, not the word. Only `secret_rotated`
is yours to fix (restart with the new secret); the rest are lifted on the
platform. A handler that was mid-turn when the link dropped
still sends its answer on the new link, where the platform matches it to the
turn; the SDK logs that and counts it under
`connect_results_after_reconnect_total`. If no new link is up yet, the answer
waits up to 15 s for one (never sending before the new link's hello is
acknowledged) and is then given up with a warning.

**Redelivery.** If the link drops in the moment between the platform sending
a turn and the SDK acknowledging it, the platform cannot know whether the
turn arrived, so it sends it again on the new link flagged as a redelivery
(the `hello.ack` says how many such turns follow). The SDK counts each under
`connect_redelivered_turns_total`; one this process already handled in the
last 15 minutes is acknowledged again and not run twice (that repeat counts
`connect_duplicate_turns_total`), and one it never saw runs once. The SDK's
hello offers this (`pipbit.connect.CLIENT_CAPABILITIES` lists `cancel` and
`redelivery`); a client that did not offer it has such a turn failed by the
platform at once instead.

**Cancellation.** The platform can withdraw a turn while your handler is
still working on it: the user returned to the manager, switched agent, ended
the session, went idle, or the device closed. `req.is_cancelled()` turns
`True` at that moment (a turn still waiting for a free handler thread sees
`True` from its first line), so a handler doing slow work polls it between
steps and stops early. Whatever the handler returns after a cancel is dropped
by the SDK, never sent: the platform cancelled the reply token in the same
breath, so the answer would have been refused anyway. The SDK tells the
platform what happened (`cancelled`, `already_replied` when the answer was
already on its way, or `unknown`) and counts each dropped answer under
`connect_cancelled_turns_total`. A handler that deferred (`return
req.defer()`) and never polled finds out at its `push_reply`: the platform
refuses it with `InvalidRequestError` whose `.reason` is
`reply_token_cancelled`. Treat that as "the user moved on", not as a failure,
and do not resend the text through `push_message`.

**Limits.** The `hello.ack` states the link's per-minute limits (60 turns
and 30 pushed messages per minute in v1). A `push_message` over the limit is
refused with `InvalidRequestError` whose `.code` is `rate_limited` (status
429): the message was not applied and the SDK does not retry it, because a
retry loop is exactly what the limit exists to stop. Wait for the next minute
or drop the message.

**What the link serves.** `served_capabilities()` (on `Agent` and
`ConnectClient`) lists the optional frame families the platform said it
serves on the current link, as its `hello.ack` named them: `cancel` and
`redelivery` today. It is empty before `start()`, while reconnecting, and in
webhook mode, which has no link to negotiate on.

**Stopping.** `stop(timeout=10.0)` closes the link and waits up to `timeout`
seconds in total for the handlers that are already running; turns that were
queued but had not started are dropped (their answers would have nowhere to
go). A handler that is still going when the time is up is left to finish on
its own thread and named in a warning, so a hung third-party call never
holds Ctrl-C.

## Request model

Each inbound request is exposed as a `Request` object:

```python
req.id                  # wire field: request_id
req.reply_token         # one-shot token for push_reply
req.session_id
req.text                # wire field: utterance
req.conversation
req.subject_id
req.agent_session_id
req.device_id
req.device_shadow
req.handoff_context
req.environment_event
req.manager_name        # e.g. "Pippa"; None on older platforms
req.multi_user          # True on turns from a device in Family Mode ("Multi-user" in the portal)
req.speaker_verified    # True / False when the bridge judged the voice; None = no verdict
req.speaker_basis       # the bridge's word for that verdict, e.g. "voiceprint"; None = no verdict
req.on_wifi             # True when the device shadow says Wi-Fi is the active radio
req.is_connect_intro()
req.is_environment_event()
req.is_cancelled()      # True once the platform withdrew the turn (connect transport only)
req.defer()
```

- `subject_id` is the stable Pipbit subject id for the current user/device binding.
- `session_id` is the request/reply conversation id.
- `agent_session_id` is the higher-level Pip-managed handoff session.
- `manager_name` is the user's chosen name for their manager assistant. Use it
  in send-offs instead of "the manager".
- `multi_user` is `True` when the device is in Family Mode ("Multi-user" in the
  developer portal): the speaker is an
  anonymous household member (possibly a child) acting under the owner's
  subject — not necessarily the person whose accounts are linked. Adapt
  accordingly: avoid personal names, and be conservative with
  account-specific or spending actions. If your agent should never be
  reachable on shared devices at all, uncheck "Enable Multi-user" in the
  developer portal instead — then you never receive family-mode turns.
  `False` on older platforms and on every non-family turn.
- `family_mode` is a read-only alias of `multi_user`, kept for one release
  because the field was renamed just before 0.10.0 shipped; the field on the
  wire is still called `family_mode`.
- `speaker_verified` is the bridge's speaker verdict for this turn, when it
  computed one. It is `True` only when this turn's own voice evidence
  admitted it, and `False` on every other admit (a bypass: the turn was let
  through for a reason other than the owner's verified voice). It is `None`
  when the platform sent no verdict: an older bridge, the bridge's verdict
  flag off, a turn by the vocative path (Pip's own audio path, "George, are
  you there?"), or an environment event. The connect intro carries the
  verdict of the turn that asked for the switch when the bridge's local gate
  scored that turn (the user was talking to another agent and asked for
  yours; the words ride as `handoff_context` when they said more than your
  agent's name), and carries none when Pip's own connect tool or a vocative
  turn drove the connect. `None` means "no verdict"; `False` means "a
  verdict, and it was not the owner's verified voice". Speaker verification
  is a user-experience filter so your agent can ignore the neighbour at the
  café or a voice on the TV; it is never a security control, so do not gate
  money or personal data on it. The SDK is type-strict (a non-boolean raises
  `InvalidRequestError`).
- `speaker_basis` is the bridge's word for why the turn was admitted. Two
  words come with `speaker_verified` `True`: `voiceprint` (the turn's voice
  matched the owner's stored voiceprint) and `post_wake_trust` (it matched
  closely enough inside the short trust window a verified or name-addressed
  turn opens). Eight come with `False`: `name_addressed` (the turn names Pip
  or the agent, while its own voice fell short or could not be scored),
  `recent_owner_voice` (a short turn admitted because the owner's voice was
  confirmed moments before), `repeat_grace` (the speaker repeated the same
  words at a near-identical score), `strike_grace` (below the threshold, but
  not yet enough misses to reject), `not_armed` (the voice gate is not armed
  on this bridge), `admin_override` (the admin voice-lock override lifted the
  gate for this session), `multi_user` (Family Mode, "Multi-user" in the portal, bypasses the gate) and
  `abstain` (no verdict could be computed: empty transcript, no voiceprint
  yet, scoring failed). The SDK checks the type only (a non-empty string)
  and never the vocabulary: a word you do not know, from a newer bridge, is
  legal and means unverified, so gate on `speaker_verified` alone. `None`
  whenever `speaker_verified` is. The same posture applies: a filter for
  the user's experience, never a security control.
- `conversation` carries the last few turns of the routed conversation (user
  turns, and on current platforms your agent's spoken replies too). It is
  short and resets when a new agent session starts — agents that reason over
  context should keep their own per-`subject_id` history of what they said.

## Reply modes

Immediate reply:

```python
@agent.handle
def handle(req):
    return "Hi. How can I help?"
```

Deferred reply:

```python
@agent.handle
def handle(req):
    queue_work(req)
    return req.defer()
```

Later:

```python
agent.push_reply(
    reply_token=req.reply_token,
    text="Your report is ready.",
)
```

Reply tokens are **one-shot** and expire about 15 minutes after the turn. A
second `push_reply` on the same token is rejected — which also makes a
network-error retry safe: if the first attempt landed, the retry fails with
"not pending" instead of speaking twice. A `202` means the platform owns
delivery from there (it retries transient device conditions itself and holds
the reply for a quiet moment); do not re-send after a `202`.

If the reply arrives after the conversational moment has passed, `push_reply`
raises `SessionGoneError` — see [Error handling](#error-handling).

Handing back (ending the agent's turn):

```python
agent.push_reply(
    reply_token=req.reply_token,
    text=send_off,       # your LLM's own goodbye, naming req.manager_name
    handback=True,
)
```

With `handback=True` this reply is the agent's send-off: after it is spoken,
the platform returns control to the manager assistant automatically. The
platform adds no farewell on your behalf — say the goodbye yourself (the
manager then acknowledges her return in her own voice). Let your LLM word it
for the moment (Garry's `handback_to_manager` tool is the worked pattern) and
name the manager via `req.manager_name`. The flag is omitted from the payload
when `False`, so older platforms are unaffected.

Handing back for a reason the manager can act on:

```python
agent.push_reply(
    reply_token=req.reply_token,
    text=one_sentence,   # what you can't do from here — not how to fix it
    handback=True,
    handback_reason="needs_wifi",
)
```

`handback_reason` is an optional short machine-readable word that tells the
manager *why* you handed back, so she can act on it the moment she is back
instead of re-asking the user. Today only `"needs_wifi"` has a meaning: your
request needs the user's device on its home Wi-Fi (it is on cellular — see
`req.on_wifi`). The manager then offers to move the device onto a saved Wi-Fi
network herself, in one question. So say, in one sentence, what you can't do
from cellular — and leave the Wi-Fi part to her: don't explain how to get
onto Wi-Fi, don't mention restarting, don't tell the user to ask anyone.
Two voices must never both explain Wi-Fi. The Sonos example agent is the
worked pattern. The reason requires `handback=True` (passing it alone raises
`InvalidRequestError`) and is omitted from the payload when not given; older
platforms ignore it.

Proactive message:

```python
agent.push_message(
    subject_id=req.subject_id,
    text="You have a new update.",
)
```

A pushed message is spoken right away when the user has a live session; when
the device is busy the platform retries for about a minute and can hold it
for the next quiet moment. If the user is not around it lands in the device
inbox rather than being spoken later — treat `push_message` as "say this now
if possible". There is no idempotency key, so do not blind-retry it.

## Environment events

Agents can subscribe to real-time environment events (audio scene, sensor
data, etc.) by including `event_subscriptions` during registration:

```json
{"event_subscriptions": ["audio_scene"]}
```

Events arrive as normal webhook calls with a sentinel utterance:

```python
@agent.handle
def handle(req):
    if req.is_environment_event():
        event = req.environment_event  # {"type": "audio_scene", "data": {...}, "timestamp": ...}
        if req.agent_session_id:
            return "I heard something."       # active session — reply directly
        else:
            agent.push_message(               # background — signal Pip
                text="Alert from my agent.",
                device_id=req.device_id,
            )
            return req.defer()   # no spoken reply for this event (an empty string is rejected)
    # ... normal handler
```

Additional request fields for environment events:

- `req.environment_event` — structured event dict (`type`, `data`, `timestamp`)
- `req.device_id` — the device identifier (always present for events)
- `req.is_environment_event()` — returns `True` for event requests

`ENVIRONMENT_EVENT_UTTERANCE` is exported for agents that want the raw
sentinel token.

## LAN relay

When the user is home, their device is on the same Wi-Fi network as their
speakers, TVs, and hubs. With an **admin-granted LAN policy**, an agent can
relay raw exchanges to those hosts through the device — the platform carries
the bytes, the device opens the local connection. Three verbs:

- `agent.lan_exchange(host=..., port=..., payload=b"...", subject_id=...)` →
  `LanResponse` (`data`, `duration_ms`) — raw TCP bytes out, response back.
- `agent.lan_discover(search_target="ssdp:all", subject_id=..., window_ms=None)` →
  `list[LanDevice]` — SSDP discovery; each responder carries `ip`, `port`,
  raw `data`, and parsed `.headers()` / `.header(name)`. An empty list means
  nothing answered — not an error.
- `window_ms` on `lan_discover` is how long the device collects answers (default
  2500; the platform clamps it to 1000–4000). For a name-based `mdns:` target the
  device ends the window 300 ms after the last answer, so a short window only
  bounds the nothing-answered case.
- `agent.lan_http(host=..., port=..., method=..., path=..., headers=...,
  body=..., subject_id=...)` → `LanHttpResponse` (`status`, `reason`,
  `headers`, `body`, case-insensitive `.header(name)`) — HTTP/1.1 over the
  relay, chunked responses handled.

Each takes exactly one of `subject_id` / `device_id`. The device must be on
Wi-Fi — gate on `req.on_wifi` — and a round trip takes seconds, so
`return req.defer()` first and do LAN work off the webhook thread:

```python
from pipbit import LanExchangeError, LanUnavailableError

@agent.handle
def handle(req):
    if not req.on_wifi:
        return cloud_path(req)      # device on cellular — no LAN today
    run_in_background(lan_work, req)
    return req.defer()

def lan_work(req):
    try:
        resp = agent.lan_http(
            subject_id=req.subject_id,
            host="192.168.1.50", port=1400,
            method="POST", path="/MediaRenderer/AVTransport/Control",
            headers={"SOAPACTION": '"urn:...#Play"',
                     "Content-Type": 'text/xml; charset="utf-8"'},
            body=soap_envelope,
        )
    except LanUnavailableError:     # roamed off Wi-Fi, no policy, older platform
        return cloud_fallback(req)
    except LanExchangeError:        # LAN path exists; this attempt failed
        return apologize(req)
    agent.push_reply(reply_token=req.reply_token, text=describe(resp))
```

`LanUnavailableError.reason` (`device_not_connected`, `device_on_cellular`,
`lan_not_enabled`, `port_not_allowed`, `not_supported`) always means "fall
back to the cloud path" — an older platform without the LAN endpoints shows
up as `not_supported`, so agents degrade cleanly. `LanExchangeError.reason`
(`device_busy`, `device_timeout`, `lan_host_unreachable`, `lan_timeout`,
`response_too_large`, `rate_limited`, `bridge_unreachable`,
`invalid_http_response`) means the attempt failed; exchanges are
side-effectful on the LAN peer, so do not blind-retry.

## OAuth account-linking

Connect a user's third-party account (Sonos, Spotify, Google, ...) to your
agent. **Your agent owns the tokens — the platform never sees them.** Because a
Pipbit device is voice-only, the user can't consent on the device; instead the
agent hands off to a second screen (the same way Alexa/Google "account linking"
works): you generate a link, deliver it to the user out of band, they consent in
a browser, and the SDK's built-in callback stores the token under their
`subject_id`.

Account linking is webhook-only in 0.10.0: the provider's redirect needs a
public callback URL, which a connect agent does not have, so
`Agent(oauth=..., transport="connect")` raises `ConfigurationError` at
construction rather than half-working.

```python
from pipbit import Agent, OAuth2Config, FileTokenStore

agent = Agent(
    name="sonos",
    secret="pb_live_sk_...",
    oauth=OAuth2Config(
        authorize_url="https://api.sonos.com/login/v3/oauth",
        token_url="https://api.sonos.com/login/v3/oauth/access",
        client_id="...",
        client_secret="...",
        redirect_uri="https://my-agent.example.com/pipbit/oauth/callback",
        scope="playback-control-all",
        token_endpoint_auth="basic",   # or "post"
    ),
    token_store=FileTokenStore("tokens.json"),   # MemoryTokenStore() for dev
)

@agent.handle
def handle(req):
    token = agent.get_oauth_token(req.subject_id)   # auto-refreshes; None if unlinked
    if token is None:
        link = agent.account_link_url(req.subject_id)
        deliver_to_user(link)        # your channel: SMS / email / companion app
        return "I've sent you a link to connect your account — open it, sign in, and tap allow."
    return do_something(token.access_token)
```

When `oauth=` is set, the SDK automatically serves the callback:

```text
GET /pipbit/oauth/callback
```

so your `redirect_uri` must point there (and HTTPS). The `state` parameter is
HMAC-signed with your agent secret and time-limited, so a third party can't
forge a callback to link the wrong identity.

Helpers:

- `agent.account_link_url(subject_id)` — the URL to deliver to the user.
- `agent.get_oauth_token(subject_id)` — a valid `OAuth2Token` (refreshed if
  needed), or `None` if not linked / no longer refreshable.
- `agent.is_linked(subject_id)` / `agent.unlink(subject_id)`.
- `on_account_link=` (an `Agent` kwarg) fires `fn(subject_id, token)` after a
  successful link — handy to `push_message` the user a confirmation.

### Voice-first onboarding (`start_device_link`)

`account_link_url()` gives you the raw provider URL, but a screenless device
can't hand the user a link. For the voice path, use `start_device_link()` — the
platform brokers a fixed verification URL (and a spoken code when the user has no
phone on file), so you just tell the user where to go:

```python
link = agent.start_device_link(req.subject_id, provider_label="Sonos")
if link.method == "code":
    return f"Go to {link.verification_uri} and enter {link.user_code} to connect your Sonos."
return f"Open {link.verification_uri} to finish connecting your Sonos."
```

The user finishes consent there; the token still lands at this agent's
`/pipbit/oauth/callback`, and `get_oauth_token()` returns it next turn.

> `start_device_link()` uses the platform's `POST /v1/account-links/start`
> broker, which is live — it is the recommended path for voice-first linking.
> `account_link_url()` remains the manual alternative when you deliver the
> provider URL yourself (a spoken URL is useless, so use a code/second-screen
> flow rather than an SMS link).

## Health checks

The SDK exposes:

- `GET /health`
- `GET /pipbit/health`

The response is structured and readiness-aware. The SDK includes:

- a required `agent_secret` check
- a non-required `platform_health` check against `GET {base_url}/health`

Add your own checks for agent-specific dependencies:

```python
import os

from pipbit import Agent

agent = Agent(name="george", secret="pb_live_sk_...", base_url="https://api.pipbit.ai")

agent.add_health_check(
    "openai_api_key",
    lambda: (bool(os.getenv("OPENAI_API_KEY")), "Set OPENAI_API_KEY."),
    required=True,
)
```

Example health response:

```json
{
  "status": "ok",
  "ready": true,
  "agent_name": "george",
  "api_version": "v1",
  "base_url": "https://api.pipbit.ai",
  "checks": [
    {"name": "agent_secret", "ok": true, "required": true, "message": "ok"},
    {"name": "platform_health", "ok": true, "required": false, "message": "ok"}
  ]
}
```

If any required check fails, `/health` returns `503`.

In connect mode there is no HTTP server and so no `/health` route: read
`agent.health_report()` (or `agent.is_ready()`) yourself, from your own
status page or a supervisor probe. The `platform_health` check is replaced
by a required `connect_link` check that passes only while the link's state
is `connected`; its `details` block is `agent.connection_state().to_dict()`,
so the report says why when the link is down (`"Connect link is
reconnecting: superseded."`, `"Connect link is failed: connect_not_enabled."`):

```json
{
  "status": "ok",
  "ready": true,
  "agent_name": "george",
  "api_version": "v1",
  "base_url": "https://api.pipbit.ai",
  "checks": [
    {"name": "agent_secret", "ok": true, "required": true, "message": "ok"},
    {
      "name": "connect_link", "ok": true, "required": true, "message": "ok",
      "details": {"state": "connected", "reason": null, "connection_id": "pbc_0123456789ab", "detail": null}
    }
  ]
}
```

## Metrics

The SDK exposes:

- `GET /metrics`
- `GET /pipbit/metrics`

Current counters include:

- `webhook_requests_total`
- `webhook_success_total`
- `webhook_immediate_replies_total`
- `webhook_deferred_replies_total`
- `webhook_auth_failures_total`
- `webhook_invalid_requests_total`
- `webhook_configuration_errors_total`
- `webhook_handler_errors_total`
- `push_reply_calls_total`
- `push_message_calls_total`
- `lan_exchange_calls_total`
- `lan_discover_calls_total`
- `device_link_requests_total`
- `api_request_errors_total`
- `oauth_callback_requests_total`
- `platform_health_checks_total`
- `platform_health_check_errors_total`

Connect mode adds its own `connect_*` counters; the
[Connect transport](#connect-transport-beta) section names each beside the
behaviour it counts. There is no `/metrics` route in connect mode; read
`agent.metrics_snapshot()` yourself. The full set:

- `connect_connections_total` — sockets opened: the first dial and every
  reconnect that reached the platform
- `connect_reconnects_total` — times the link went down after a dial and the
  client had to decide whether to dial again (it also counts the final
  close before a fatal stop)
- `connect_pong_timeouts_total` — keep-alive pings the platform did not
  answer in time; the client dropped the link and reconnected
- `connect_turns_total` — turns accepted for handling (after the duplicate
  check below)
- `connect_redelivered_turns_total` — turns that arrived flagged as a
  redelivery after a reconnect
- `connect_duplicate_turns_total` — turns acknowledged again but not run,
  because this process handled that request id in the last 15 minutes
- `connect_success_total` — turns whose handler produced a usable result
  (immediate or deferred)
- `connect_immediate_replies_total` / `connect_deferred_replies_total` —
  that result, split by kind
- `connect_invalid_requests_total` — turn payloads the parser refused
  (answered as `invalid_request`)
- `connect_configuration_errors_total` — handler results the SDK could not
  use, such as an empty string (answered as `configuration_error`)
- `connect_handler_errors_total` — handlers that raised (answered as
  `handler_error`)
- `connect_cancelled_turns_total` — handler results dropped because the
  platform cancelled the turn first
- `connect_results_after_reconnect_total` — turn results sent on a later
  socket than the one the turn arrived on
- `connect_reply_frames_total` / `connect_message_frames_total` — pushes
  sent as `reply` / `message` frames (`push_reply` / `push_message` in
  connect mode), beside the transport-neutral `push_*_calls_total`
- `connect_requests_total` — push frames sent and waited on (the two above
  combined)
- `connect_request_timeouts_total` — pushes whose `result` frame did not
  come back within the wait (raised as `ack_timeout`)
- `connect_unmatched_results_total` — `result` frames that arrived with
  nobody waiting (a push that had already timed out, or one sent on an
  earlier socket); logged and dropped

## Local development

Run your Flask app locally:

```bash
python app.py
```

For local testing, expose your server so Pipbit can reach `POST /pipbit/agent`
(an HTTPS tunnel such as cloudflared or ngrok works well).

Connect mode needs no tunnel, because the agent dials out. For a local loop,
run the fake broker that ships with the connect spec: it plays the
platform's side of every frame and validates each frame your agent sends
against the spec. It needs `websockets` and `jsonschema` (the `[connect]`
and `[dev]` extras):

```bash
cd SDK/connect_spec
FAKE_BROKER_AGENT_NAME=george FAKE_BROKER_AGENT_SECRET=pb_live_sk_test \
python -m conformance.fake_broker
```

Point the agent at it with the same name and secret, `PIPBIT_TRANSPORT=connect`
and `PIPBIT_CONNECT_URL=ws://127.0.0.1:5001/v1/connect`, then send it a turn
through the broker's HTTP twin on port 5002 (`GET /events` on the same port
lists every frame that went by, in order):

```bash
curl -sS -X POST http://127.0.0.1:5002/simulate/request \
  -H 'Content-Type: application/json' \
  -d '{"utterance":"Hello from the fake broker"}'
```

## Production serving

`agent.app()` returns a standard Flask WSGI app. The shipped examples serve
it with gunicorn:

```python
# my_agent.py
app = agent.app()
```

```bash
gunicorn my_agent:app --bind 0.0.0.0:3000 --workers 1 --threads 8
```

Use **exactly one worker**: in-process state (conversation history, pending
link confirmations, `MemoryTokenStore` tokens) is per-process, and extra
workers shard it — the agent gets amnesia between turns. One worker is enough
because handlers defer instantly and do real work on background threads;
`--threads 8` keeps a slow in-request path (like the OAuth callback's token
exchange) from blocking the webhook. To scale past one process, move per-user
state to shared storage first. Use `/health` for load-balancer readiness — it
returns 503 until required checks pass.

**Connect mode** has no WSGI app to hand to gunicorn: the agent is one
long-lived process that calls `agent.serve()` (or `start()` inside your own
main loop) and holds the link. Run exactly one copy: a second copy that
connects under the same agent name supersedes the first, which then waits
30 s and takes the link back, so two copies fight over the link instead of
sharing the load. Handlers run on the client's own pool of eight threads
(`ConnectClient(max_workers=...)` for people building on the client
directly), so a slow handler does not hold up the next turn. Let a
supervisor keep the process up; a systemd unit is enough:

```ini
[Unit]
Description=Pipbit agent (connect mode)
After=network-online.target

[Service]
User=agent
WorkingDirectory=/opt/my_agent
EnvironmentFile=/opt/my_agent/.env
ExecStart=/opt/my_agent/.venv/bin/python my_agent.py
Restart=always
RestartSec=30

[Install]
WantedBy=multi-user.target
```

`RestartSec=30` keeps an agent the platform keeps refusing under the
platform's limit of ten proven hellos a minute. There is no `/health` route
to point a load balancer at, and none is needed (nothing connects in):
readiness is `agent.health_report()["ready"]`, or more directly
`agent.connection_state().state == "connected"`. A refusal the platform will
not lift makes `serve()` raise `ConnectUnavailableError`; let the process
exit non-zero on it (the hello_world example exits with status 2) and let
the supervisor's restart policy decide how often to try again.

The spoken voice is configured on the Pipbit platform record for your agent; it
is not chosen inside the Python SDK. The preferred platform contract is:

- `voice_request`: developer-supplied abstract preference like presentation, tone, role
- `voice_binding`: platform-selected locked concrete voice
- `voice_id`: bridge-compatible compatibility field derived from the binding

Legacy explicit `voice_id` registration still works, but new agents should
prefer `voice_request`.

## Contract test

This repo includes SDK contract tests that exercise the basic developer flow
against the real platform service:

- register agent
- bind device/subject
- connect the agent
- send the handoff intro turn
- send one normal user turn
- end the agent session

`tests.test_contract` does that over webhooks. `tests.test_contract_connect`
does it over the connect transport, with the platform, the broker and the
SDK's client on loopback ports (it needs `aiohttp` and `websockets` in the
venv and skips without them). `tests.test_fake_broker` runs the SDK's client
against the spec's fake broker, the one "Local development" above uses; it
borrows the live fixture's raw socket client, so it needs `jsonschema` plus
the same `aiohttp` and `websockets` as `tests.test_contract_connect`, and
skips without them. Run them from the repo root:

```bash
PYTHONPATH=SDK/pipbit_sdk:HOST/pipbit_platform_service \
python3 -m unittest \
  SDK.pipbit_sdk.tests.test_contract \
  SDK.pipbit_sdk.tests.test_contract_connect \
  SDK.pipbit_sdk.tests.test_fake_broker -v
```

## Packaging for PyPI

Build the sdist and wheel:

```bash
cd SDK/pipbit_sdk
python3 -m build
twine check dist/*
```

The repo also includes a `RELEASE.md` checklist for version bumps and publish steps.

## Error handling

All SDK errors derive from `PipbitError`:

- `AuthenticationError` — a 401/403 response. Not always a bad secret: a
  suspended/unpublished agent or a reply token belonging to another agent
  403s too — the platform's detail rides the message and `.code`.
- `InvalidRequestError` — the platform rejected the request. With
  `.code == "rate_limited"` (status 429) the push went over a per-minute
  limit and was not applied; wait for the next minute rather than retrying
  at once.
- `ConnectUnavailableError`: the connect link was refused or lost for good,
  or is down at the moment of a push
  (`.reason` is one of `authentication_failed`, `connect_not_enabled`,
  `transport_mismatch`, `agent_suspended`, `protocol_unsupported`,
  `revoked`, `dependency_missing`, or `not_connected` from `push_reply` /
  `push_message` while the link is down); subclass of `ConnectError`. A
  supersede (another copy of the agent took the link) is not an error: the
  client holds off and reconnects, and `connection_state().reason` reads
  `superseded` meanwhile. For `revoked`, `.detail` names the platform's
  fine reason (`secret_rotated`, `suspended`, ...) when it sent one.
- `ConnectError` (the base class) on its own: a push frame went out on the
  connect link but its answer did not come back (`.reason` is `ack_timeout`
  or `link_lost`, both meaning the push may have landed so do not blindly
  re-send, or the platform's error-frame code `unsupported_frame` /
  `invalid_frame`). See [Connect transport](#connect-transport-beta).
- `SessionGoneError` — subclass of `InvalidRequestError`: the reply token is
  unknown or expired, so the conversational moment is over. Fall back to
  `push_message` if the text is still worth saying:

  ```python
  from pipbit import SessionGoneError

  try:
      agent.push_reply(reply_token=token, text=answer)
  except SessionGoneError:
      agent.push_message(subject_id=subject_id, text=answer)
  ```

- `ConfigurationError` — the SDK is configured incorrectly.
- `OAuthError` — raised by the account-linking flow (`OAuth2Client.exchange_code()` /
  `refresh()`). `Agent.get_oauth_token()` and the built-in `/pipbit/oauth/callback`
  route handle it internally, so you only catch it if you call `OAuth2Client` directly.
- `LanUnavailableError` / `LanExchangeError` (both `LanError`) — raised by the
  LAN relay verbs; switch on `.reason` (see [LAN relay](#lan-relay)).
  Unavailable = fall back to the cloud path; exchange failure = the LAN path
  exists but this attempt failed.

Errors raised from platform responses carry `.code` (the platform's
machine-readable error string) and `.status_code`. Network failures and 5xx
responses raise plain `RuntimeError`.

Inbound webhook failures return JSON error bodies with sensible HTTP status codes.
