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_PLATFORM_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.authoring import AuthoringResource
 63from ctfy.sdk.resources.awd import AwdResource
 64from ctfy.sdk.resources.challenges import ChallengesResource
 65from ctfy.sdk.resources.changelog import ChangelogResource
 66from ctfy.sdk.resources.competitions import CompetitionsResource
 67from ctfy.sdk.resources.eval import EvalResource, RegistryResource
 68from ctfy.sdk.resources.instances import InstancesResource
 69from ctfy.sdk.resources.me import MeResource
 70from ctfy.sdk.resources.nodes import NodesResource
 71from ctfy.sdk.resources.registration import RegistrationResource
 72from ctfy.sdk.resources.reports import ReportsResource
 73from ctfy.sdk.resources.scoreboard import ScoreboardResource
 74from ctfy.sdk.resources.series import SeriesResource
 75from ctfy.sdk.resources.submissions import PatchesResource, SubmissionsResource
 76from ctfy.sdk.resources.teams import TeamsResource
 77from ctfy.sdk.resources.users import UsersResource
 78from ctfy.sdk.resources.vendor import VendorResource
 79from ctfy.server.models import ClusterInfo, HealthResponse, MetaResponse
 80
 81__all__ = ["InstanceReadyResult", "PlatformClient"]
 82
 83
 84class PlatformClient(BaseHttpClient):
 85    """Player-facing HTTP client for the ctfy platform.
 86
 87    Methods are grouped into resource namespaces (``client.teams.list()``, …)
 88    plus a per-competition scope via ``client.competition(id)``. The admin
 89    surface is a separate :class:`~ctfy.sdk.admin.AdminClient` reached via
 90    :attr:`admin`. Used by the CLI, the SDK, the MCP server, and external callers.
 91    """
 92
 93    def __init__(
 94        self,
 95        server_url: str,
 96        token: str = "",
 97        max_retries: int = 2,
 98        timeout: int = DEFAULT_PLATFORM_TIMEOUT,
 99        transport: httpx.BaseTransport | None = None,
100    ):
101        self._url = server_url.rstrip("/")
102        super().__init__(
103            f"{self._url}/api/v1",
104            token,
105            timeout=timeout,
106            max_retries=max_retries,
107            transport=transport,
108        )
109
110    @property
111    def server_url(self) -> str:
112        """The platform root, without the ``/api/v1`` suffix.
113
114        Exposed for callers that must build a non-``/api/v1`` URL of
115        their own — today the shell WebSocket, whose ticket carries a
116        platform-relative path the client has to absolutise against the
117        scheme and host *it* reached us on.
118        """
119        return self._url
120
121    # -- resource namespaces ------------------------------------------------
122
123    @cached_property
124    def registration(self) -> RegistrationResource:
125        """The caller's own per-competition registration + team logo."""
126        return RegistrationResource(self)
127
128    @property
129    def authoring(self) -> AuthoringResource:
130        """Upload a challenge your team wrote, for a peer-authored event."""
131        return AuthoringResource(self)
132
133    @cached_property
134    def teams(self) -> TeamsResource:
135        """Public team discovery + profile reads."""
136        return TeamsResource(self)
137
138    @cached_property
139    def users(self) -> UsersResource:
140        """Public per-user profile reads."""
141        return UsersResource(self)
142
143    @cached_property
144    def me(self) -> MeResource:
145        """The calling user's profile, inbox, account, and progress."""
146        return MeResource(self)
147
148    @cached_property
149    def auth(self) -> AuthResource:
150        """Password auth, OAuth identities, API tokens, sign-in discovery."""
151        return AuthResource(self)
152
153    @cached_property
154    def achievements(self) -> AchievementsResource:
155        """Public badge catalog, recent-unlock feed, easter eggs."""
156        return AchievementsResource(self)
157
158    @cached_property
159    def challenges(self) -> ChallengesResource:
160        """Global challenge catalog, attachments, facets, feedback chips."""
161        return ChallengesResource(self)
162
163    @cached_property
164    def changelog(self) -> ChangelogResource:
165        """Public "What's New" feed, plus this account's seen marker."""
166        return ChangelogResource(self)
167
168    @cached_property
169    def competitions(self) -> CompetitionsResource:
170        """Discover competitions (``list`` / ``get``). To act within one, use
171        :meth:`competition`."""
172        return CompetitionsResource(self)
173
174    def competition(self, competition_id: str) -> Competition:
175        """Scope to one competition. The inherently competition-scoped
176        operations — register, your team (captain tooling + invites +
177        join-requests), the comp's challenges, team search, and standings —
178        hang off the returned :class:`~ctfy.sdk.competition.Competition`
179        handle, so ``competition_id`` is named once instead of on every call.
180        (Instance lifecycle and answer submission stay on :attr:`instances` /
181        :attr:`submissions`, keyed by ``instance_id``.)"""
182        return Competition(self, competition_id)
183
184    @cached_property
185    def series(self) -> SeriesResource:
186        """A recurring contest's ladder (public), not its schedule —
187        configuring a series is ``client.admin.series``."""
188        return SeriesResource(self)
189
190    @cached_property
191    def eval(self) -> EvalResource:
192        """Public model-evaluation leaderboard (ranking + dimensional breakdowns)."""
193        return EvalResource(self)
194
195    @property
196    def registry(self) -> RegistryResource:
197        """The container registry's token endpoint (docker follows it itself)."""
198        return RegistryResource(self)
199
200    @cached_property
201    def vendor(self) -> VendorResource:
202        """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer)."""
203        return VendorResource(self)
204
205    @cached_property
206    def instances(self) -> InstancesResource:
207        """Launch / control / inspect challenge instances by instance id
208        (the competition is inferred server-side)."""
209        return InstancesResource(self)
210
211    @cached_property
212    def submissions(self) -> SubmissionsResource:
213        """Submit / verify answers + list submissions (by instance id), plus
214        the instance-less QA challenges."""
215        return SubmissionsResource(self)
216
217    @cached_property
218    def patches(self) -> PatchesResource:
219        """AWD+ defence: submit a patch, then watch for its verdict."""
220        return PatchesResource(self)
221
222    @cached_property
223    def awd(self) -> AwdResource:
224        """Classic AWD: submit a round's captured flags in one batch."""
225        return AwdResource(self)
226
227    @cached_property
228    def reports(self) -> ReportsResource:
229        """The engagement report: save a version, read the current one."""
230        return ReportsResource(self)
231
232    @cached_property
233    def scoreboard(self) -> ScoreboardResource:
234        """Global standings, per-challenge stats, persisted snapshots."""
235        return ScoreboardResource(self)
236
237    @cached_property
238    def activities(self) -> ActivitiesResource:
239        """Platform activity log + histogram."""
240        return ActivitiesResource(self)
241
242    @cached_property
243    def nodes(self) -> NodesResource:
244        """Public worker-node list (operator node mgmt is on ``admin.nodes``)."""
245        return NodesResource(self)
246
247    @cached_property
248    def admin(self) -> AdminClient:
249        """Admin / operator surface (separate, role-gated server-side).
250
251        Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`.
252        """
253        return AdminClient(self)
254
255    # -- top-level convenience: server status / realtime / version ----------
256
257    def health(self) -> HealthResponse:
258        resp = self.request("GET", "/health")
259        _raise_for_status(resp)
260        return HealthResponse.model_validate(resp.json())
261
262    def get_meta(self) -> MetaResponse:
263        """Server identity + challenge repo SHA + build version.
264        Admin tokens additionally see cluster capacity + team / solve
265        counts in the same payload."""
266        resp = self.request("GET", "/meta")
267        _raise_for_status(resp)
268        return MetaResponse.model_validate(resp.json())
269
270    def cluster_info(self) -> ClusterInfo:
271        """Aggregate worker-node capacity / utilisation (the public
272        capacity banner; no per-node detail)."""
273        resp = self.request("GET", "/cluster-info")
274        _raise_for_status(resp)
275        return ClusterInfo.model_validate(resp.json())
276
277    def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck:
278        """Compare this client's ``ctfy`` version against the server's.
279
280        Uses the cheap unauthenticated ``/health`` probe (pass an
281        already-fetched :class:`HealthResponse` to avoid a second round
282        trip). Pure classification — never prints, never raises on a
283        mismatch, never mutates anything. The caller (CLI, MCP, harness)
284        decides what to do with :class:`~ctfy.core.version.VersionCheck`
285        (typically: print ``.message`` to stderr when not ``.quiet``).
286
287        A server too old to report its version yields
288        :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which
289        is ``.quiet`` — so this stays silent against legacy servers
290        rather than crying wolf.
291        """
292        h = health if health is not None else self.health()
293        return check_compatibility(package_version(), h.version)
294
295    def events(self, *, auto_reconnect: bool = True) -> EventStream:
296        """Open the platform SSE event stream.
297
298        Yields ``{"event": <name>, "data": <dict>}`` for each frame.
299        Filters: team-scoped events for every team the caller's user is
300        on (across every per-comp competition), plus all global events;
301        admin tokens additionally see admin-only frames.
302
303        Usage::
304
305            with client.events() as stream:
306                for event in stream:
307                    if event["event"] == "solve":
308                        ...
309
310        Auto-reconnects on transient network errors with exponential
311        backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make
312        the iterator raise instead.
313        """
314        token = self._token
315
316        def _factory() -> httpx.Client:
317            # New client per session so a reconnect after a stale
318            # connection drops fresh sockets, not warmed-over ones.
319            return httpx.Client(base_url=self._base_url, timeout=None)
320
321        return EventStream(
322            client_factory=_factory,
323            path="/events",
324            token=token,
325            auto_reconnect=auto_reconnect,
326        )
@dataclass
class InstanceReadyResult:
88@dataclass
89class InstanceReadyResult:
90    """Result of polling an instance until ready."""
91
92    id: str
93    surface: AttackSurface
94    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):
 85class PlatformClient(BaseHttpClient):
 86    """Player-facing HTTP client for the ctfy platform.
 87
 88    Methods are grouped into resource namespaces (``client.teams.list()``, …)
 89    plus a per-competition scope via ``client.competition(id)``. The admin
 90    surface is a separate :class:`~ctfy.sdk.admin.AdminClient` reached via
 91    :attr:`admin`. Used by the CLI, the SDK, the MCP server, and external callers.
 92    """
 93
 94    def __init__(
 95        self,
 96        server_url: str,
 97        token: str = "",
 98        max_retries: int = 2,
 99        timeout: int = DEFAULT_PLATFORM_TIMEOUT,
100        transport: httpx.BaseTransport | None = None,
101    ):
102        self._url = server_url.rstrip("/")
103        super().__init__(
104            f"{self._url}/api/v1",
105            token,
106            timeout=timeout,
107            max_retries=max_retries,
108            transport=transport,
109        )
110
111    @property
112    def server_url(self) -> str:
113        """The platform root, without the ``/api/v1`` suffix.
114
115        Exposed for callers that must build a non-``/api/v1`` URL of
116        their own — today the shell WebSocket, whose ticket carries a
117        platform-relative path the client has to absolutise against the
118        scheme and host *it* reached us on.
119        """
120        return self._url
121
122    # -- resource namespaces ------------------------------------------------
123
124    @cached_property
125    def registration(self) -> RegistrationResource:
126        """The caller's own per-competition registration + team logo."""
127        return RegistrationResource(self)
128
129    @property
130    def authoring(self) -> AuthoringResource:
131        """Upload a challenge your team wrote, for a peer-authored event."""
132        return AuthoringResource(self)
133
134    @cached_property
135    def teams(self) -> TeamsResource:
136        """Public team discovery + profile reads."""
137        return TeamsResource(self)
138
139    @cached_property
140    def users(self) -> UsersResource:
141        """Public per-user profile reads."""
142        return UsersResource(self)
143
144    @cached_property
145    def me(self) -> MeResource:
146        """The calling user's profile, inbox, account, and progress."""
147        return MeResource(self)
148
149    @cached_property
150    def auth(self) -> AuthResource:
151        """Password auth, OAuth identities, API tokens, sign-in discovery."""
152        return AuthResource(self)
153
154    @cached_property
155    def achievements(self) -> AchievementsResource:
156        """Public badge catalog, recent-unlock feed, easter eggs."""
157        return AchievementsResource(self)
158
159    @cached_property
160    def challenges(self) -> ChallengesResource:
161        """Global challenge catalog, attachments, facets, feedback chips."""
162        return ChallengesResource(self)
163
164    @cached_property
165    def changelog(self) -> ChangelogResource:
166        """Public "What's New" feed, plus this account's seen marker."""
167        return ChangelogResource(self)
168
169    @cached_property
170    def competitions(self) -> CompetitionsResource:
171        """Discover competitions (``list`` / ``get``). To act within one, use
172        :meth:`competition`."""
173        return CompetitionsResource(self)
174
175    def competition(self, competition_id: str) -> Competition:
176        """Scope to one competition. The inherently competition-scoped
177        operations — register, your team (captain tooling + invites +
178        join-requests), the comp's challenges, team search, and standings —
179        hang off the returned :class:`~ctfy.sdk.competition.Competition`
180        handle, so ``competition_id`` is named once instead of on every call.
181        (Instance lifecycle and answer submission stay on :attr:`instances` /
182        :attr:`submissions`, keyed by ``instance_id``.)"""
183        return Competition(self, competition_id)
184
185    @cached_property
186    def series(self) -> SeriesResource:
187        """A recurring contest's ladder (public), not its schedule —
188        configuring a series is ``client.admin.series``."""
189        return SeriesResource(self)
190
191    @cached_property
192    def eval(self) -> EvalResource:
193        """Public model-evaluation leaderboard (ranking + dimensional breakdowns)."""
194        return EvalResource(self)
195
196    @property
197    def registry(self) -> RegistryResource:
198        """The container registry's token endpoint (docker follows it itself)."""
199        return RegistryResource(self)
200
201    @cached_property
202    def vendor(self) -> VendorResource:
203        """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer)."""
204        return VendorResource(self)
205
206    @cached_property
207    def instances(self) -> InstancesResource:
208        """Launch / control / inspect challenge instances by instance id
209        (the competition is inferred server-side)."""
210        return InstancesResource(self)
211
212    @cached_property
213    def submissions(self) -> SubmissionsResource:
214        """Submit / verify answers + list submissions (by instance id), plus
215        the instance-less QA challenges."""
216        return SubmissionsResource(self)
217
218    @cached_property
219    def patches(self) -> PatchesResource:
220        """AWD+ defence: submit a patch, then watch for its verdict."""
221        return PatchesResource(self)
222
223    @cached_property
224    def awd(self) -> AwdResource:
225        """Classic AWD: submit a round's captured flags in one batch."""
226        return AwdResource(self)
227
228    @cached_property
229    def reports(self) -> ReportsResource:
230        """The engagement report: save a version, read the current one."""
231        return ReportsResource(self)
232
233    @cached_property
234    def scoreboard(self) -> ScoreboardResource:
235        """Global standings, per-challenge stats, persisted snapshots."""
236        return ScoreboardResource(self)
237
238    @cached_property
239    def activities(self) -> ActivitiesResource:
240        """Platform activity log + histogram."""
241        return ActivitiesResource(self)
242
243    @cached_property
244    def nodes(self) -> NodesResource:
245        """Public worker-node list (operator node mgmt is on ``admin.nodes``)."""
246        return NodesResource(self)
247
248    @cached_property
249    def admin(self) -> AdminClient:
250        """Admin / operator surface (separate, role-gated server-side).
251
252        Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`.
253        """
254        return AdminClient(self)
255
256    # -- top-level convenience: server status / realtime / version ----------
257
258    def health(self) -> HealthResponse:
259        resp = self.request("GET", "/health")
260        _raise_for_status(resp)
261        return HealthResponse.model_validate(resp.json())
262
263    def get_meta(self) -> MetaResponse:
264        """Server identity + challenge repo SHA + build version.
265        Admin tokens additionally see cluster capacity + team / solve
266        counts in the same payload."""
267        resp = self.request("GET", "/meta")
268        _raise_for_status(resp)
269        return MetaResponse.model_validate(resp.json())
270
271    def cluster_info(self) -> ClusterInfo:
272        """Aggregate worker-node capacity / utilisation (the public
273        capacity banner; no per-node detail)."""
274        resp = self.request("GET", "/cluster-info")
275        _raise_for_status(resp)
276        return ClusterInfo.model_validate(resp.json())
277
278    def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck:
279        """Compare this client's ``ctfy`` version against the server's.
280
281        Uses the cheap unauthenticated ``/health`` probe (pass an
282        already-fetched :class:`HealthResponse` to avoid a second round
283        trip). Pure classification — never prints, never raises on a
284        mismatch, never mutates anything. The caller (CLI, MCP, harness)
285        decides what to do with :class:`~ctfy.core.version.VersionCheck`
286        (typically: print ``.message`` to stderr when not ``.quiet``).
287
288        A server too old to report its version yields
289        :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which
290        is ``.quiet`` — so this stays silent against legacy servers
291        rather than crying wolf.
292        """
293        h = health if health is not None else self.health()
294        return check_compatibility(package_version(), h.version)
295
296    def events(self, *, auto_reconnect: bool = True) -> EventStream:
297        """Open the platform SSE event stream.
298
299        Yields ``{"event": <name>, "data": <dict>}`` for each frame.
300        Filters: team-scoped events for every team the caller's user is
301        on (across every per-comp competition), plus all global events;
302        admin tokens additionally see admin-only frames.
303
304        Usage::
305
306            with client.events() as stream:
307                for event in stream:
308                    if event["event"] == "solve":
309                        ...
310
311        Auto-reconnects on transient network errors with exponential
312        backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make
313        the iterator raise instead.
314        """
315        token = self._token
316
317        def _factory() -> httpx.Client:
318            # New client per session so a reconnect after a stale
319            # connection drops fresh sockets, not warmed-over ones.
320            return httpx.Client(base_url=self._base_url, timeout=None)
321
322        return EventStream(
323            client_factory=_factory,
324            path="/events",
325            token=token,
326            auto_reconnect=auto_reconnect,
327        )

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 = 2, timeout: int = 60, transport: httpx.BaseTransport | None = None)
 94    def __init__(
 95        self,
 96        server_url: str,
 97        token: str = "",
 98        max_retries: int = 2,
 99        timeout: int = DEFAULT_PLATFORM_TIMEOUT,
100        transport: httpx.BaseTransport | None = None,
101    ):
102        self._url = server_url.rstrip("/")
103        super().__init__(
104            f"{self._url}/api/v1",
105            token,
106            timeout=timeout,
107            max_retries=max_retries,
108            transport=transport,
109        )

