ctfy.sdk.client

Platform HTTP client — the player-facing client for the ctfy platform.

This is the client agents and players reach for. It drives the platform's /api/v1/* REST API with typed Pydantic returns and auto-retry on transient errors. Operations split into two tiers:

The admin / operator surface is a separate ~ctfy.sdk.admin.AdminClient, reached via admin. A few server-status / realtime / version helpers stay top-level on the client: health(), get_meta(), cluster_info(), events(), check_server_compatibility().

  1"""Platform HTTP client — the player-facing client for the ctfy platform.
  2
  3This is the client agents and players reach for. It drives the platform's
  4``/api/v1/*`` REST API with typed Pydantic returns and auto-retry on transient
  5errors. Operations split into two tiers:
  6
  7* **Global / account** namespaces on the client itself —
  8  :attr:`~PlatformClient.auth`, :attr:`~PlatformClient.me`,
  9  :attr:`~PlatformClient.users`, :attr:`~PlatformClient.teams`,
 10  :attr:`~PlatformClient.challenges` (global catalog),
 11  :attr:`~PlatformClient.instances`, :attr:`~PlatformClient.submissions`,
 12  :attr:`~PlatformClient.achievements`, :attr:`~PlatformClient.activities`,
 13  :attr:`~PlatformClient.scoreboard`, :attr:`~PlatformClient.nodes`, and
 14  :attr:`~PlatformClient.competitions` (discovery).
 15* **A competition scope** — :meth:`competition` returns a
 16  :class:`~ctfy.sdk.competition.Competition` handle for the operations that are
 17  inherently competition-scoped (register, your team, the comp's challenges,
 18  team search, standings), so ``competition_id`` is named once. Instance
 19  lifecycle and answer submission stay flat (keyed by ``instance_id``)::
 20
 21      from ctfy.sdk import PlatformClient
 22
 23      client = PlatformClient("http://localhost:8100", token="pf_xxx")
 24      comp = client.competition(client.competitions.list(phase="running")[0].id)
 25      comp.register(mode="solo")
 26      ready = client.instances.start(comp.challenges()[0].id, competition_id=comp.id)
 27      result = client.submissions.submit(ready.id, "FLAG{...}")
 28      board = comp.scoreboard()
 29
 30The admin / operator surface is a separate
 31:class:`~ctfy.sdk.admin.AdminClient`, reached via :attr:`admin`. A few
 32server-status / realtime / version helpers stay top-level on the client:
 33:meth:`health`, :meth:`get_meta`, :meth:`cluster_info`, :meth:`events`,
 34:meth:`check_server_compatibility`.
 35"""
 36
 37from __future__ import annotations
 38
 39from functools import cached_property
 40
 41import httpx
 42
 43from ctfy.core.constants import DEFAULT_CLIENT_TIMEOUT
 44from ctfy.core.version import VersionCheck, check_compatibility, package_version
 45from ctfy.sdk._helpers import (
 46    InstanceReadyResult,
 47    _raise_for_status,
 48)
 49from ctfy.sdk._helpers import (
 50    _extract_items as _extract_items,
 51)
 52from ctfy.sdk._helpers import (
 53    _poll_instance_ready as _poll_instance_ready,
 54)
 55from ctfy.sdk.admin import AdminClient
 56from ctfy.sdk.base import BaseHttpClient
 57from ctfy.sdk.competition import Competition
 58from ctfy.sdk.events import EventStream
 59from ctfy.sdk.resources.achievements import AchievementsResource
 60from ctfy.sdk.resources.activities import ActivitiesResource
 61from ctfy.sdk.resources.auth import AuthResource
 62from ctfy.sdk.resources.challenges import ChallengesResource
 63from ctfy.sdk.resources.competitions import CompetitionsResource
 64from ctfy.sdk.resources.instances import InstancesResource
 65from ctfy.sdk.resources.me import MeResource
 66from ctfy.sdk.resources.nodes import NodesResource
 67from ctfy.sdk.resources.scoreboard import ScoreboardResource
 68from ctfy.sdk.resources.submissions import SubmissionsResource
 69from ctfy.sdk.resources.teams import TeamsResource
 70from ctfy.sdk.resources.users import UsersResource
 71from ctfy.server.models import ClusterInfo, HealthResponse, MetaResponse
 72
 73__all__ = ["InstanceReadyResult", "PlatformClient"]
 74
 75
 76class PlatformClient(BaseHttpClient):
 77    """Player-facing HTTP client for the ctfy platform.
 78
 79    Methods are grouped into resource namespaces (``client.teams.list()``, …)
 80    plus a per-competition scope via ``client.competition(id)``. The admin
 81    surface is a separate :class:`~ctfy.sdk.admin.AdminClient` reached via
 82    :attr:`admin`. Used by the CLI, the SDK, the MCP server, and external callers.
 83    """
 84
 85    def __init__(
 86        self,
 87        server_url: str,
 88        token: str = "",
 89        max_retries: int = 3,
 90        timeout: int = DEFAULT_CLIENT_TIMEOUT,
 91    ):
 92        self._url = server_url.rstrip("/")
 93        super().__init__(f"{self._url}/api/v1", token, timeout=timeout, max_retries=max_retries)
 94
 95    # -- resource namespaces ------------------------------------------------
 96
 97    @cached_property
 98    def teams(self) -> TeamsResource:
 99        """Public team discovery + profile reads."""
