ctfy.sdk

Python SDK for the ctfy platform.

Drives the platform's /api/v1/* REST API from Python with typed Pydantic returns and auto-retry on transient errors. Two clients, split by audience — the same line the ctfy / ctfy-admin CLI binaries draw:

  • ~ctfy.sdk.client.PlatformClient — the player client. This is what agents and players want. Methods are grouped into resource namespaces (client.teams, client.instances, …) plus a per-competition handle via client.competition(id).
  • ~ctfy.sdk.admin.AdminClient — the operator client (/api/v1/admin/* and fleet management). Reached via client.admin or AdminClient.connect(). Kept separate so the player reference never surfaces admin endpoints they can't call.

Install

The full ctfy package pulls in the server too. For an agent / harness that only needs the client, use the client extra::

pip install "ctfy[client]"

Authenticate

Sign in to the platform in the browser, then go to Settings → API tokens and mint a fine-grained token (prefix pf_). These tokens launch instances and submit answers on the team's behalf but cannot mint more tokens, re-link OAuth providers, or change team membership — so a leaked token never costs you the account.

Export it as CTFY_TOKEN for the CLI / MCP server, or pass it directly to PlatformClient.

Quick start

End-to-end: discover a competition, register, list its challenges, launch one, read the attack surface, submit a captured flag::

from ctfy.sdk import PlatformClient

client = PlatformClient("https://ctfy.example.com", token="pf_xxx")

# 1) Find a running competition
comps = client.competitions.list(phase="running")
comp = comps[0]
print(comp.id, comp.title)

# 2) Scope to the competition, then register Solo (skip if already on a
#    team: inspect ``client.me.get().competition_teams`` first)
comp_api = client.competition(comp.id)
team = comp_api.register(mode="solo")
print(team.id)

# 3) List the challenges scoped to this competition (curated order)
challenges = comp_api.challenges()
for ch in challenges:
    print(f"  {ch.id} [{ch.category.value}/{ch.difficulty}] {ch.name}")

# 4) Launch the first challenge — blocks until ready
ready = client.instances.start(
    challenges[0].id, competition_id=comp.id
)
print(f"instance {ready.id}")

# 5) Read the attack surface (services + credentials, plus VPN for
#    engagement-mode challenges)
for svc in ready.surface.services:
    print(f"  [{svc.service_type.value}] {svc.url}")
    if svc.credentials:
        print(f"    {svc.credentials.username}/{svc.credentials.password}")
if ready.surface.vpn:
    print(f"  VPN: {ready.surface.vpn.host}:{ready.surface.vpn.port}")

# 6) Submit a captured flag (oracle-probe via
#    ``client.submissions.verify()`` first if you don't want to burn
#    an audit row)
result = client.submissions.submit(ready.id, "FLAG{your_flag_here}")
print(f"correct={result.correct} solved={result.challenge_fully_solved}")

# 7) Tear down (or let TTL expire — default 24h, admin-tunable)
client.instances.stop(ready.id)

A runnable, interactive copy of this script lives at examples/quickstart.py.

Player surface

Global / account namespaces on ~ctfy.sdk.client.PlatformClient:

  • client.teams / client.users — public team + user profile reads.
  • client.me — the caller's profile, inbox, account export/delete, achievements, solve + milestone progress.
  • client.auth — password register/login, OAuth identities, API token CRUD, sign-in discovery.
  • client.competitions — discover competitions (list / get).
  • client.challenges — global catalog, attachments, facets, feedback chips.
  • client.instancesstart (blocks until ready), status, stop, renew, list (by instance id; the competition is inferred).
  • client.submissionssubmit (graded), verify (oracle probe, no record), list, and QA challenges.
  • client.scoreboard — global standings, per-challenge stats, snapshots.
  • client.achievements / client.activities / client.nodes — public badge catalog, activity log, worker-node list.

Per-competition scope — client.competition(id) returns a ~ctfy.sdk.competition.Competition handle for the operations that are inherently competition-scoped, so competition_id is named once:

  • comp.register(mode="solo" | "create" | "join", …) — get on a team.
  • comp.team — your team: rename / leave / kick, plus comp.team.invites (codes + email invites) and comp.team.requests (approve / reject join requests).
  • comp.challenges() / comp.search_teams() and the standings (comp.scoreboard() / score_history / score_distribution / solve_matrix / challenge_breakdown).

Top-level convenience methods stay flat: client.health(), client.get_meta(), client.cluster_info(), client.events(), client.check_server_compatibility().

Admin surface

client.admin (an ~ctfy.sdk.admin.AdminClient) carries the operator endpoints, grouped the same way: admin.users, admin.competitions, admin.announcements, admin.achievements, admin.challenges, admin.instances, admin.records (instance forensics), admin.nodes, admin.observability, admin.settings, admin.competition_admins. Operator tooling without a player client can use AdminClient.connect(url, token=...).

Realtime ~~~~

PlatformClient.events() yields an auto-reconnecting ~ctfy.sdk.events.EventStream over the SSE endpoint — solve broadcasts, instance state changes, scoreboard refreshes.

See also

  • PlatformClient — the player client; every namespace method has full docstrings.
  • AdminClient — the operator client.
  • ctfy.server.models — the typed response shapes (ChallengeInfo, InstanceInfo, SubmissionResponse, …) that SDK methods return. Re-using the server's Pydantic models means the wire shape can never drift between the two sides.
  • <platform>/docs — auto-generated FastAPI Swagger UI for the raw REST API (language-agnostic).
  • <platform>/api/v1/mcp/ — streamable-HTTP MCP endpoint with the same operations as MCP tools, if the agent speaks MCP instead of Python.
  • ~ctfy.sdk.node_client.NodeClient — separate client for worker-node ↔ platform RPCs; not what agent harnesses want.
  1"""Python SDK for the ctfy platform.
  2
  3Drives the platform's ``/api/v1/*`` REST API from Python with typed Pydantic
  4returns and auto-retry on transient errors. Two clients, split by audience —
  5the same line the ``ctfy`` / ``ctfy-admin`` CLI binaries draw:
  6
  7- :class:`~ctfy.sdk.client.PlatformClient` — the **player** client. This is
  8  what agents and players want. Methods are grouped into resource namespaces
  9  (``client.teams``, ``client.instances``, …) plus a per-competition handle
 10  via ``client.competition(id)``.
 11- :class:`~ctfy.sdk.admin.AdminClient` — the **operator** client
 12  (``/api/v1/admin/*`` and fleet management). Reached via ``client.admin`` or
 13  :meth:`AdminClient.connect`. Kept separate so the player reference never
 14  surfaces admin endpoints they can't call.
 15
 16Install
 17-------
 18
 19The full ``ctfy`` package pulls in the server too. For an agent /
 20harness that only needs the client, use the ``client`` extra::
 21
 22    pip install "ctfy[client]"
 23
 24Authenticate
 25------------
 26
 27Sign in to the platform in the browser, then go to **Settings → API
 28tokens** and mint a fine-grained token (prefix ``pf_``). These tokens
 29launch instances and submit answers on the team's behalf but cannot
 30mint more tokens, re-link OAuth providers, or change team
 31membership — so a leaked token never costs you the account.
 32
 33Export it as ``CTFY_TOKEN`` for the CLI / MCP server, or pass it
 34directly to :class:`PlatformClient`.
 35
 36Quick start
 37-----------
 38
 39End-to-end: discover a competition, register, list its challenges,
 40launch one, read the attack surface, submit a captured flag::
 41
 42    from ctfy.sdk import PlatformClient
 43
 44    client = PlatformClient("https://ctfy.example.com", token="pf_xxx")
 45
 46    # 1) Find a running competition
 47    comps = client.competitions.list(phase="running")
 48    comp = comps[0]
 49    print(comp.id, comp.title)
 50
 51    # 2) Scope to the competition, then register Solo (skip if already on a
 52    #    team: inspect ``client.me.get().competition_teams`` first)
 53    comp_api = client.competition(comp.id)
 54    team = comp_api.register(mode="solo")
 55    print(team.id)
 56
 57    # 3) List the challenges scoped to this competition (curated order)
 58    challenges = comp_api.challenges()
 59    for ch in challenges:
 60        print(f"  {ch.id} [{ch.category.value}/{ch.difficulty}] {ch.name}")
 61
 62    # 4) Launch the first challenge — blocks until ready
 63    ready = client.instances.start(
 64        challenges[0].id, competition_id=comp.id
 65    )
 66    print(f"instance {ready.id}")
 67
 68    # 5) Read the attack surface (services + credentials, plus VPN for
 69    #    engagement-mode challenges)
 70    for svc in ready.surface.services:
 71        print(f"  [{svc.service_type.value}] {svc.url}")
 72        if svc.credentials:
 73            print(f"    {svc.credentials.username}/{svc.credentials.password}")
 74    if ready.surface.vpn:
 75        print(f"  VPN: {ready.surface.vpn.host}:{ready.surface.vpn.port}")
 76
 77    # 6) Submit a captured flag (oracle-probe via
 78    #    ``client.submissions.verify()`` first if you don't want to burn
 79    #    an audit row)
 80    result = client.submissions.submit(ready.id, "FLAG{your_flag_here}")
 81    print(f"correct={result.correct} solved={result.challenge_fully_solved}")
 82
 83    # 7) Tear down (or let TTL expire — default 24h, admin-tunable)
 84    client.instances.stop(ready.id)
 85
 86A runnable, interactive copy of this script lives at
 87``examples/quickstart.py``.
 88
 89Player surface
 90--------------
 91
 92Global / account namespaces on :class:`~ctfy.sdk.client.PlatformClient`:
 93
 94- ``client.teams`` / ``client.users`` — public team + user profile reads.
 95- ``client.me`` — the caller's profile, inbox, account export/delete,
 96  achievements, solve + milestone progress.
 97- ``client.auth`` — password register/login, OAuth identities, API token
 98  CRUD, sign-in discovery.
 99- ``client.competitions`` — discover competitions (``list`` / ``get``).
100- ``client.challenges`` — global catalog, attachments, facets, feedback chips.
101- ``client.instances`` — ``start`` (blocks until ready), ``status``, ``stop``,
102  ``renew``, ``list`` (by instance id; the competition is inferred).
103- ``client.submissions`` — ``submit`` (graded), ``verify`` (oracle probe,
104  no record), ``list``, and QA challenges.
105- ``client.scoreboard`` — global standings, per-challenge stats, snapshots.
106- ``client.achievements`` / ``client.activities`` / ``client.nodes`` — public
107  badge catalog, activity log, worker-node list.
108
109Per-competition scope — ``client.competition(id)`` returns a
110:class:`~ctfy.sdk.competition.Competition` handle for the operations that are
111*inherently* competition-scoped, so ``competition_id`` is named once:
112
113- ``comp.register(mode="solo" | "create" | "join", …)`` — get on a team.
114- ``comp.team`` — your team: ``rename`` / ``leave`` / ``kick``, plus
115  ``comp.team.invites`` (codes + email invites) and ``comp.team.requests``
116  (approve / reject join requests).
117- ``comp.challenges()`` / ``comp.search_teams()`` and the standings
118  (``comp.scoreboard()`` / ``score_history`` / ``score_distribution`` /
119  ``solve_matrix`` / ``challenge_breakdown``).
120
121Top-level convenience methods stay flat: ``client.health()``,
122``client.get_meta()``, ``client.cluster_info()``, ``client.events()``,
123``client.check_server_compatibility()``.
124
125Admin surface
126-------------
127
128``client.admin`` (an :class:`~ctfy.sdk.admin.AdminClient`) carries the
129operator endpoints, grouped the same way: ``admin.users``,
130``admin.competitions``, ``admin.announcements``, ``admin.achievements``,
131``admin.challenges``, ``admin.instances``, ``admin.records`` (instance
132forensics), ``admin.nodes``, ``admin.observability``, ``admin.settings``,
133``admin.competition_admins``. Operator tooling without a player client can use
134``AdminClient.connect(url, token=...)``.
135
136Realtime
137~~~~~~~~
138
139:meth:`PlatformClient.events` yields an auto-reconnecting
140:class:`~ctfy.sdk.events.EventStream` over the SSE endpoint —
141solve broadcasts, instance state changes, scoreboard refreshes.
142
143See also
144--------
145
146- :class:`PlatformClient` — the player client; every namespace method has
147  full docstrings.
148- :class:`AdminClient` — the operator client.
149- ``ctfy.server.models`` — the typed response shapes
150  (``ChallengeInfo``, ``InstanceInfo``, ``SubmissionResponse``, …)
151  that SDK methods return. Re-using the server's Pydantic models means
152  the wire shape can never drift between the two sides.
153- ``<platform>/docs`` — auto-generated FastAPI Swagger UI for the
154  raw REST API (language-agnostic).
155- ``<platform>/api/v1/mcp/`` — streamable-HTTP MCP endpoint with
156  the same operations as MCP tools, if the agent speaks MCP
157  instead of Python.
158- :class:`~ctfy.sdk.node_client.NodeClient` — separate client for
159  worker-node ↔ platform RPCs; not what agent harnesses want.
160"""
161
162from ctfy.sdk.admin import AdminClient
163from ctfy.sdk.client import PlatformClient
164from ctfy.sdk.node_client import NodeClient
165
166__all__ = ["AdminClient", "NodeClient", "PlatformClient"]
class AdminClient:
 73class AdminClient:
 74    """Admin / operator surface, grouped into resource namespaces.
 75
 76    Namespaces: :attr:`users`, :attr:`competitions`, :attr:`announcements`,
 77    :attr:`achievements`, :attr:`challenges`, :attr:`instances`,
 78    :attr:`records`, :attr:`nodes`, :attr:`observability`, :attr:`settings`,
 79    :attr:`competition_admins`.
 80    """
 81
 82    def __init__(self, http: BaseHttpClient) -> None:
 83        #: Shared transport (the parent PlatformClient when reached via
 84        #: ``client.admin``, or an owned client when built via ``connect``).
 85        self._http = http
 86
 87    @classmethod
 88    def connect(
 89        cls,
 90        server_url: str,
 91        token: str = "",
 92        *,
 93        max_retries: int = 3,
 94        timeout: int = DEFAULT_CLIENT_TIMEOUT,
 95    ) -> AdminClient:
 96        """Build an ``AdminClient`` that owns its own transport.
 97
 98        For operator tooling that doesn't already hold a
 99        :class:`PlatformClient`. The returned client is a context manager
100        that closes its transport on exit.
101        """
102        base = server_url.rstrip("/")
103        http = BaseHttpClient(f"{base}/api/v1", token, timeout=timeout, max_retries=max_retries)
104        return cls(http)
105
106    @cached_property
107    def users(self) -> AdminUsersResource:
108        return AdminUsersResource(self._http)
109
110    @cached_property
111    def competitions(self) -> AdminCompetitionsResource:
112        return AdminCompetitionsResource(self._http)
113
114    @cached_property
115    def announcements(self) -> AdminAnnouncementsResource:
116        return AdminAnnouncementsResource(self._http)
117
118    @cached_property
119    def achievements(self) -> AdminAchievementsResource:
120        return AdminAchievementsResource(self._http)
121
122    @cached_property
123    def challenges(self) -> AdminChallengesResource:
124        return AdminChallengesResource(self._http)
125
126    @cached_property
127    def email(self) -> AdminEmailResource:
128        return AdminEmailResource(self._http)
129
130    @cached_property
131    def patches(self) -> AdminPatchesResource:
132        return AdminPatchesResource(self._http)
133
134    @cached_property
135    def awd(self) -> AdminAwdResource:
136        return AdminAwdResource(self._http)
137
138    @cached_property
139    def series(self) -> AdminSeriesResource:
140        return AdminSeriesResource(self._http)
141
142    @cached_property
143    def instances(self) -> AdminInstancesResource:
144        return AdminInstancesResource(self._http)
145
146    @cached_property
147    def records(self) -> AdminRecordsResource:
148        return AdminRecordsResource(self._http)
149
150    @cached_property
151    def nodes(self) -> AdminNodesResource:
152        return AdminNodesResource(self._http)
153
154    @cached_property
155    def observability(self) -> AdminObservabilityResource:
156        return AdminObservabilityResource(self._http)
157
158    @cached_property
159    def settings(self) -> AdminSettingsResource:
160        return AdminSettingsResource(self._http)
161
162    @cached_property
163    def competition_admins(self) -> AdminCompetitionAdminsResource:
164        return AdminCompetitionAdminsResource(self._http)
165
166    @cached_property
167    def competition_invites(self) -> AdminCompetitionInvitesResource:
168        return AdminCompetitionInvitesResource(self._http)
169
170    @cached_property
171    def registrations(self) -> AdminRegistrationsResource:
172        """Entrant roster, eligibility review, and CSV export."""
173        return AdminRegistrationsResource(self._http)
174
175    @cached_property
176    def teams(self) -> AdminTeamsResource:
177        """Enforcement against a squad — disqualify and reinstate.
178
179        Separate from ``registrations`` on purpose: that one rules on
180        eligibility (which decides prizes), this one decides whether the
181        team is in the event at all.
182        """
183        return AdminTeamsResource(self._http)
184
185    @cached_property
186    def tasks(self) -> AdminTasksResource:
187        return AdminTasksResource(self._http)
188
189    @cached_property
190    def scheduled_jobs(self) -> AdminScheduledJobsResource:
191        return AdminScheduledJobsResource(self._http)
192
193    @cached_property
194    def eval_models(self) -> AdminEvalModelsResource:
195        return AdminEvalModelsResource(self._http)
196
197    @cached_property
198    def eval_vendors(self) -> AdminEvalVendorsResource:
199        return AdminEvalVendorsResource(self._http)
200
201    @cached_property
202    def eval_harnesses(self) -> AdminEvalHarnessesResource:
203        return AdminEvalHarnessesResource(self._http)
204
205    @cached_property
206    def eval_runs(self) -> AdminEvalRunsResource:
207        return AdminEvalRunsResource(self._http)
208
209    @cached_property
210    def eval_campaigns(self) -> AdminEvalCampaignsResource:
211        return AdminEvalCampaignsResource(self._http)
212
213    def close(self) -> None:
214        """Close the underlying transport.
215
216        Only call this on a standalone client built via :meth:`connect`;
217        when reached via ``PlatformClient.admin`` the transport is shared
218        with — and closed by — the parent player client.
219        """
220        self._http.close()
221
222    def __enter__(self) -> Self:
223        return self
224
225    def __exit__(
226        self,
227        exc_type: type[BaseException] | None,
228        exc: BaseException | None,
229        tb: TracebackType | None,
230    ) -> None:
231        self.close()

Admin / operator surface, grouped into resource namespaces.

Namespaces: users, competitions, announcements, achievements, challenges, instances, records, nodes, observability, settings, competition_admins.

AdminClient(http: ctfy.sdk.base.BaseHttpClient)
82    def __init__(self, http: BaseHttpClient) -> None:
83        #: Shared transport (the parent PlatformClient when reached via
84        #: ``client.admin``, or an owned client when built via ``connect``).
85        self._http = http
@classmethod
def connect( cls, server_url: str, token: str = '', *, max_retries: int = 3, timeout: int = 600) -> AdminClient:
 87    @classmethod
 88    def connect(
 89        cls,
 90        server_url: str,
 91        token: str = "",
 92        *,
 93        max_retries: int = 3,
 94        timeout: int = DEFAULT_CLIENT_TIMEOUT,
 95    ) -> AdminClient:
 96        """Build an ``AdminClient`` that owns its own transport.
 97
 98        For operator tooling that doesn't already hold a
 99        :class:`PlatformClient`. The returned client is a context manager
100        that closes its transport on exit.
101        """
102        base = server_url.rstrip("/")
103        http = BaseHttpClient(f"{base}/api/v1", token, timeout=timeout, max_retries=max_retries)
104        return cls(http)

Build an AdminClient that owns its own transport.

For operator tooling that doesn't already hold a PlatformClient. The returned client is a context manager that closes its transport on exit.

106    @cached_property
107    def users(self) -> AdminUsersResource:
108        return AdminUsersResource(self._http)
110    @cached_property
111    def competitions(self) -> AdminCompetitionsResource:
112        return AdminCompetitionsResource(self._http)
114    @cached_property
115    def announcements(self) -> AdminAnnouncementsResource:
116        return AdminAnnouncementsResource(self._http)
118    @cached_property
119    def achievements(self) -> AdminAchievementsResource:
120        return AdminAchievementsResource(self._http)
122    @cached_property
123    def challenges(self) -> AdminChallengesResource:
124        return AdminChallengesResource(self._http)
126    @cached_property
127    def email(self) -> AdminEmailResource:
128        return AdminEmailResource(self._http)
130    @cached_property
131    def patches(self) -> AdminPatchesResource:
132        return AdminPatchesResource(self._http)
134    @cached_property
135    def awd(self) -> AdminAwdResource:
136        return AdminAwdResource(self._http)
138    @cached_property
139    def series(self) -> AdminSeriesResource:
140        return AdminSeriesResource(self._http)
142    @cached_property
143    def instances(self) -> AdminInstancesResource:
144        return AdminInstancesResource(self._http)
146    @cached_property
147    def records(self) -> AdminRecordsResource:
148        return AdminRecordsResource(self._http)
150    @cached_property
151    def nodes(self) -> AdminNodesResource:
152        return AdminNodesResource(self._http)
154    @cached_property
155    def observability(self) -> AdminObservabilityResource:
156        return AdminObservabilityResource(self._http)
158    @cached_property
159    def settings(self) -> AdminSettingsResource:
160        return AdminSettingsResource(self._http)
162    @cached_property
163    def competition_admins(self) -> AdminCompetitionAdminsResource:
164        return AdminCompetitionAdminsResource(self._http)
166    @cached_property
167    def competition_invites(self) -> AdminCompetitionInvitesResource:
168        return AdminCompetitionInvitesResource(self._http)
170    @cached_property
171    def registrations(self) -> AdminRegistrationsResource:
172        """Entrant roster, eligibility review, and CSV export."""
173        return AdminRegistrationsResource(self._http)

Entrant roster, eligibility review, and CSV export.

175    @cached_property
176    def teams(self) -> AdminTeamsResource:
177        """Enforcement against a squad — disqualify and reinstate.
178
179        Separate from ``registrations`` on purpose: that one rules on
180        eligibility (which decides prizes), this one decides whether the
181        team is in the event at all.
182        """
183        return AdminTeamsResource(self._http)

Enforcement against a squad — disqualify and reinstate.

Separate from registrations on purpose: that one rules on eligibility (which decides prizes), this one decides whether the team is in the event at all.

185    @cached_property
186    def tasks(self) -> AdminTasksResource:
187        return AdminTasksResource(self._http)
189    @cached_property
190    def scheduled_jobs(self) -> AdminScheduledJobsResource:
191        return AdminScheduledJobsResource(self._http)
193    @cached_property
194    def eval_models(self) -> AdminEvalModelsResource:
195        return AdminEvalModelsResource(self._http)
197    @cached_property
198    def eval_vendors(self) -> AdminEvalVendorsResource:
199        return AdminEvalVendorsResource(self._http)
201    @cached_property
202    def eval_harnesses(self) -> AdminEvalHarnessesResource:
203        return AdminEvalHarnessesResource(self._http)
205    @cached_property
206    def eval_runs(self) -> AdminEvalRunsResource:
207        return AdminEvalRunsResource(self._http)
209    @cached_property
210    def eval_campaigns(self) -> AdminEvalCampaignsResource:
211        return AdminEvalCampaignsResource(self._http)
def close(self) -> None:
213    def close(self) -> None:
214        """Close the underlying transport.
215
216        Only call this on a standalone client built via :meth:`connect`;
217        when reached via ``PlatformClient.admin`` the transport is shared
218        with — and closed by — the parent player client.
219        """
220        self._http.close()

Close the underlying transport.

Only call this on a standalone client built via connect(); when reached via PlatformClient.admin the transport is shared with — and closed by — the parent player client.

class NodeClient(ctfy.sdk.base.BaseHttpClient):
 49class NodeClient(BaseHttpClient):
 50    """Thin sync HTTP client; one instance per node URL."""
 51
 52    def __init__(
 53        self,
 54        node_url: str,
 55        token: str,
 56        *,
 57        timeout: int = DEFAULT_CLIENT_TIMEOUT,
 58    ) -> None:
 59        super().__init__(f"{node_url.rstrip('/')}/api/v1", token, timeout=timeout)
 60
 61    # -- lifecycle ----------------------------------------------------------
 62
 63    def start_instance(
 64        self,
 65        *,
 66        challenge_id: str,
 67        instance_id: str,
 68        ttl: int,
 69        answers: dict[str, str],
 70        proxy_output_dir: str | None = None,
 71        env: dict[str, str] | None = None,
 72        publish_gamebox: bool = False,
 73    ) -> dict[str, Any]:
 74        resp = self.request(
 75            "POST",
 76            "/instances",
 77            json={
 78                "challenge_id": challenge_id,
 79                "instance_id": instance_id,
 80                "ttl": ttl,
 81                "answers": answers,
 82                "proxy_output_dir": proxy_output_dir,
 83                "env": env or {},
 84                "publish_gamebox": publish_gamebox,
 85            },
 86        )
 87        _raise_for_node_status(resp)
 88        body: dict[str, Any] = resp.json()
 89        return body
 90
 91    def stop_instance(self, instance_id: str) -> None:
 92        resp = self.request("DELETE", f"/instances/{instance_id}")
 93        resp.raise_for_status()
 94
 95    def stop_all(self) -> None:
 96        resp = self.request("POST", "/admin/stop-all")
 97        resp.raise_for_status()
 98
 99    def rescan_challenges(self) -> dict[str, Any]:
100        """Tell the node to drop its spec cache and re-scan challenges_dir.
101
102        Returns ``{total, added, removed}`` so the platform can report
103        the per-node outcome of a cluster-wide rescan."""
104        resp = self.request("POST", "/admin/rescan-challenges")
105        resp.raise_for_status()
106        body: dict[str, Any] = resp.json()
107        return body
108
109    # -- admin pre-build (image cache warming) ------------------------------
110
111    def build_challenge(self, challenge_id: str) -> dict[str, Any]:
112        """Ask the node to pre-build images for *challenge_id*.
113
114        Fires-and-returns: the node persists ``status="building"`` and
115        spawns a daemon thread; the body returned here is that initial
116        state row. Polling :meth:`get_build_state` is how the platform
117        learns when it lands on ``built`` / ``failed``.
118        """
119        resp = self.request("POST", f"/admin/challenges/{challenge_id}/build")
120        _raise_for_node_status(resp)
121        body: dict[str, Any] = resp.json()
122        return body
123
124    def build_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, Any]:
125        """Queue background pre-build on the node.
126
127        With ``challenge_ids=None`` the node builds every spec it knows.
128        With an explicit list (the platform scoping a bulk build to a
129        competition's challenge set) only those are built. Returns
130        ``{queued, skipped_built, skipped_in_progress}`` so the platform
131        can report per-node what got picked up. Sequential on the node
132        side — no fan-out across the corpus.
133        """
134        kwargs: dict[str, Any] = {}
135        if challenge_ids is not None:
136            kwargs["json"] = {"challenge_ids": challenge_ids}
137        resp = self.request("POST", "/admin/challenges/build-all", **kwargs)
138        _raise_for_node_status(resp)
139        body: dict[str, Any] = resp.json()
140        return body
141
142    def get_build_state(self) -> dict[str, Any]:
143        """Fetch every per-challenge build-state row on this node.
144
145        Returns ``{"rows": [{challenge_id, status, built_at, error}, …]}``.
146        ``status`` is one of ``unbuilt`` / ``building`` / ``built`` /
147        ``failed``; ``unbuilt`` placeholders are synthesised for specs
148        the node has seen but never been asked to build.
149        """
150        resp = self.request("GET", "/admin/challenges/build-state")
151        _raise_for_node_status(resp)
152        body: dict[str, Any] = resp.json()
153        return body
154
155    def pull_challenge(self, challenge_id: str) -> dict[str, Any]:
156        """Ask the node to pre-pull registry images for *challenge_id*.
157
158        Pull-side twin of :meth:`build_challenge`: the node persists
159        ``status="pulling"`` and spawns a daemon thread; poll
160        :meth:`get_pull_state` for the ``pulled`` / ``failed`` landing.
161        """
162        resp = self.request("POST", f"/admin/challenges/{challenge_id}/pull")
163        _raise_for_node_status(resp)
164        body: dict[str, Any] = resp.json()
165        return body
166
167    def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, Any]:
168        """Queue background pre-pull on the node.
169
170        With ``challenge_ids=None`` the node pulls every spec it knows;
171        with an explicit list only those (the platform scoping to a
172        competition). Returns ``{queued, skipped_pulled,
173        skipped_in_progress}``.
174        """
175        kwargs: dict[str, Any] = {}
176        if challenge_ids is not None:
177            kwargs["json"] = {"challenge_ids": challenge_ids}
178        resp = self.request("POST", "/admin/challenges/pull-all", **kwargs)
179        _raise_for_node_status(resp)
180        body: dict[str, Any] = resp.json()
181        return body
182
183    def get_pull_state(self) -> dict[str, Any]:
184        """Fetch every per-challenge pull-state row on this node.
185
186        Returns ``{"rows": [{challenge_id, status, pulled_at, error}, …]}``.
187        ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` /
188        ``failed``; ``unpulled`` placeholders are synthesised for specs
189        the node has seen but never been asked to pull.
190        """
191        resp = self.request("GET", "/admin/challenges/pull-state")
192        _raise_for_node_status(resp)
193        body: dict[str, Any] = resp.json()
194        return body
195
196    # -- status / health ----------------------------------------------------
197
198    def get_status(self, instance_id: str) -> dict[str, Any]:
199        resp = self.request("GET", f"/instances/{instance_id}/status")
200        resp.raise_for_status()
201        body: dict[str, Any] = resp.json()
202        return body
203
204    def list_instances(self) -> list[dict[str, Any]]:
205        """Every instance the node still has bookkeeping for.
206
207        Used by the platform's boot re-adoption pass to decide which
208        durable claims still have containers behind them. ``get_status``
209        answers the same question one id at a time; asking it per claim
210        would cost one round trip per instance to a node that may be
211        slow or down, on the path that gates startup.
212        """
213        resp = self.request("GET", "/instances")
214        resp.raise_for_status()
215        items: list[dict[str, Any]] = resp.json().get("items", [])
216        return items
217
218    def check_health(self, instance_id: str) -> bool:
219        resp = self.request("GET", f"/instances/{instance_id}/health")
220        resp.raise_for_status()
221        return bool(resp.json().get("is_healthy"))
222
223    def node_health(self) -> dict[str, Any]:
224        """Liveness + ``{running, capacity}`` for heartbeat."""
225        resp = self.request("GET", "/health")
226        resp.raise_for_status()
227        body: dict[str, Any] = resp.json()
228        return body
229
230    # -- traffic / runtime --------------------------------------------------
231
232    def get_traffic(self, instance_id: str) -> dict[str, Any]:
233        """Fetch mitmproxy flow data; file lives on the node's FS."""
234        resp = self.request("GET", f"/instances/{instance_id}/traffic")
235        resp.raise_for_status()
236        body: dict[str, Any] = resp.json()
237        return body
238
239    def list_containers(self, instance_id: str) -> list[dict[str, Any]]:
240        """Enumerate every container in an instance's compose project.
241
242        Backs the admin-shell feature: the platform forwards a
243        ``GET /admin/instances/{id}/containers`` to the assigned node,
244        which returns one row per container (challenge services and
245        platform-injected sidecars alike). The WebSocket reverse-proxy
246        is opened separately and does not go through ``NodeClient``.
247        """
248        resp = self.request("GET", f"/instances/{instance_id}/containers")
249        _raise_for_node_status(resp)
250        body: list[dict[str, Any]] = resp.json()
251        return body
252
253    def run_checker(
254        self, instance_id: str, *, service: str, cmd: list[str], timeout_s: int = 30
255    ) -> dict[str, Any]:
256        """Exec an author-supplied checker in a trusted judge sidecar.
257
258        Backs the checker / exec-judge feature: the platform forwards a
259        verify request to the assigned node, which runs ``cmd`` inside the
260        named sidecar and returns
261        ``{exit_code, stdout, stderr, timed_out, error}``. The per-call HTTP
262        timeout sits above the node's exec budget so the client doesn't
263        abandon the request before the node returns.
264        """
265        resp = self.request(
266            "POST",
267            f"/instances/{instance_id}/check",
268            json={"service": service, "cmd": cmd, "timeout_s": timeout_s},
269            timeout=timeout_s + 10,
270        )
271        _raise_for_node_status(resp)
272        body: dict[str, Any] = resp.json()
273        return body
274
275    def run_harness(
276        self, *, image: str, env: dict[str, str], timeout_s: int = 1800
277    ) -> dict[str, Any]:
278        """Run a one-shot evaluation-harness container on the node.
279
280        The node ``docker run``s ``image`` with ``env`` injected, waits for it
281        to exit (bounded by ``timeout_s``), and returns
282        ``{exit_code, stdout, stderr, timed_out, error}`` — the same shape as
283        :meth:`run_checker`. The harness writes its JSON rollup to stdout. The
284        per-call HTTP timeout sits above the node's run budget so the client
285        doesn't abandon the request before the (long-running) harness returns.
286        """
287        resp = self.request(
288            "POST",
289            "/harness/run",
290            json={"image": image, "env": env, "timeout_s": timeout_s},
291            timeout=timeout_s + 30,
292        )
293        _raise_for_node_status(resp)
294        body: dict[str, Any] = resp.json()
295        return body
296
297    def verify_patch(
298        self, challenge_id: str, files: dict[str, bytes], *, timeout_s: int = 900
299    ) -> dict[str, Any]:
300        """Build a submitted patch on the node and return its verdict.
301
302        ``files`` maps a challenge-relative path to its new bytes; base64
303        is applied here because a patch target may legitimately be
304        binary. Returns ``{verdict, detail, applied, steps, error}``.
305
306        **An empty ``verdict`` with a populated ``error`` is not a
307        judgement** — it means the node refused the submission or the
308        verifier crashed. The caller must retry or retire the lease
309        rather than write a score, since failing to judge a patch is not
310        the same as judging it unfixed.
311
312        The per-call HTTP timeout sits above the node's own budget so the
313        client doesn't abandon the request while the node is still
314        building.
315        """
316        resp = self.request(
317            "POST",
318            "/patches/verify",
319            json={
320                "challenge_id": challenge_id,
321                "files": {
322                    path: base64.b64encode(content).decode() for path, content in files.items()
323                },
324                "timeout_s": timeout_s,
325            },
326            timeout=timeout_s + 60,
327        )
328        _raise_for_node_status(resp)
329        body: dict[str, Any] = resp.json()
330        return body
331
332    def collect_patch_files(
333        self, instance_id: str, service: str, paths: list[str]
334    ) -> dict[str, bytes]:
335        """Read a player's SSH edits back out of their running box.
336
337        ``paths`` are absolute in-container paths the *platform* derived
338        from the pristine source tree. Returns only what could be read:
339        a path missing from the reply means the player did not change it,
340        and the caller falls back to the shipped file rather than
341        recording a deletion they never made.
342        """
343        resp = self.request(
344            "POST",
345            f"/instances/{instance_id}/patch-collect",
346            json={"service": service, "paths": paths},
347        )
348        _raise_for_node_status(resp)
349        body: dict[str, str] = resp.json()
350        return {path: base64.b64decode(blob) for path, blob in body.items()}
351
352    def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]:
353        """Rotate a round's flags into every box of this node, in one call.
354
355        ⚠️ **One call for the whole node, never one per box.** §5.2's
356        capacity red line is 1000 teams x 3 services injected inside
357        120 s; per-box that is 3000 round trips, batched it is about
358        fourteen. The signature is the enforcement — a caller cannot
359        accidentally loop.
360
361        Returns ``{instance_id: ""}`` for the boxes written and a reason
362        for the ones that were not, because one team's unreachable box
363        must not cost every other team on the node its rotation. An
364        unrotated box keeps serving last round's flag, which anyone
365        holding the old value can replay.
366        """
367        resp = self.request("POST", "/awd/answers", json={"boxes": boxes})
368        _raise_for_node_status(resp)
369        result: dict[str, str] = resp.json()
370        return result
371
372    def probe_awd_service(
373        self,
374        *,
375        instance_id: str,
376        service_id: str,
377        tick: int,
378        flag: str,
379        previous_flag: str = "",
380        previous_flag_id: str = "",
381    ) -> dict[str, str]:
382        """Run one round's SLA probes against one box, from outside it.
383
384        The flags travel *to* the node: a checker that sourced its
385        expected value from the box it is checking would pass on any box
386        that returns whatever it was handed.
387        """
388        resp = self.request(
389            "POST",
390            "/awd/probe",
391            json={
392                "instance_id": instance_id,
393                "service_id": service_id,
394                "tick": tick,
395                "flag": flag,
396                "previous_flag": previous_flag,
397                "previous_flag_id": previous_flag_id,
398            },
399        )
400        _raise_for_node_status(resp)
401        result: dict[str, str] = resp.json()
402        return result
403
404    def get_container_logs(self, instance_id: str) -> str:
405        """Fetch combined container stdout/stderr for archival."""
406        resp = self.request("GET", f"/instances/{instance_id}/container-logs")
407        resp.raise_for_status()
408        return str(resp.json().get("logs") or "")
409
410    def get_pcap(self, instance_id: str) -> bytes:
411        """Fetch the raw tcpdump capture for *instance_id*.
412
413        Returns an empty ``bytes`` when no capture exists (sidecar
414        disabled, instance never reached the running state, etc.) so
415        callers can persist conditionally without try/except.
416        """
417        resp = self.request("GET", f"/instances/{instance_id}/pcap")
418        if resp.status_code == 404:
419            return b""
420        resp.raise_for_status()
421        return resp.content
422
423    def get_agent_runtime(self, instance_id: str) -> dict[str, Any]:
424        """Provider-agnostic runtime hints for attaching an agent (proxy URL,
425        CA PEM, optional Docker-specific names). Replaces the old
426        sandbox-network shape."""
427        resp = self.request("GET", f"/instances/{instance_id}/agent-runtime")
428        resp.raise_for_status()
429        body: dict[str, Any] = resp.json()
430        return body
431
432    # -- per-instance rendered attachments ---------------------------------
433
434    def list_instance_attachments(self, instance_id: str) -> dict[str, Any]:
435        """List per-instance attachments rendered into the node's workdir.
436
437        Returns the raw JSON dict — caller projects through
438        :class:`AttachmentList` for typing. Used by the platform's
439        per-instance attachment endpoint to surface team-specific
440        rendered file listings."""
441        resp = self.request("GET", f"/instances/{instance_id}/attachments")
442        resp.raise_for_status()
443        body: dict[str, Any] = resp.json()
444        return body
445
446    def get_openvpn_config(self, instance_id: str) -> bytes:
447        """Fetch the per-instance OpenVPN client config from the node.
448
449        The node reads ``/etc/openvpn/client.ovpn`` out of the instance's
450        ``openvpn`` service container (generated on first boot by the
451        ``ctfy/openvpn-base`` image's bootstrap). Returns the body
452        verbatim — the platform-side route adds the
453        ``Content-Disposition`` header before re-emitting to the player.
454
455        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
456        proxy route catches 404 and re-emits to the player, anything
457        else is treated as a 502.
458        """
459        resp = self.request("GET", f"/instances/{instance_id}/openvpn-config")
460        resp.raise_for_status()
461        return resp.content
462
463    def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]:
464        """Download one per-instance attachment as ``(bytes, content_type)``.
465
466        Buffers the whole body — attachments are bounded (typical case
467        is a 10 KB binary or a small text file). For huge captures the
468        caller should hand the player a CDN URL instead.
469
470        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
471        proxy route catches 404 and re-emits to the player, anything
472        else is a 502."""
473        resp = self.request(
474            "GET",
475            f"/instances/{instance_id}/attachments/{filename}",
476        )
477        resp.raise_for_status()
478        return resp.content, resp.headers.get("content-type", "application/octet-stream")

Thin sync HTTP client; one instance per node URL.

NodeClient(node_url: str, token: str, *, timeout: int = 600)
52    def __init__(
53        self,
54        node_url: str,
55        token: str,
56        *,
57        timeout: int = DEFAULT_CLIENT_TIMEOUT,
58    ) -> None:
59        super().__init__(f"{node_url.rstrip('/')}/api/v1", token, timeout=timeout)
def start_instance( self, *, challenge_id: str, instance_id: str, ttl: int, answers: dict[str, str], proxy_output_dir: str | None = None, env: dict[str, str] | None = None, publish_gamebox: bool = False) -> dict[str, typing.Any]:
63    def start_instance(
64        self,
65        *,
66        challenge_id: str,
67        instance_id: str,
68        ttl: int,
69        answers: dict[str, str],
70        proxy_output_dir: str | None = None,
71        env: dict[str, str] | None = None,
72        publish_gamebox: bool = False,
73    ) -> dict[str, Any]:
74        resp = self.request(
75            "POST",
76            "/instances",
77            json={
78                "challenge_id": challenge_id,
79                "instance_id": instance_id,
80                "ttl": ttl,
81                "answers": answers,
82                "proxy_output_dir": proxy_output_dir,
83                "env": env or {},
84                "publish_gamebox": publish_gamebox,
85            },
86        )
87        _raise_for_node_status(resp)
88        body: dict[str, Any] = resp.json()
89        return body
def stop_instance(self, instance_id: str) -> None:
91    def stop_instance(self, instance_id: str) -> None:
92        resp = self.request("DELETE", f"/instances/{instance_id}")
93        resp.raise_for_status()
def stop_all(self) -> None:
95    def stop_all(self) -> None:
96        resp = self.request("POST", "/admin/stop-all")
97        resp.raise_for_status()
def rescan_challenges(self) -> dict[str, typing.Any]:
 99    def rescan_challenges(self) -> dict[str, Any]:
100        """Tell the node to drop its spec cache and re-scan challenges_dir.
101
102        Returns ``{total, added, removed}`` so the platform can report
103        the per-node outcome of a cluster-wide rescan."""
104        resp = self.request("POST", "/admin/rescan-challenges")
105        resp.raise_for_status()
106        body: dict[str, Any] = resp.json()
107        return body

Tell the node to drop its spec cache and re-scan challenges_dir.

Returns {total, added, removed} so the platform can report the per-node outcome of a cluster-wide rescan.

def build_challenge(self, challenge_id: str) -> dict[str, typing.Any]:
111    def build_challenge(self, challenge_id: str) -> dict[str, Any]:
112        """Ask the node to pre-build images for *challenge_id*.
113
114        Fires-and-returns: the node persists ``status="building"`` and
115        spawns a daemon thread; the body returned here is that initial
116        state row. Polling :meth:`get_build_state` is how the platform
117        learns when it lands on ``built`` / ``failed``.
118        """
119        resp = self.request("POST", f"/admin/challenges/{challenge_id}/build")
120        _raise_for_node_status(resp)
121        body: dict[str, Any] = resp.json()
122        return body

Ask the node to pre-build images for challenge_id.

Fires-and-returns: the node persists status="building" and spawns a daemon thread; the body returned here is that initial state row. Polling get_build_state() is how the platform learns when it lands on built / failed.

def build_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, typing.Any]:
124    def build_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, Any]:
125        """Queue background pre-build on the node.
126
127        With ``challenge_ids=None`` the node builds every spec it knows.
128        With an explicit list (the platform scoping a bulk build to a
129        competition's challenge set) only those are built. Returns
130        ``{queued, skipped_built, skipped_in_progress}`` so the platform
131        can report per-node what got picked up. Sequential on the node
132        side — no fan-out across the corpus.
133        """
134        kwargs: dict[str, Any] = {}
135        if challenge_ids is not None:
136            kwargs["json"] = {"challenge_ids": challenge_ids}
137        resp = self.request("POST", "/admin/challenges/build-all", **kwargs)
138        _raise_for_node_status(resp)
139        body: dict[str, Any] = resp.json()
140        return body

Queue background pre-build on the node.

With challenge_ids=None the node builds every spec it knows. With an explicit list (the platform scoping a bulk build to a competition's challenge set) only those are built. Returns {queued, skipped_built, skipped_in_progress} so the platform can report per-node what got picked up. Sequential on the node side — no fan-out across the corpus.

def get_build_state(self) -> dict[str, typing.Any]:
142    def get_build_state(self) -> dict[str, Any]:
143        """Fetch every per-challenge build-state row on this node.
144
145        Returns ``{"rows": [{challenge_id, status, built_at, error}, …]}``.
146        ``status`` is one of ``unbuilt`` / ``building`` / ``built`` /
147        ``failed``; ``unbuilt`` placeholders are synthesised for specs
148        the node has seen but never been asked to build.
149        """
150        resp = self.request("GET", "/admin/challenges/build-state")
151        _raise_for_node_status(resp)
152        body: dict[str, Any] = resp.json()
153        return body

Fetch every per-challenge build-state row on this node.

Returns {"rows": [{challenge_id, status, built_at, error}, …]}. status is one of unbuilt / building / built / failed; unbuilt placeholders are synthesised for specs the node has seen but never been asked to build.

def pull_challenge(self, challenge_id: str) -> dict[str, typing.Any]:
155    def pull_challenge(self, challenge_id: str) -> dict[str, Any]:
156        """Ask the node to pre-pull registry images for *challenge_id*.
157
158        Pull-side twin of :meth:`build_challenge`: the node persists
159        ``status="pulling"`` and spawns a daemon thread; poll
160        :meth:`get_pull_state` for the ``pulled`` / ``failed`` landing.
161        """
162        resp = self.request("POST", f"/admin/challenges/{challenge_id}/pull")
163        _raise_for_node_status(resp)
164        body: dict[str, Any] = resp.json()
165        return body

Ask the node to pre-pull registry images for challenge_id.

Pull-side twin of build_challenge(): the node persists status="pulling" and spawns a daemon thread; poll get_pull_state() for the pulled / failed landing.

def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, typing.Any]:
167    def pull_all_challenges(self, challenge_ids: list[str] | None = None) -> dict[str, Any]:
168        """Queue background pre-pull on the node.
169
170        With ``challenge_ids=None`` the node pulls every spec it knows;
171        with an explicit list only those (the platform scoping to a
172        competition). Returns ``{queued, skipped_pulled,
173        skipped_in_progress}``.
174        """
175        kwargs: dict[str, Any] = {}
176        if challenge_ids is not None:
177            kwargs["json"] = {"challenge_ids": challenge_ids}
178        resp = self.request("POST", "/admin/challenges/pull-all", **kwargs)
179        _raise_for_node_status(resp)
180        body: dict[str, Any] = resp.json()
181        return body

Queue background pre-pull on the node.

With challenge_ids=None the node pulls every spec it knows; with an explicit list only those (the platform scoping to a competition). Returns {queued, skipped_pulled, skipped_in_progress}.

def get_pull_state(self) -> dict[str, typing.Any]:
183    def get_pull_state(self) -> dict[str, Any]:
184        """Fetch every per-challenge pull-state row on this node.
185
186        Returns ``{"rows": [{challenge_id, status, pulled_at, error}, …]}``.
187        ``status`` is one of ``unpulled`` / ``pulling`` / ``pulled`` /
188        ``failed``; ``unpulled`` placeholders are synthesised for specs
189        the node has seen but never been asked to pull.
190        """
191        resp = self.request("GET", "/admin/challenges/pull-state")
192        _raise_for_node_status(resp)
193        body: dict[str, Any] = resp.json()
194        return body

Fetch every per-challenge pull-state row on this node.

Returns {"rows": [{challenge_id, status, pulled_at, error}, …]}. status is one of unpulled / pulling / pulled / failed; unpulled placeholders are synthesised for specs the node has seen but never been asked to pull.

def get_status(self, instance_id: str) -> dict[str, typing.Any]:
198    def get_status(self, instance_id: str) -> dict[str, Any]:
199        resp = self.request("GET", f"/instances/{instance_id}/status")
200        resp.raise_for_status()
201        body: dict[str, Any] = resp.json()
202        return body
def list_instances(self) -> list[dict[str, typing.Any]]:
204    def list_instances(self) -> list[dict[str, Any]]:
205        """Every instance the node still has bookkeeping for.
206
207        Used by the platform's boot re-adoption pass to decide which
208        durable claims still have containers behind them. ``get_status``
209        answers the same question one id at a time; asking it per claim
210        would cost one round trip per instance to a node that may be
211        slow or down, on the path that gates startup.
212        """
213        resp = self.request("GET", "/instances")
214        resp.raise_for_status()
215        items: list[dict[str, Any]] = resp.json().get("items", [])
216        return items

Every instance the node still has bookkeeping for.

Used by the platform's boot re-adoption pass to decide which durable claims still have containers behind them. get_status answers the same question one id at a time; asking it per claim would cost one round trip per instance to a node that may be slow or down, on the path that gates startup.

def check_health(self, instance_id: str) -> bool:
218    def check_health(self, instance_id: str) -> bool:
219        resp = self.request("GET", f"/instances/{instance_id}/health")
220        resp.raise_for_status()
221        return bool(resp.json().get("is_healthy"))
def node_health(self) -> dict[str, typing.Any]:
223    def node_health(self) -> dict[str, Any]:
224        """Liveness + ``{running, capacity}`` for heartbeat."""
225        resp = self.request("GET", "/health")
226        resp.raise_for_status()
227        body: dict[str, Any] = resp.json()
228        return body

Liveness + {running, capacity} for heartbeat.

def get_traffic(self, instance_id: str) -> dict[str, typing.Any]:
232    def get_traffic(self, instance_id: str) -> dict[str, Any]:
233        """Fetch mitmproxy flow data; file lives on the node's FS."""
234        resp = self.request("GET", f"/instances/{instance_id}/traffic")
235        resp.raise_for_status()
236        body: dict[str, Any] = resp.json()
237        return body

Fetch mitmproxy flow data; file lives on the node's FS.

def list_containers(self, instance_id: str) -> list[dict[str, typing.Any]]:
239    def list_containers(self, instance_id: str) -> list[dict[str, Any]]:
240        """Enumerate every container in an instance's compose project.
241
242        Backs the admin-shell feature: the platform forwards a
243        ``GET /admin/instances/{id}/containers`` to the assigned node,
244        which returns one row per container (challenge services and
245        platform-injected sidecars alike). The WebSocket reverse-proxy
246        is opened separately and does not go through ``NodeClient``.
247        """
248        resp = self.request("GET", f"/instances/{instance_id}/containers")
249        _raise_for_node_status(resp)
250        body: list[dict[str, Any]] = resp.json()
251        return body

Enumerate every container in an instance's compose project.

Backs the admin-shell feature: the platform forwards a GET /admin/instances/{id}/containers to the assigned node, which returns one row per container (challenge services and platform-injected sidecars alike). The WebSocket reverse-proxy is opened separately and does not go through NodeClient.

def run_checker( self, instance_id: str, *, service: str, cmd: list[str], timeout_s: int = 30) -> dict[str, typing.Any]:
253    def run_checker(
254        self, instance_id: str, *, service: str, cmd: list[str], timeout_s: int = 30
255    ) -> dict[str, Any]:
256        """Exec an author-supplied checker in a trusted judge sidecar.
257
258        Backs the checker / exec-judge feature: the platform forwards a
259        verify request to the assigned node, which runs ``cmd`` inside the
260        named sidecar and returns
261        ``{exit_code, stdout, stderr, timed_out, error}``. The per-call HTTP
262        timeout sits above the node's exec budget so the client doesn't
263        abandon the request before the node returns.
264        """
265        resp = self.request(
266            "POST",
267            f"/instances/{instance_id}/check",
268            json={"service": service, "cmd": cmd, "timeout_s": timeout_s},
269            timeout=timeout_s + 10,
270        )
271        _raise_for_node_status(resp)
272        body: dict[str, Any] = resp.json()
273        return body

Exec an author-supplied checker in a trusted judge sidecar.

Backs the checker / exec-judge feature: the platform forwards a verify request to the assigned node, which runs cmd inside the named sidecar and returns {exit_code, stdout, stderr, timed_out, error}. The per-call HTTP timeout sits above the node's exec budget so the client doesn't abandon the request before the node returns.

def run_harness( self, *, image: str, env: dict[str, str], timeout_s: int = 1800) -> dict[str, typing.Any]:
275    def run_harness(
276        self, *, image: str, env: dict[str, str], timeout_s: int = 1800
277    ) -> dict[str, Any]:
278        """Run a one-shot evaluation-harness container on the node.
279
280        The node ``docker run``s ``image`` with ``env`` injected, waits for it
281        to exit (bounded by ``timeout_s``), and returns
282        ``{exit_code, stdout, stderr, timed_out, error}`` — the same shape as
283        :meth:`run_checker`. The harness writes its JSON rollup to stdout. The
284        per-call HTTP timeout sits above the node's run budget so the client
285        doesn't abandon the request before the (long-running) harness returns.
286        """
287        resp = self.request(
288            "POST",
289            "/harness/run",
290            json={"image": image, "env": env, "timeout_s": timeout_s},
291            timeout=timeout_s + 30,
292        )
293        _raise_for_node_status(resp)
294        body: dict[str, Any] = resp.json()
295        return body

Run a one-shot evaluation-harness container on the node.

The node docker runs image with env injected, waits for it to exit (bounded by timeout_s), and returns {exit_code, stdout, stderr, timed_out, error} — the same shape as run_checker(). The harness writes its JSON rollup to stdout. The per-call HTTP timeout sits above the node's run budget so the client doesn't abandon the request before the (long-running) harness returns.

def verify_patch( self, challenge_id: str, files: dict[str, bytes], *, timeout_s: int = 900) -> dict[str, typing.Any]:
297    def verify_patch(
298        self, challenge_id: str, files: dict[str, bytes], *, timeout_s: int = 900
299    ) -> dict[str, Any]:
300        """Build a submitted patch on the node and return its verdict.
301
302        ``files`` maps a challenge-relative path to its new bytes; base64
303        is applied here because a patch target may legitimately be
304        binary. Returns ``{verdict, detail, applied, steps, error}``.
305
306        **An empty ``verdict`` with a populated ``error`` is not a
307        judgement** — it means the node refused the submission or the
308        verifier crashed. The caller must retry or retire the lease
309        rather than write a score, since failing to judge a patch is not
310        the same as judging it unfixed.
311
312        The per-call HTTP timeout sits above the node's own budget so the
313        client doesn't abandon the request while the node is still
314        building.
315        """
316        resp = self.request(
317            "POST",
318            "/patches/verify",
319            json={
320                "challenge_id": challenge_id,
321                "files": {
322                    path: base64.b64encode(content).decode() for path, content in files.items()
323                },
324                "timeout_s": timeout_s,
325            },
326            timeout=timeout_s + 60,
327        )
328        _raise_for_node_status(resp)
329        body: dict[str, Any] = resp.json()
330        return body

Build a submitted patch on the node and return its verdict.

files maps a challenge-relative path to its new bytes; base64 is applied here because a patch target may legitimately be binary. Returns {verdict, detail, applied, steps, error}.

An empty verdict with a populated error is not a judgement — it means the node refused the submission or the verifier crashed. The caller must retry or retire the lease rather than write a score, since failing to judge a patch is not the same as judging it unfixed.

The per-call HTTP timeout sits above the node's own budget so the client doesn't abandon the request while the node is still building.

def collect_patch_files( self, instance_id: str, service: str, paths: list[str]) -> dict[str, bytes]:
332    def collect_patch_files(
333        self, instance_id: str, service: str, paths: list[str]
334    ) -> dict[str, bytes]:
335        """Read a player's SSH edits back out of their running box.
336
337        ``paths`` are absolute in-container paths the *platform* derived
338        from the pristine source tree. Returns only what could be read:
339        a path missing from the reply means the player did not change it,
340        and the caller falls back to the shipped file rather than
341        recording a deletion they never made.
342        """
343        resp = self.request(
344            "POST",
345            f"/instances/{instance_id}/patch-collect",
346            json={"service": service, "paths": paths},
347        )
348        _raise_for_node_status(resp)
349        body: dict[str, str] = resp.json()
350        return {path: base64.b64decode(blob) for path, blob in body.items()}

Read a player's SSH edits back out of their running box.

paths are absolute in-container paths the platform derived from the pristine source tree. Returns only what could be read: a path missing from the reply means the player did not change it, and the caller falls back to the shipped file rather than recording a deletion they never made.

def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]:
352    def write_awd_answers(self, boxes: dict[str, dict[str, str]]) -> dict[str, str]:
353        """Rotate a round's flags into every box of this node, in one call.
354
355        ⚠️ **One call for the whole node, never one per box.** §5.2's
356        capacity red line is 1000 teams x 3 services injected inside
357        120 s; per-box that is 3000 round trips, batched it is about
358        fourteen. The signature is the enforcement — a caller cannot
359        accidentally loop.
360
361        Returns ``{instance_id: ""}`` for the boxes written and a reason
362        for the ones that were not, because one team's unreachable box
363        must not cost every other team on the node its rotation. An
364        unrotated box keeps serving last round's flag, which anyone
365        holding the old value can replay.
366        """
367        resp = self.request("POST", "/awd/answers", json={"boxes": boxes})
368        _raise_for_node_status(resp)
369        result: dict[str, str] = resp.json()
370        return result

Rotate a round's flags into every box of this node, in one call.

⚠️ One call for the whole node, never one per box. §5.2's capacity red line is 1000 teams x 3 services injected inside 120 s; per-box that is 3000 round trips, batched it is about fourteen. The signature is the enforcement — a caller cannot accidentally loop.

Returns {instance_id: ""} for the boxes written and a reason for the ones that were not, because one team's unreachable box must not cost every other team on the node its rotation. An unrotated box keeps serving last round's flag, which anyone holding the old value can replay.

def probe_awd_service( self, *, instance_id: str, service_id: str, tick: int, flag: str, previous_flag: str = '', previous_flag_id: str = '') -> dict[str, str]:
372    def probe_awd_service(
373        self,
374        *,
375        instance_id: str,
376        service_id: str,
377        tick: int,
378        flag: str,
379        previous_flag: str = "",
380        previous_flag_id: str = "",
381    ) -> dict[str, str]:
382        """Run one round's SLA probes against one box, from outside it.
383
384        The flags travel *to* the node: a checker that sourced its
385        expected value from the box it is checking would pass on any box
386        that returns whatever it was handed.
387        """
388        resp = self.request(
389            "POST",
390            "/awd/probe",
391            json={
392                "instance_id": instance_id,
393                "service_id": service_id,
394                "tick": tick,
395                "flag": flag,
396                "previous_flag": previous_flag,
397                "previous_flag_id": previous_flag_id,
398            },
399        )
400        _raise_for_node_status(resp)
401        result: dict[str, str] = resp.json()
402        return result

Run one round's SLA probes against one box, from outside it.

The flags travel to the node: a checker that sourced its expected value from the box it is checking would pass on any box that returns whatever it was handed.

def get_container_logs(self, instance_id: str) -> str:
404    def get_container_logs(self, instance_id: str) -> str:
405        """Fetch combined container stdout/stderr for archival."""
406        resp = self.request("GET", f"/instances/{instance_id}/container-logs")
407        resp.raise_for_status()
408        return str(resp.json().get("logs") or "")

Fetch combined container stdout/stderr for archival.

def get_pcap(self, instance_id: str) -> bytes:
410    def get_pcap(self, instance_id: str) -> bytes:
411        """Fetch the raw tcpdump capture for *instance_id*.
412
413        Returns an empty ``bytes`` when no capture exists (sidecar
414        disabled, instance never reached the running state, etc.) so
415        callers can persist conditionally without try/except.
416        """
417        resp = self.request("GET", f"/instances/{instance_id}/pcap")
418        if resp.status_code == 404:
419            return b""
420        resp.raise_for_status()
421        return resp.content

Fetch the raw tcpdump capture for instance_id.

Returns an empty bytes when no capture exists (sidecar disabled, instance never reached the running state, etc.) so callers can persist conditionally without try/except.

def get_agent_runtime(self, instance_id: str) -> dict[str, typing.Any]:
423    def get_agent_runtime(self, instance_id: str) -> dict[str, Any]:
424        """Provider-agnostic runtime hints for attaching an agent (proxy URL,
425        CA PEM, optional Docker-specific names). Replaces the old
426        sandbox-network shape."""
427        resp = self.request("GET", f"/instances/{instance_id}/agent-runtime")
428        resp.raise_for_status()
429        body: dict[str, Any] = resp.json()
430        return body

Provider-agnostic runtime hints for attaching an agent (proxy URL, CA PEM, optional Docker-specific names). Replaces the old sandbox-network shape.

def list_instance_attachments(self, instance_id: str) -> dict[str, typing.Any]:
434    def list_instance_attachments(self, instance_id: str) -> dict[str, Any]:
435        """List per-instance attachments rendered into the node's workdir.
436
437        Returns the raw JSON dict — caller projects through
438        :class:`AttachmentList` for typing. Used by the platform's
439        per-instance attachment endpoint to surface team-specific
440        rendered file listings."""
441        resp = self.request("GET", f"/instances/{instance_id}/attachments")
442        resp.raise_for_status()
443        body: dict[str, Any] = resp.json()
444        return body

List per-instance attachments rendered into the node's workdir.

Returns the raw JSON dict — caller projects through AttachmentList for typing. Used by the platform's per-instance attachment endpoint to surface team-specific rendered file listings.

def get_openvpn_config(self, instance_id: str) -> bytes:
446    def get_openvpn_config(self, instance_id: str) -> bytes:
447        """Fetch the per-instance OpenVPN client config from the node.
448
449        The node reads ``/etc/openvpn/client.ovpn`` out of the instance's
450        ``openvpn`` service container (generated on first boot by the
451        ``ctfy/openvpn-base`` image's bootstrap). Returns the body
452        verbatim — the platform-side route adds the
453        ``Content-Disposition`` header before re-emitting to the player.
454
455        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
456        proxy route catches 404 and re-emits to the player, anything
457        else is treated as a 502.
458        """
459        resp = self.request("GET", f"/instances/{instance_id}/openvpn-config")
460        resp.raise_for_status()
461        return resp.content

Fetch the per-instance OpenVPN client config from the node.

The node reads /etc/openvpn/client.ovpn out of the instance's openvpn service container (generated on first boot by the ctfy/openvpn-base image's bootstrap). Returns the body verbatim — the platform-side route adds the Content-Disposition header before re-emitting to the player.

Raises httpx.HTTPStatusError on non-2xx — the platform's proxy route catches 404 and re-emits to the player, anything else is treated as a 502.

def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]:
463    def download_instance_attachment(self, instance_id: str, filename: str) -> tuple[bytes, str]:
464        """Download one per-instance attachment as ``(bytes, content_type)``.
465
466        Buffers the whole body — attachments are bounded (typical case
467        is a 10 KB binary or a small text file). For huge captures the
468        caller should hand the player a CDN URL instead.
469
470        Raises ``httpx.HTTPStatusError`` on non-2xx — the platform's
471        proxy route catches 404 and re-emits to the player, anything
472        else is a 502."""
473        resp = self.request(
474            "GET",
475            f"/instances/{instance_id}/attachments/{filename}",
476        )
477        resp.raise_for_status()
478        return resp.content, resp.headers.get("content-type", "application/octet-stream")

Download one per-instance attachment as (bytes, content_type).

Buffers the whole body — attachments are bounded (typical case is a 10 KB binary or a small text file). For huge captures the caller should hand the player a CDN URL instead.

Raises httpx.HTTPStatusError on non-2xx — the platform's proxy route catches 404 and re-emits to the player, anything else is a 502.

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: 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.