max_retries is how many times a request that could not connect, or timed out, is tried again — 0 is one attempt.

⚠️ It counted attempts: the loop ran range(max_retries), so 0 sent nothing and died on a bare AssertionError, the one value whose meaning the ecosystem agrees on (anthropic and openai's max_retries, httpx's retries). The default kept its behaviour rather than its number — three attempts, now spelled 2 — and a negative count is refused here, not at the first request.

transport swaps where requests are delivered, not what delivers them.

⚠️ It exists so nothing has to substitute the client itself. The retry loop below catches httpx.ConnectError / httpx.TimeoutException, so this class is coupled to httpx's exception hierarchy and not merely to "something that can send a request" — a different library's client would satisfy a structural reading of the seam and then raise past those handlers. Tests used to assemble instances with __new__ + a hand-set _client; a real parameter is the supported version of what they were reaching for, and it keeps self._client genuinely an httpx.Client.

None is httpx's own default, so an ordinary caller is unaffected.

server_url: str
111    @property
112    def server_url(self) -> str:
113        """The platform root, without the ``/api/v1`` suffix.
114
115        Exposed for callers that must build a non-``/api/v1`` URL of
116        their own — today the shell WebSocket, whose ticket carries a
117        platform-relative path the client has to absolutise against the
118        scheme and host *it* reached us on.
119        """
120        return self._url