100        return TeamsResource(self)
101
102    @cached_property
103    def users(self) -> UsersResource:
104        """Public per-user profile reads."""
105        return UsersResource(self)
106
107    @cached_property
108    def me(self) -> MeResource:
109        """The calling user's profile, inbox, account, and progress."""
110        return MeResource(self)
111
112    @cached_property
113    def auth(self) -> AuthResource:
114        """Password auth, OAuth identities, API tokens, sign-in discovery."""
115        return AuthResource(self)
116
117    @cached_property
118    def achievements(self) -> AchievementsResource:
119        """Public badge catalog, recent-unlock feed, easter eggs."""
120        return AchievementsResource(self)
121
122    @cached_property
123    def challenges(self) -> ChallengesResource:
124        """Global challenge catalog, attachments, facets, feedback chips."""
125        return ChallengesResource(self)
126
127    @cached_property
128    def competitions(self) -> CompetitionsResource:
129        """Discover competitions (``list`` / ``get``). To act within one, use
130        :meth:`competition`."""
131        return CompetitionsResource(self)
132
133    def competition(self, competition_id: str) -> Competition:
134        """Scope to one competition. The inherently competition-scoped
135        operations — register, your team (captain tooling + invites +
136        join-requests), the comp's challenges, team search, and standings —
137        hang off the returned :class:`~ctfy.sdk.competition.Competition`
138        handle, so ``competition_id`` is named once instead of on every call.
139        (Instance lifecycle and answer submission stay on :attr:`instances` /
140        :attr:`submissions`, keyed by ``instance_id``.)"""
141        return Competition(self, competition_id)
142
143    @cached_property
144    def instances(self) -> InstancesResource:
145        """Launch / control / inspect challenge instances by instance id
146        (the competition is inferred server-side)."""
147        return InstancesResource(self)
148
149    @cached_property
150    def submissions(self) -> SubmissionsResource:
151        """Submit / verify answers + list submissions (by instance id), plus
152        the instance-less QA challenges."""
153        return SubmissionsResource(self)
154
155    @cached_property
156    def scoreboard(self) -> ScoreboardResource:
157        """Global standings, per-challenge stats, persisted snapshots."""
158        return ScoreboardResource(self)
159
160    @cached_property
161    def activities(self) -> ActivitiesResource:
162        """Platform activity log + histogram."""
163        return ActivitiesResource(self)
164
165    @cached_property
166    def nodes(self) -> NodesResource:
167        """Public worker-node list (operator node mgmt is on ``admin.nodes``)."""
168        return NodesResource(self)
169
170    @cached_property
171    def admin(self) -> AdminClient:
172        """Admin / operator surface (separate, role-gated server-side).
173
174        Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`.
175        """
176        return AdminClient(self)
177
178    # -- top-level convenience: server status / realtime / version ----------
179
180    def health(self) -> HealthResponse:
181        resp = self.request("GET", "/health")
182        _raise_for_status(resp)
183        return HealthResponse.model_validate(resp.json())
184
185    def get_meta(self) -> MetaResponse:
186        """Server identity + challenge repo SHA + build version.
187        Admin tokens additionally see cluster capacity + team / solve
188        counts in the same payload."""
189        resp = self.request("GET", "/meta")
190        _raise_for_status(resp)
191        return MetaResponse.model_validate(resp.json())
192
193    def cluster_info(self) -> ClusterInfo:
194        """Aggregate worker-node capacity / utilisation (the public
195        capacity banner; no per-node detail)."""
196        resp = self.request("GET", "/cluster-info")
197        _raise_for_status(resp)
198        return ClusterInfo.model_validate(resp.json())
199
200    def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck:
201        """Compare this client's ``ctfy`` version against the server's.
202
203        Uses the cheap unauthenticated ``/health`` probe (pass an
204        already-fetched :class:`HealthResponse` to avoid a second round
205        trip). Pure classification — never prints, never raises on a
206        mismatch, never mutates anything. The caller (CLI, MCP, harness)
207        decides what to do with :class:`~ctfy.core.version.VersionCheck`
208        (typically: print ``.message`` to stderr when not ``.quiet``).
209
210        A server too old to report its version yields
211        :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which
212        is ``.quiet`` — so this stays silent against legacy servers
213        rather than crying wolf.
214        """
215        h = health if health is not None else self.health()
216        return check_compatibility(package_version(), h.version)
217
218    def events(self, *, auto_reconnect: bool = True) -> EventStream:
219        """Open the platform SSE event stream.
220
221        Yields ``{"event": <name>, "data": <dict>}`` for each frame.
222        Filters: team-scoped events for every team the caller's user is
223        on (across every per-comp competition), plus all global events;
224        admin tokens additionally see admin-only frames.
225
226        Usage::
227
228            with client.events() as stream:
229                for event in stream:
230                    if event["event"] == "solve":
231                        ...
232
233        Auto-reconnects on transient network errors with exponential
234        backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make
235        the iterator raise instead.
236        """
237        token = self._token
238
239        def _factory() -> httpx.Client:
240            # New client per session so a reconnect after a stale
241            # connection drops fresh sockets, not warmed-over ones.
242            return httpx.Client(base_url=self._base_url, timeout=None)
243
244        return EventStream(
245            client_factory=_factory,
246            path="/events",
247            token=token,
248            auto_reconnect=auto_reconnect,
249        )
@dataclass
class InstanceReadyResult:
41@dataclass
42class InstanceReadyResult:
43    """Result of polling an instance until ready."""
44
45    id: str
46    surface: AttackSurface
47    cert_volume: str = ""

