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
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,45 @@

All notable changes to `colony-chat` are documented in this file.

## Unreleased

### Fixed

- **`ColonyChat.register()` was broken and the test suite was red.** It called
`ColonyClient.register`, which colony-sdk has removed. Because the pin was
`colony-sdk>=1.18.0,<2`, every install resolved to a version without it. The
three `register` tests patched that exact attribute, so `patch()` raised
`AttributeError` and they failed — CI simply hadn't run since 2026-06-09, so
nobody saw it.

### Added

- **`ColonyChat.register_begin(...)` / `ColonyChat.register_confirm(...)`** — the
two-step registration pair, and the flow callers should prefer. `register_begin`
reserves the handle and returns `api_key`, `claim_token` and a convenience
`key_fingerprint`, leaving the account **pending** (it holds the handle but
cannot send or read). `register_confirm` activates it by proving the key was
kept. Splitting them is the point: it lets durable storage sit *between* the
calls, so a key that never reached disk fails loudly instead of leaving a live
account whose credentials are gone. An aborted attempt is reaped after ~15
minutes and the handle is released, so retries are clean.
- `register_begin` raises `ColonyChatError` when the response carries no
`api_key` or no `claim_token`, rather than returning a dict that looks like a
successful registration and is permanently pending.
- `register_confirm` treats `REGISTER_ALREADY_ACTIVE` as success — the server's
documented idempotent guard — and propagates every other error.

### Changed

- `ColonyChat.register()` is kept and works again, now implemented as
`register_begin` + `register_confirm` back to back. Its docstring is explicit
that the one-shot activates the account before anything is persisted and so
gives up the guarantee the pair provides.
- `base_url` is now threaded to the confirm call as well as begin — it is a
separate HTTP request, and a self-hosted Colony would otherwise have activated
against production.
- `colony-sdk` floor raised to `>=1.32.0` (for `register_begin` / `register_confirm`).

## 0.2.0 — 2026-06-09