The platform root, without the /api/v1 suffix.

Exposed for callers that must build a non-/api/v1 URL of their own — today the shell WebSocket, whose ticket carries a platform-relative path the client has to absolutise against the scheme and host it reached us on.

124    @cached_property
125    def registration(self) -> RegistrationResource:
126        """The caller's own per-competition registration + team logo."""
127        return RegistrationResource(self)

The caller's own per-competition registration + team logo.

129    @property
130    def authoring(self) -> AuthoringResource:
131        """Upload a challenge your team wrote, for a peer-authored event."""
132        return AuthoringResource(self)

Upload a challenge your team wrote, for a peer-authored event.

134    @cached_property
135    def teams(self) -> TeamsResource:
136        """Public team discovery + profile reads."""
137        return TeamsResource(self)

Public team discovery + profile reads.

139    @cached_property
140    def users(self) -> UsersResource:
141        """Public per-user profile reads."""
142        return UsersResource(self)

Public per-user profile reads.

144    @cached_property
145    def me(self) -> MeResource:
146        """The calling user's profile, inbox, account, and progress."""
147        return MeResource(self)

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

149    @cached_property
150    def auth(self) -> AuthResource:
151        """Password auth, OAuth identities, API tokens, sign-in discovery."""
152        return AuthResource(self)

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