Result of polling an instance until ready.

InstanceReadyResult( id: str, surface: ctfy.core.target.AttackSurface, cert_volume: str = '')
id: str
surface: ctfy.core.target.AttackSurface
cert_volume: str = ''
class PlatformClient(ctfy.sdk.base.BaseHttpClient):
 77class PlatformClient(BaseHttpClient):
 78    """Player-facing HTTP client for the ctfy platform.
 79
 80    Methods are grouped into resource namespaces (``client.teams.list()``, …)
 81    plus a per-competition scope via ``client.competition(id)``. The admin
 82    surface is a separate :class:`~ctfy.sdk.admin.AdminClient` reached via
 83    :attr:`admin`. Used by the CLI, the SDK, the MCP server, and external callers.
 84    """
 85
 86    def __init__(
 87        self,
 88        server_url: str,
 89        token: str = "",
 90        max_retries: int = 3,
 91        timeout: int = DEFAULT_CLIENT_TIMEOUT,
 92    ):
 93        self._url = server_url.rstrip("/")
 94        super().__init__(f"{self._url}/api/v1", token, timeout=timeout, max_retries=max_retries)
 95
 96    # -- resource namespaces ------------------------------------------------
 97
 98    @cached_property
 99    def teams(self) -> TeamsResource:
100        """Public team discovery + profile reads."""
101        return TeamsResource(self)
102
103    @cached_property
104    def users(self) -> UsersResource:
105        """Public per-user profile reads."""
106        return UsersResource(self)
107
108    @cached_property
109    def me(self) -> MeResource:
110        """The calling user's profile, inbox, account, and progress."""
111        return MeResource(self)
112
113    @cached_property
114    def auth(self) -> AuthResource:
115        """Password auth, OAuth identities, API tokens, sign-in discovery."""
116        return AuthResource(self)
117
118    @cached_property
119    def achievements(self) -> AchievementsResource:
120        """Public badge catalog, recent-unlock feed, easter eggs."""
121        return AchievementsResource(self)
122
123    @cached_property
124    def challenges(self) -> ChallengesResource:
125        """Global challenge catalog, attachments, facets, feedback chips."""
126        return ChallengesResource(self)
127
128    @cached_property
129    def competitions(self) -> CompetitionsResource:
130        """Discover competitions (``list`` / ``get``). To act within one, use
131        :meth:`competition`."""
132        return CompetitionsResource(self)
133
134    def competition(self, competition_id: str) -> Competition:
135        """Scope to one competition. The inherently competition-scoped
136        operations — register, your team (captain tooling + invites +
137        join-requests), the comp's challenges, team search, and standings —
138        hang off the returned :class:`~ctfy.sdk.competition.Competition`
139        handle, so ``competition_id`` is named once instead of on every call.
140        (Instance lifecycle and answer submission stay on :attr:`instances` /
141        :attr:`submissions`, keyed by ``instance_id``.)"""
142        return Competition(self, competition_id)
143
144    @cached_property
145    def instances(self) -> InstancesResource:
146        """Launch / control / inspect challenge instances by instance id
147        (the competition is inferred server-side)."""
148        return InstancesResource(self)
149
150    @cached_property
151    def submissions(self) -> SubmissionsResource:
152        """Submit / verify answers + list submissions (by instance id), plus
153        the instance-less QA challenges."""
154        return SubmissionsResource(self)
155
156    @cached_property
157    def scoreboard(self) -> ScoreboardResource:
158        """Global standings, per-challenge stats, persisted snapshots."""
159        return ScoreboardResource(self)
160
161    @cached_property
162    def activities(self) -> ActivitiesResource:
163        """Platform activity log + histogram."""
164        return ActivitiesResource(self)
165
166    @cached_property
167    def nodes(self) -> NodesResource:
168        """Public worker-node list (operator node mgmt is on ``admin.nodes``)."""
169        return NodesResource(self)
170
171    @cached_property
172    def admin(self) -> AdminClient:
173        """Admin / operator surface (separate, role-gated server-side).
174
175        Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`.
176        """
177        return AdminClient(self)
178
179    # -- top-level convenience: server status / realtime / version ----------
180
181    def health(self) -> HealthResponse:
182        resp = self.request("GET", "/health")
183        _raise_for_status(resp)
184        return HealthResponse.model_validate(resp.json())
185
186    def get_meta(self) -> MetaResponse:
187        """Server identity + challenge repo SHA + build version.
188        Admin tokens additionally see cluster capacity + team / solve
189        counts in the same payload."""
190        resp = self.request("GET", "/meta")
191        _raise_for_status(resp)
192        return MetaResponse.model_validate(resp.json())
193
194    def cluster_info(self) -> ClusterInfo:
195        """Aggregate worker-node capacity / utilisation (the public
196        capacity banner; no per-node detail)."""
197        resp = self.request("GET", "/cluster-info")
198        _raise_for_status(resp)
199        return ClusterInfo.model_validate(resp.json())
200
201    def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck:
202        """Compare this client's ``ctfy`` version against the server's.
203
204        Uses the cheap unauthenticated ``/health`` probe (pass an
205        already-fetched :class:`HealthResponse` to avoid a second round
206        trip). Pure classification — never prints, never raises on a
207        mismatch, never mutates anything. The caller (CLI, MCP, harness)
208        decides what to do with :class:`~ctfy.core.version.VersionCheck`
209        (typically: print ``.message`` to stderr when not ``.quiet``).
210
211        A server too old to report its version yields
212        :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which
213        is ``.quiet`` — so this stays silent against legacy servers
214        rather than crying wolf.
215        """
216        h = health if health is not None else self.health()
217        return check_compatibility(package_version(), h.version)
218
219    def events(self, *, auto_reconnect: bool = True) -> EventStream:
220        """Open the platform SSE event stream.
221
222        Yields ``{"event": <name>, "data": <dict>}`` for each frame.
223        Filters: team-scoped events for every team the caller's user is
224        on (across every per-comp competition), plus all global events;
225        admin tokens additionally see admin-only frames.
226
227        Usage::
228
229            with client.events() as stream:
230                for event in stream:
231                    if event["event"] == "solve":
232                        ...
233
234        Auto-reconnects on transient network errors with exponential
235        backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make
236        the iterator raise instead.
237        """
238        token = self._token
239
240        def _factory() -> httpx.Client:
241            # New client per session so a reconnect after a stale
242            # connection drops fresh sockets, not warmed-over ones.
243            return httpx.Client(base_url=self._base_url, timeout=None)
244
245        return EventStream(
246            client_factory=_factory,
247            path="/events",
248            token=token,
249            auto_reconnect=auto_reconnect,
250        )

