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

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)
91    def __init__(
92        self,
93        server_url: str,
94        token: str = "",
95        max_retries: int = 3,
96        timeout: int = DEFAULT_CLIENT_TIMEOUT,
97    ):
98        self._url = server_url.rstrip("/")
99        super().__init__(f"{self._url}/api/v1", token, timeout=timeout, max_retries=max_retries)
server_url: str
101    @property
102    def server_url(self) -> str:
103        """The platform root, without the ``/api/v1`` suffix.
104
105        Exposed for callers that must build a non-``/api/v1`` URL of
106        their own — today the shell WebSocket, whose ticket carries a
107        platform-relative path the client has to absolutise against the
108        scheme and host *it* reached us on.
109        """
110        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.

114    @cached_property
115    def registration(self) -> RegistrationResource:
116        """The caller's own per-competition registration + team logo."""
117        return RegistrationResource(self)

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

119    @cached_property
120    def teams(self) -> TeamsResource:
121        """Public team discovery + profile reads."""
122        return TeamsResource(self)

Public team discovery + profile reads.

124    @cached_property
125    def users(self) -> UsersResource:
126        """Public per-user profile reads."""
127        return UsersResource(self)

Public per-user profile reads.

129    @cached_property
130    def me(self) -> MeResource:
131        """The calling user's profile, inbox, account, and progress."""
132        return MeResource(self)

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

134    @cached_property
135    def auth(self) -> AuthResource:
136        """Password auth, OAuth identities, API tokens, sign-in discovery."""
137        return AuthResource(self)

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

139    @cached_property
140    def achievements(self) -> AchievementsResource:
141        """Public badge catalog, recent-unlock feed, easter eggs."""
142        return AchievementsResource(self)

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

144    @cached_property
145    def challenges(self) -> ChallengesResource:
146        """Global challenge catalog, attachments, facets, feedback chips."""
147        return ChallengesResource(self)

Global challenge catalog, attachments, facets, feedback chips.

149    @cached_property
150    def competitions(self) -> CompetitionsResource:
151        """Discover competitions (``list`` / ``get``). To act within one, use
152        :meth:`competition`."""
153        return CompetitionsResource(self)

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

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

165    @cached_property
166    def series(self) -> SeriesResource:
167        """A recurring contest's ladder (public), not its schedule —
168        configuring a series is ``client.admin.series``."""
169        return SeriesResource(self)

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

171    @cached_property
172    def eval(self) -> EvalResource:
173        """Public model-evaluation leaderboard (ranking + dimensional breakdowns)."""
174        return EvalResource(self)

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

176    @cached_property
177    def vendor(self) -> VendorResource:
178        """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer)."""
179        return VendorResource(self)

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

181    @cached_property
182    def instances(self) -> InstancesResource:
183        """Launch / control / inspect challenge instances by instance id
184        (the competition is inferred server-side)."""
185        return InstancesResource(self)

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

187    @cached_property
188    def submissions(self) -> SubmissionsResource:
189        """Submit / verify answers + list submissions (by instance id), plus
190        the instance-less QA challenges."""
191        return SubmissionsResource(self)

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

193    @cached_property
194    def patches(self) -> PatchesResource:
195        """AWD+ defence: submit a patch, then watch for its verdict."""
196        return PatchesResource(self)

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

198    @cached_property
199    def awd(self) -> AwdResource:
200        """Classic AWD: submit a round's captured flags in one batch."""
201        return AwdResource(self)

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

203    @cached_property
204    def scoreboard(self) -> ScoreboardResource:
205        """Global standings, per-challenge stats, persisted snapshots."""
206        return ScoreboardResource(self)

Global standings, per-challenge stats, persisted snapshots.

208    @cached_property
209    def activities(self) -> ActivitiesResource:
210        """Platform activity log + histogram."""
211        return ActivitiesResource(self)

Platform activity log + histogram.

213    @cached_property
214    def nodes(self) -> NodesResource:
215        """Public worker-node list (operator node mgmt is on ``admin.nodes``)."""
216        return NodesResource(self)

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

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

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.