154    @cached_property
155    def achievements(self) -> AchievementsResource:
156        """Public badge catalog, recent-unlock feed, easter eggs."""
157        return AchievementsResource(self)

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

159    @cached_property
160    def challenges(self) -> ChallengesResource:
161        """Global challenge catalog, attachments, facets, feedback chips."""
162        return ChallengesResource(self)

Global challenge catalog, attachments, facets, feedback chips.

164    @cached_property
165    def changelog(self) -> ChangelogResource:
166        """Public "What's New" feed, plus this account's seen marker."""
167        return ChangelogResource(self)

Public "What's New" feed, plus this account's seen marker.

169    @cached_property
170    def competitions(self) -> CompetitionsResource:
171        """Discover competitions (``list`` / ``get``). To act within one, use
172        :meth:`competition`."""
173        return CompetitionsResource(self)

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

def competition(self, competition_id: str) -> ctfy.sdk.competition.Competition:
175    def competition(self, competition_id: str) -> Competition:
176        """Scope to one competition. The inherently competition-scoped
177        operations — register, your team (captain tooling + invites +
178        join-requests), the comp's challenges, team search, and standings —
179        hang off the returned :class:`~ctfy.sdk.competition.Competition`
180        handle, so ``competition_id`` is named once instead of on every call.
181        (Instance lifecycle and answer submission stay on :attr:`instances` /
182        :attr:`submissions`, keyed by ``instance_id``.)"""
183        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.)