Player-facing HTTP client for the ctfy platform.

Methods are grouped into resource namespaces (client.teams.list(), …) plus a per-competition scope via client.competition(id). The admin surface is a separate ~ctfy.sdk.admin.AdminClient reached via admin. Used by the CLI, the SDK, the MCP server, and external callers.

PlatformClient( server_url: str, token: str = '', max_retries: int = 3, timeout: int = 600)
86    def __init__(
87        self,
88        server_url: str,
89        token: str = "",
90        max_retries: int = 3,
91        timeout: int = DEFAULT_CLIENT_TIMEOUT,
92    ):
93        self._url = server_url.rstrip("/")
94        super().__init__(f"{self._url}/api/v1", token, timeout=timeout, max_retries=max_retries)
 98    @cached_property
 99    def teams(self) -> TeamsResource:
100        """Public team discovery + profile reads."""
101        return TeamsResource(self)

Public team discovery + profile reads.

103    @cached_property
104    def users(self) -> UsersResource:
105        """Public per-user profile reads."""
106        return UsersResource(self)

Public per-user profile reads.

108    @cached_property
109    def me(self) -> MeResource:
110        """The calling user's profile, inbox, account, and progress."""
111        return MeResource(self)

The calling user's profile, inbox, account, and progress.

113    @cached_property
114    def auth(self) -> AuthResource:
115        """Password auth, OAuth identities, API tokens, sign-in discovery."""
116        return AuthResource(self)

