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

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, unavailable_budget_s: float = 120.0)
106    def __init__(
107        self,
108        server_url: str,
109        token: str = "",
110        max_retries: int = 2,
111        timeout: int = DEFAULT_PLATFORM_TIMEOUT,
112        transport: httpx.BaseTransport | None = None,
113        unavailable_budget_s: float = UNAVAILABLE_BUDGET_S,
114    ):
115        """``unavailable_budget_s``: how long one call waits out a ``503`` +
116        ``Retry-After`` (#3312: a redeploy is a wait, not a failed tool
117        call). ``0`` never waits — the platform's own MCP endpoint calling
118        itself, whose tool thread holds one of the pool's slots (#3504)."""
119        self._url = server_url.rstrip("/")
120        super().__init__(
121            f"{self._url}/api/v1",
122            token,
123            timeout=timeout,
124            max_retries=max_retries,
125            transport=transport,
126            unavailable_budget_s=unavailable_budget_s,
127        )

unavailable_budget_s: how long one call waits out a 503 + Retry-After (#3312: a redeploy is a wait, not a failed tool call). 0 never waits — the platform's own MCP endpoint calling itself, whose tool thread holds one of the pool's slots (#3504).

server_url: str
129    @property
130    def server_url(self) -> str:
131        """The platform root, without the ``/api/v1`` suffix.
132
133        Exposed for callers that must build a non-``/api/v1`` URL of
134        their own — today the shell WebSocket, whose ticket carries a
135        platform-relative path the client has to absolutise against the
136        scheme and host *it* reached us on.
137        """
138        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.

142    @cached_property
143    def registration(self) -> RegistrationResource:
144        """The caller's own per-competition registration + team logo."""
145        return RegistrationResource(self)

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

147    @property
148    def authoring(self) -> AuthoringResource:
149        """Upload a challenge your team wrote, for a peer-authored event."""
150        return AuthoringResource(self)

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

152    @cached_property
153    def teams(self) -> TeamsResource:
154        """Public team discovery + profile reads."""
155        return TeamsResource(self)

Public team discovery + profile reads.

157    @cached_property
158    def users(self) -> UsersResource:
159        """Public per-user profile reads."""
160        return UsersResource(self)

Public per-user profile reads.

162    @cached_property
163    def me(self) -> MeResource:
164        """The calling user's profile, inbox, account, and progress."""
165        return MeResource(self)

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

167    @cached_property
168    def messages(self) -> MessagesResource:
169        """Site messages and the inbox stream (#2073)."""
170        return MessagesResource(self)

Site messages and the inbox stream (#2073).

172    @cached_property
173    def auth(self) -> AuthResource:
174        """Password auth, OAuth identities, API tokens, sign-in discovery."""
175        return AuthResource(self)

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

177    @cached_property
178    def achievements(self) -> AchievementsResource:
179        """Public badge catalog, recent-unlock feed, easter eggs."""
180        return AchievementsResource(self)

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

182    @cached_property
183    def challenges(self) -> ChallengesResource:
184        """Global challenge catalog, attachments, facets, feedback chips."""
185        return ChallengesResource(self)

Global challenge catalog, attachments, facets, feedback chips.

187    @cached_property
188    def site(self) -> SiteResource:
189        """The operator's site pages and their Contact form (#3414)."""
190        return SiteResource(self)

The operator's site pages and their Contact form (#3414).

192    @cached_property
193    def hints(self) -> HintsResource:
194        """A challenge's hint tiers in a competition: read, and open (#2101)."""
195        return HintsResource(self)

A challenge's hint tiers in a competition: read, and open (#2101).

197    @cached_property
198    def changelog(self) -> ChangelogResource:
199        """Public "What's New" feed, plus this account's seen marker."""
200        return ChangelogResource(self)

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

202    @cached_property
203    def competitions(self) -> CompetitionsResource:
204        """Discover competitions (``list`` / ``get``). To act within one, use
205        :meth:`competition`."""
206        return CompetitionsResource(self)

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

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

218    @cached_property
219    def series(self) -> SeriesResource:
220        """A recurring contest's ladder (public), not its schedule —
221        configuring a series is ``client.admin.series``."""
222        return SeriesResource(self)

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

224    @property
225    def suites(self) -> SuitesResource:
226        """The benchmark suites -- public challenge sets anybody practises or
227        evaluates against at any time (#2097). Playing one is
228        ``client.competition(id)``, like any competition."""
229        return SuitesResource(self)

The benchmark suites -- public challenge sets anybody practises or evaluates against at any time (#2097). Playing one is client.competition(id), like any competition.

231    @cached_property
232    def eval(self) -> EvalResource:
233        """Public model-evaluation leaderboard (ranking + dimensional breakdowns)."""
234        return EvalResource(self)

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

236    @property
237    def registry(self) -> RegistryResource:
238        """The container registry's token endpoint (docker follows it itself)."""
239        return RegistryResource(self)

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

241    @cached_property
242    def vendor(self) -> VendorResource:
243        """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer)."""
244        return VendorResource(self)

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

246    @cached_property
247    def instances(self) -> InstancesResource:
248        """Launch / control / inspect challenge instances by instance id
249        (the competition is inferred server-side)."""
250        return InstancesResource(self)

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

252    @cached_property
253    def submissions(self) -> SubmissionsResource:
254        """Submit / verify answers + list submissions (by instance id), plus
255        the instance-less QA challenges."""
256        return SubmissionsResource(self)

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

258    @cached_property
259    def patches(self) -> PatchesResource:
260        """AWD+ defence: submit a patch, then watch for its verdict."""
261        return PatchesResource(self)

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

263    @cached_property
264    def awd(self) -> AwdResource:
265        """Classic AWD: submit a round's captured flags in one batch."""
266        return AwdResource(self)

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

268    @cached_property
269    def reports(self) -> ReportsResource:
270        """The engagement report: save a version, read the current one."""
271        return ReportsResource(self)

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

273    @cached_property
274    def search(self) -> SearchResource:
275        """Global search: competitions, challenges and teams by name."""
276        return SearchResource(self)

Global search: competitions, challenges and teams by name.

278    @cached_property
279    def tasks(self) -> TasksResource:
280        """The background tasks you may see: when each waited, ran and ended."""
281        return TasksResource(self)

The background tasks you may see: when each waited, ran and ended.

283    @cached_property
284    def activities(self) -> ActivitiesResource:
285        """Platform activity log + histogram."""
286        return ActivitiesResource(self)

Platform activity log + histogram.

288    @cached_property
289    def nodes(self) -> NodesResource:
290        """Public worker-node list (operator node mgmt is on ``admin.nodes``)."""
291        return NodesResource(self)

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

admin: ctfy.sdk.AdminClient
293    @cached_property
294    def admin(self) -> AdminClient:
295        """Admin / operator surface (separate, role-gated server-side).
296
297        Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`.
298        """
299        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:
303    def health(self) -> HealthResponse:
304        resp = self.request("GET", "/health")
305        _raise_for_status(resp)
306        return HealthResponse.model_validate(resp.json())
def get_meta(self) -> ctfy.server.models.MetaResponse:
308    def get_meta(self) -> MetaResponse:
309        """Server identity + challenge repo SHA + build version.
310        Admin tokens additionally see cluster capacity + team / solve
311        counts in the same payload."""
312        resp = self.request("GET", "/meta")
313        _raise_for_status(resp)
314        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 get_landing(self) -> ctfy.server.models.LandingResponse:
316    def get_landing(self) -> LandingResponse:
317        """What the public home page shows: the featured event (``None``
318        when nothing featured may be shown) and the catalog by domain."""
319        resp = self.request("GET", "/landing")
320        _raise_for_status(resp)
321        return LandingResponse.model_validate(resp.json())

What the public home page shows: the featured event (None when nothing featured may be shown) and the catalog by domain.

def get_public_stats(self) -> ctfy.server.models.PublicStatsResponse:
323    def get_public_stats(self) -> PublicStatsResponse:
324        """The home page's live eval numbers and rounded platform scale
325        (#3413). Public and the same for every caller."""
326        resp = self.request("GET", "/stats/public")
327        _raise_for_status(resp)
328        return PublicStatsResponse.model_validate(resp.json())

The home page's live eval numbers and rounded platform scale (#3413). Public and the same for every caller.

def cluster_info(self) -> ctfy.server.models.ClusterInfo:
330    def cluster_info(self) -> ClusterInfo:
331        """Aggregate worker-node capacity / utilisation (the public
332        capacity banner; no per-node detail)."""
333        resp = self.request("GET", "/cluster-info")
334        _raise_for_status(resp)
335        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:
337    def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck:
338        """Compare this client's ``ctfy`` version against the server's.
339
340        Uses the cheap unauthenticated ``/health`` probe (pass an
341        already-fetched :class:`HealthResponse` to avoid a second round
342        trip). Pure classification — never prints, never raises on a
343        mismatch, never mutates anything. The caller (CLI, MCP, harness)
344        decides what to do with :class:`~ctfy.core.version.VersionCheck`
345        (typically: print ``.message`` to stderr when not ``.quiet``).
346
347        A server too old to report its version yields
348        :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which
349        is ``.quiet`` — so this stays silent against legacy servers
350        rather than crying wolf.
351        """
352        h = health if health is not None else self.health()
353        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:
355    def events(self, *, auto_reconnect: bool = True) -> EventStream:
356        """Open the platform SSE event stream.
357
358        Yields ``{"event": <name>, "data": <dict>}`` for each frame.
359        Filters: team-scoped events for every team the caller's user is
360        on (across every per-comp competition), plus all global events;
361        admin tokens additionally see admin-only frames.
362
363        Usage::
364
365            with client.events() as stream:
366                for event in stream:
367                    if event["event"] == "solve":
368                        ...
369
370        Auto-reconnects on transient network errors with exponential
371        backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make
372        the iterator raise instead.
373        """
374        token = self._token
375
376        def _factory() -> httpx.Client:
377            # New client per session so a reconnect after a stale
378            # connection drops fresh sockets, not warmed-over ones.
379            return httpx.Client(base_url=self._base_url, timeout=None)
380
381        return EventStream(
382            client_factory=_factory,
383            path="/events",
384            token=token,
385            auto_reconnect=auto_reconnect,
386        )

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.