185    @cached_property
186    def series(self) -> SeriesResource:
187        """A recurring contest's ladder (public), not its schedule —
188        configuring a series is ``client.admin.series``."""
189        return SeriesResource(self)

A recurring contest's ladder (public), not its schedule — configuring a series is client.admin.series.

191    @cached_property
192    def eval(self) -> EvalResource:
193        """Public model-evaluation leaderboard (ranking + dimensional breakdowns)."""
194        return EvalResource(self)

Public model-evaluation leaderboard (ranking + dimensional breakdowns).

196    @property
197    def registry(self) -> RegistryResource:
198        """The container registry's token endpoint (docker follows it itself)."""
199        return RegistryResource(self)

The container registry's token endpoint (docker follows it itself).

201    @cached_property
202    def vendor(self) -> VendorResource:
203        """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer)."""
204        return VendorResource(self)

Vendor tenancy — the calling vendor's own models / runs (pv_ bearer).

206    @cached_property
207    def instances(self) -> InstancesResource:
208        """Launch / control / inspect challenge instances by instance id
209        (the competition is inferred server-side)."""
210        return InstancesResource(self)

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

212    @cached_property
213    def submissions(self) -> SubmissionsResource:
214        """Submit / verify answers + list submissions (by instance id), plus
215        the instance-less QA challenges."""
216        return SubmissionsResource(self)

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