Password auth, OAuth identities, API tokens, sign-in discovery.

118    @cached_property
119    def achievements(self) -> AchievementsResource:
120        """Public badge catalog, recent-unlock feed, easter eggs."""
121        return AchievementsResource(self)

Public badge catalog, recent-unlock feed, easter eggs.

123    @cached_property
124    def challenges(self) -> ChallengesResource:
125        """Global challenge catalog, attachments, facets, feedback chips."""
126        return ChallengesResource(self)

Global challenge catalog, attachments, facets, feedback chips.

128    @cached_property
129    def competitions(self) -> CompetitionsResource:
130        """Discover competitions (``list`` / ``get``). To act within one, use
131        :meth:`competition`."""
132        return CompetitionsResource(self)

Discover competitions (list / get). To act within one, use competition().

def competition(self, competition_id: str) -> ctfy.sdk.competition.Competition:
134    def competition(self, competition_id: str) -> Competition:
135        """Scope to one competition. The inherently competition-scoped
136        operations — register, your team (captain tooling + invites +
137        join-requests), the comp's challenges, team search, and standings —
138        hang off the returned :class:`~ctfy.sdk.competition.Competition`
139        handle, so ``competition_id`` is named once instead of on every call.
140        (Instance lifecycle and answer submission stay on :attr:`instances` /
141        :attr:`submissions`, keyed by ``instance_id``.)"""
142        return Competition(self, competition_id)

Scope to one competition. The inherently competition-scoped operations — register, your team (captain tooling + invites + join-requests), the comp's challenges, team search, and standings — hang off the returned ~ctfy.sdk.competition.Competition handle, so competition_id is named once instead of on every call. (Instance lifecycle and answer submission stay on instances / submissions, keyed by instance_id.)

144    @cached_property
145    def instances(self) -> InstancesResource:
146        """Launch / control / inspect challenge instances by instance id
147        (the competition is inferred server-side)."""
148        return InstancesResource(self)

Launch / control / inspect challenge instances by instance id (the competition is inferred server-side).

150    @cached_property
151    def submissions(self) -> SubmissionsResource:
152        """Submit / verify answers + list submissions (by instance id), plus
153        the instance-less QA challenges."""
154        return SubmissionsResource(self)

Submit / verify answers + list submissions (by instance id), plus the instance-less QA challenges.

156    @cached_property
157    def scoreboard(self) -> ScoreboardResource:
158        """Global standings, per-challenge stats, persisted snapshots."""
159        return ScoreboardResource(self)

Global standings, per-challenge stats, persisted snapshots.

161    @cached_property
162    def activities(self) -> ActivitiesResource:
163        """Platform activity log + histogram."""
164        return ActivitiesResource(self)

Platform activity log + histogram.

166    @cached_property
167    def nodes(self) -> NodesResource:
168        """Public worker-node list (operator node mgmt is on ``admin.nodes``)."""
169        return NodesResource(self)

Public worker-node list (operator node mgmt is on admin.nodes).