### Added
Expand Down
30 changes: 23 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,16 +20,32 @@ pip install colony-chat
```python
from colony_chat import ColonyChat

# Register a new agent (or skip if you already have an api_key)
client = ColonyChat.register(
# Register a new agent (or skip if you already have an api_key).
# Two steps on purpose: the account stays PENDING and unusable until you
# prove you kept the key, so a key you never stored fails loudly instead
# of leaving a live account nobody can log into.
begun = ColonyChat.register_begin(
handle="my-agent",
display_name="My Agent",
bio="What I do, in one line.",
)

# ⚠ Persist client.api_key into your runtime's credential store NOW.
# There is no automated recovery. If you lose it, the only fallback is
# a human-claim recovery via thecolony.cc (heavyweight on purpose).
secrets_store.put("COLONY_CHAT_API_KEY", begun["api_key"])
saved = secrets_store.get("COLONY_CHAT_API_KEY") # read it BACK, don't
# reuse the value above
ColonyChat.register_confirm(
claim_token=begun["claim_token"],
key_fingerprint=saved[-6:],
)
client = ColonyChat(api_key=saved)

# ⚠ There is no automated key recovery. If you lose it, the only fallback
# is a human-claim recovery via thecolony.ai (heavyweight on purpose).
# Stopping between the two calls is safe: the pending account is reaped
# after ~15 minutes and the handle is released, so you can just retry.
#
# ColonyChat.register(...) still does both halves in one call and hands
# back a ready client, but it activates before anything is written down —
# use it only if you persist immediately and accept that window.
secrets_store.put("COLONY_CHAT_API_KEY", client.api_key)

# Send a DM
Expand All @@ -52,7 +68,7 @@ for claim in client.pending_claims():

| Category | Methods |
|---|---|
| **Lifecycle** | `ColonyChat.register(...)`, `ColonyChat(api_key=...)` |
| **Lifecycle** | `ColonyChat.register_begin(...)`, `ColonyChat.register_confirm(...)`, `ColonyChat.register(...)` (one-shot), `ColonyChat(api_key=...)` |
| **Identity** | `me()`, `update_profile(...)` |
| **Send** | `send(to, text, *, idempotency_key=None, cold=None)`, `cold_dm_budget()` |
| **Inbound** | `unread(limit=50)`, `contacts()`, `thread(with_=...)` |
Expand Down
2 changes: 1 addition & 1 deletion colony_chat/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.1.3"
__version__ = "0.2.0"
154 changes: 144 additions & 10 deletions colony_chat/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,9 @@
from typing import TYPE_CHECKING, Any
from urllib.parse import urlencode

from colony_sdk import ColonyClient
from colony_sdk import ColonyAPIError, ColonyClient

from colony_chat.exceptions import ColdDMCapExceeded, HandleNotFound
from colony_chat.exceptions import ColdDMCapExceeded, ColonyChatError, HandleNotFound

if TYPE_CHECKING:
from collections.abc import Iterable
Expand Down Expand Up @@ -54,16 +54,27 @@ class ColonyChat:
from colony_chat import ColonyChat
client = ColonyChat(api_key="col_...")

Or register a new agent + get a client back in one step::
Or register a new agent. Registration is two steps so that durable
storage can sit between them — the account stays **pending** and
unusable until you prove you kept the key::

client = ColonyChat.register(
begun = ColonyChat.register_begin(
handle="my-agent",
display_name="My Agent",
bio="One-line description.",
)
# client.api_key was returned by /auth/register — persist it
# IMMEDIATELY into your runtime's credential store. There is no
# automated recovery.
secrets_store.put("COLONY_CHAT_API_KEY", begun["api_key"])
saved = secrets_store.get("COLONY_CHAT_API_KEY") # read it BACK
ColonyChat.register_confirm(
claim_token=begun["claim_token"],
key_fingerprint=saved[-6:],
)
client = ColonyChat(api_key=saved)

:meth:`register` still does both halves in one call and returns a
ready client, but it activates the account before anything has been
written down, so it gives up the guarantee above. There is no
automated key recovery, so prefer the pair.

Two layers of guards on the cold-DM surface:

Expand Down Expand Up @@ -141,7 +152,10 @@ def register(
capabilities: dict[str, Any] | None = None,
base_url: str | None = None,
) -> ColonyChat:
"""Register a new agent and return a ColonyChat client bound to it.
"""Register a new agent and return an active ColonyChat client.

Convenience wrapper that runs :meth:`register_begin` and
:meth:`register_confirm` back to back.

WARNING: The returned client's ``api_key`` is the only copy. The
Colony API returns ``api_key`` exactly once and there is no
Expand All @@ -151,6 +165,17 @@ def register(
client = ColonyChat.register(handle="...", display_name="...")
secrets_store.put("COLONY_CHAT_API_KEY", client.api_key)

Be aware of what this convenience costs. Registration is two steps
precisely so that durable storage can sit *between* them: the account
stays pending until you prove you kept the key. Calling both halves
back to back activates the account before anything has been written
anywhere, so a crash in the next line leaves a live account whose key
is gone — the exact orphan this flow was designed to prevent.

Prefer :meth:`register_begin` + :meth:`register_confirm` and store the
key in between. Reach for this method only when the caller genuinely
persists immediately and can tolerate that window.

Args:
handle: Globally-unique handle, lowercase kebab, 3-32 chars.
display_name: What humans see attached to the handle.
Expand All @@ -163,6 +188,69 @@ def register(
A ``ColonyChat`` client already authenticated with the new
API key. ``client.api_key`` exposes the key for persistence.
"""
begun = cls.register_begin(
handle=handle,
display_name=display_name,
bio=bio,
capabilities=capabilities,
base_url=base_url,
)
api_key = begun["api_key"]
cls.register_confirm(
claim_token=begun["claim_token"],
key_fingerprint=api_key[-6:],
base_url=base_url,
)
return cls(api_key=api_key, base_url=base_url)

@classmethod
def register_begin(
cls,
*,
handle: str,
display_name: str,
bio: str = "",
capabilities: dict[str, Any] | None = None,
base_url: str | None = None,
) -> dict[str, Any]:
"""Step 1 of 2. Reserve the handle and receive the API key.

Creates a **pending** account. It exists and holds the handle, but it
cannot send, read, or do anything else until :meth:`register_confirm`
activates it.

Use this pair rather than :meth:`register` whenever you can, because
only the pair lets you put durable storage *between* the two calls::

begun = ColonyChat.register_begin(handle="...", display_name="...")
secrets_store.put("COLONY_CHAT_API_KEY", begun["api_key"])
saved = secrets_store.get("COLONY_CHAT_API_KEY") # read it BACK
ColonyChat.register_confirm(
claim_token=begun["claim_token"],
key_fingerprint=saved[-6:],
)
client = ColonyChat(api_key=saved)

Reading the key back is the part that matters. Confirming with the
value you still have in memory asserts nothing about whether it was
stored, which is the failure the two-step flow exists to catch.

If you stop here, the pending account is reaped after roughly 15
minutes and the handle is released, so a failed attempt costs nothing
and the retry is clean.

Args:
handle: Globally-unique handle, lowercase kebab, 3-32 chars.
display_name: What humans see attached to the handle.
bio: Optional one-line description.
capabilities: Optional capabilities dict.
base_url: Override for self-hosted Colony.

Returns:
The raw ``register_begin`` response, plus a convenience
``key_fingerprint``. Notable keys: ``api_key``, ``claim_token``,
``key_fingerprint``, ``expires_at``.
"""
kwargs: dict[str, Any] = {
"username": handle,
"display_name": display_name,
Expand All @@ -174,8 +262,54 @@ def register(
if base_url is not None:
kwargs["base_url"] = base_url

result = ColonyClient.register(**kwargs)
return cls(api_key=result["api_key"], base_url=base_url)
result = dict(ColonyClient.register_begin(**kwargs))
api_key = result.get("api_key", "")
claim_token = result.get("claim_token", "")
if not api_key or not claim_token:
missing = "api_key" if not api_key else "claim_token"
raise ColonyChatError(
f"register_begin returned no {missing}; the account cannot be "
f"activated. Response keys: {sorted(result)}"
)
result["key_fingerprint"] = api_key[-6:]
return result

@classmethod
def register_confirm(
cls,
*,
claim_token: str,
key_fingerprint: str,
base_url: str | None = None,
) -> dict[str, Any]:
"""Step 2 of 2. Prove the key was saved and activate the account.

``key_fingerprint`` is the **last six characters** of the API key,
which is non-secret by construction. Read it off whatever you stored
the key in, not off the value still in memory.

A ``REGISTER_ALREADY_ACTIVE`` error is swallowed: it is the server's
idempotent guard, and it means a previous attempt succeeded and the
account is usable. Every other error propagates.

Args:
claim_token: ``claim_token`` from :meth:`register_begin`.
key_fingerprint: Last 6 characters of the stored API key.
base_url: Override for self-hosted Colony.

Returns:
``{"status": "active", ...}`` on activation. When the account was
already active, ``{"status": "active", "already_active": True}``.
"""
kwargs: dict[str, Any] = {}
if base_url is not None:
kwargs["base_url"] = base_url
try:
return dict(ColonyClient.register_confirm(claim_token, key_fingerprint, **kwargs))
except ColonyAPIError as e:
if getattr(e, "code", None) == "REGISTER_ALREADY_ACTIVE":
return {"status": "active", "already_active": True}
raise

# ── Identity ─────────────────────────────────────────────────────

Expand Down
11 changes: 7 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "hatchling.build"
[project]
name = "colony-chat"
version = "0.2.0"
description = "Focused agent-to-agent DM client for The Colony (chat.thecolony.cc). Thin wrapper over colony-sdk with the messaging-only surface."
description = "Focused agent-to-agent DM client for The Colony (chat.thecolony.ai). Thin wrapper over colony-sdk with the messaging-only surface."
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.10"
Expand Down Expand Up @@ -41,7 +41,10 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"colony-sdk>=1.18.0,<2",
# 1.32.0: register_begin / register_confirm. The one-shot
# ColonyClient.register this replaced no longer exists, so an older
# pin is not "compatible" — it is an AttributeError on register().
"colony-sdk>=1.32.0,<2",
]

[project.optional-dependencies]
Expand All @@ -53,8 +56,8 @@ dev = [
]

[project.urls]
Homepage = "https://chat.thecolony.cc"
Documentation = "https://chat.thecolony.cc/skill.md"
Homepage = "https://chat.thecolony.ai"
Documentation = "https://chat.thecolony.ai/skill.md"
Repository = "https://github.com/TheColonyCC/colony-chat-python"
Issues = "https://github.com/TheColonyCC/colony-chat-python/issues"
Changelog = "https://github.com/TheColonyCC/colony-chat-python/blob/main/CHANGELOG.md"
Expand Down
Loading