218    @cached_property
219    def patches(self) -> PatchesResource:
220        """AWD+ defence: submit a patch, then watch for its verdict."""
221        return PatchesResource(self)

AWD+ defence: submit a patch, then watch for its verdict.

223    @cached_property
224    def awd(self) -> AwdResource:
225        """Classic AWD: submit a round's captured flags in one batch."""
226        return AwdResource(self)

Classic AWD: submit a round's captured flags in one batch.

228    @cached_property
229    def reports(self) -> ReportsResource:
230        """The engagement report: save a version, read the current one."""
231        return ReportsResource(self)

The engagement report: save a version, read the current one.

233    @cached_property
234    def scoreboard(self) -> ScoreboardResource:
235        """Global standings, per-challenge stats, persisted snapshots."""
236        return ScoreboardResource(self)

Global standings, per-challenge stats, persisted snapshots.

238    @cached_property
239    def activities(self) -> ActivitiesResource:
240        """Platform activity log + histogram."""
241        return ActivitiesResource(self)

Platform activity log + histogram.

243    @cached_property
244    def nodes(self) -> NodesResource:
245        """Public worker-node list (operator node mgmt is on ``admin.nodes``)."""
246        return NodesResource(self)

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

admin: ctfy.sdk.AdminClient
248    @cached_property
249    def admin(self) -> AdminClient:
250        """Admin / operator surface (separate, role-gated server-side).
251
252        Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`.
253        """
254        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:
258    def health(self) -> HealthResponse:
259        resp = self.request("GET", "/health")
260        _raise_for_status(resp)
261        return HealthResponse.model_validate(resp.json())
def get_meta(self) -> ctfy.server.models.MetaResponse:
263    def get_meta(self) -> MetaResponse:
264        """Server identity + challenge repo SHA + build version.
265        Admin tokens additionally see cluster capacity + team / solve
266        counts in the same payload."""
267        resp = self.request("GET", "/meta")
268        _raise_for_status(resp)
269        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:
271    def cluster_info(self) -> ClusterInfo:
272        """Aggregate worker-node capacity / utilisation (the public
273        capacity banner; no per-node detail)."""
274        resp = self.request("GET", "/cluster-info")
275        _raise_for_status(resp)
276        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:
278    def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck:
279        """Compare this client's ``ctfy`` version against the server's.
280
281        Uses the cheap unauthenticated ``/health`` probe (pass an
282        already-fetched :class:`HealthResponse` to avoid a second round
283        trip). Pure classification — never prints, never raises on a
284        mismatch, never mutates anything. The caller (CLI, MCP, harness)
285        decides what to do with :class:`~ctfy.core.version.VersionCheck`
286        (typically: print ``.message`` to stderr when not ``.quiet``).
287
288        A server too old to report its version yields
289        :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which
290        is ``.quiet`` — so this stays silent against legacy servers
291        rather than crying wolf.
292        """
293        h = health if health is not None else self.health()
294        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:
296    def events(self, *, auto_reconnect: bool = True) -> EventStream:
297        """Open the platform SSE event stream.
298
299        Yields ``{"event": <name>, "data": <dict>}`` for each frame.
300        Filters: team-scoped events for every team the caller's user is
301        on (across every per-comp competition), plus all global events;
302        admin tokens additionally see admin-only frames.
303
304        Usage::
305
306            with client.events() as stream:
307                for event in stream:
308                    if event["event"] == "solve":
309                        ...
310
311        Auto-reconnects on transient network errors with exponential
312        backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make
313        the iterator raise instead.
314        """
315        token = self._token
316
317        def _factory() -> httpx.Client:
318            # New client per session so a reconnect after a stale
319            # connection drops fresh sockets, not warmed-over ones.
320            return httpx.Client(base_url=self._base_url, timeout=None)
321
322        return EventStream(
323            client_factory=_factory,
324            path="/events",
325            token=token,
326            auto_reconnect=auto_reconnect,
327        )

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.