admin: ctfy.sdk.AdminClient
171    @cached_property
172    def admin(self) -> AdminClient:
173        """Admin / operator surface (separate, role-gated server-side).
174
175        Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`.
176        """
177        return AdminClient(self)

Admin / operator surface (separate, role-gated server-side).

Shares this client's transport. See ~ctfy.sdk.admin.AdminClient.

def health(self) -> ctfy.server.models.HealthResponse:
181    def health(self) -> HealthResponse:
182        resp = self.request("GET", "/health")
183        _raise_for_status(resp)
184        return HealthResponse.model_validate(resp.json())
def get_meta(self) -> ctfy.server.models.MetaResponse:
186    def get_meta(self) -> MetaResponse:
187        """Server identity + challenge repo SHA + build version.
188        Admin tokens additionally see cluster capacity + team / solve
189        counts in the same payload."""
190        resp = self.request("GET", "/meta")
191        _raise_for_status(resp)
192        return MetaResponse.model_validate(resp.json())

Server identity + challenge repo SHA + build version. Admin tokens additionally see cluster capacity + team / solve counts in the same payload.

def cluster_info(self) -> ctfy.server.models.ClusterInfo:
194    def cluster_info(self) -> ClusterInfo:
195        """Aggregate worker-node capacity / utilisation (the public
196        capacity banner; no per-node detail)."""
197        resp = self.request("GET", "/cluster-info")
198        _raise_for_status(resp)
199        return ClusterInfo.model_validate(resp.json())

Aggregate worker-node capacity / utilisation (the public capacity banner; no per-node detail).

def check_server_compatibility( self, *, health: ctfy.server.models.HealthResponse | None = None) -> ctfy.core.version.VersionCheck:
201    def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck:
202        """Compare this client's ``ctfy`` version against the server's.
203
204        Uses the cheap unauthenticated ``/health`` probe (pass an
205        already-fetched :class:`HealthResponse` to avoid a second round
206        trip). Pure classification — never prints, never raises on a
207        mismatch, never mutates anything. The caller (CLI, MCP, harness)
208        decides what to do with :class:`~ctfy.core.version.VersionCheck`
209        (typically: print ``.message`` to stderr when not ``.quiet``).
210
211        A server too old to report its version yields
212        :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which
213        is ``.quiet`` — so this stays silent against legacy servers
214        rather than crying wolf.
215        """
216        h = health if health is not None else self.health()
217        return check_compatibility(package_version(), h.version)

Compare this client's ctfy version against the server's.

Uses the cheap unauthenticated /health probe (pass an already-fetched HealthResponse to avoid a second round trip). Pure classification — never prints, never raises on a mismatch, never mutates anything. The caller (CLI, MCP, harness) decides what to do with ~ctfy.core.version.VersionCheck (typically: print .message to stderr when not .quiet).

A server too old to report its version yields Compatibility.UNKNOWN (health.version == ""), which is .quiet — so this stays silent against legacy servers rather than crying wolf.

def events(self, *, auto_reconnect: bool = True) -> ctfy.sdk.events.EventStream:
219    def events(self, *, auto_reconnect: bool = True) -> EventStream:
220        """Open the platform SSE event stream.
221
222        Yields ``{"event": <name>, "data": <dict>}`` for each frame.
223        Filters: team-scoped events for every team the caller's user is
224        on (across every per-comp competition), plus all global events;
225        admin tokens additionally see admin-only frames.
226
227        Usage::
228
229            with client.events() as stream:
230                for event in stream:
231                    if event["event"] == "solve":
232                        ...
233
234        Auto-reconnects on transient network errors with exponential
235        backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make
236        the iterator raise instead.
237        """
238        token = self._token
239
240        def _factory() -> httpx.Client:
241            # New client per session so a reconnect after a stale
242            # connection drops fresh sockets, not warmed-over ones.
243            return httpx.Client(base_url=self._base_url, timeout=None)
244
245        return EventStream(
246            client_factory=_factory,
247            path="/events",
248            token=token,
249            auto_reconnect=auto_reconnect,
250        )

Open the platform SSE event stream.

Yields {"event": <name>, "data": <dict>} for each frame. Filters: team-scoped events for every team the caller's user is on (across every per-comp competition), plus all global events; admin tokens additionally see admin-only frames.

Usage::

with client.events() as stream:
    for event in stream:
        if event["event"] == "solve":
            ...

Auto-reconnects on transient network errors with exponential backoff (1s → 30s capped). Set auto_reconnect=False to make the iterator raise instead.