ctfy.sdk.resources.competitions

client.competitions — discover competitions (list / get).

Acting within a competition — register, your team, invites, challenges, instances, submissions, standings — lives on the scoped handle client.competition(id) (ctfy.sdk.competition.Competition).

  1"""``client.competitions`` — discover competitions (list / get).
  2
  3Acting *within* a competition — register, your team, invites, challenges,
  4instances, submissions, standings — lives on the scoped handle
  5``client.competition(id)`` (:class:`ctfy.sdk.competition.Competition`).
  6"""
  7
  8from __future__ import annotations
  9
 10from typing import Any
 11
 12from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status
 13from ctfy.sdk.base import BaseHttpClient
 14from ctfy.server.models import (
 15    AgentActionsPage,
 16    AgentPulsePage,
 17    CompetitionAttachmentsResponse,
 18    CompetitionDetail,
 19    CompetitionInfo,
 20    VirtualGhostBoard,
 21)
 22
 23
 24class CompetitionsResource:
 25    """Browse competitions. To act within one, use ``client.competition(id)``."""
 26
 27    def __init__(self, http: BaseHttpClient) -> None:
 28        self._http = http
 29
 30    def list(self, phase: str = "", offset: int = 0, limit: int = 50) -> PagedList[CompetitionInfo]:
 31        """Public competition list. ``phase`` ∈ ``"" | "upcoming" |
 32        "running" | "past"`` ("" = all). Start here, then scope into one
 33        with ``client.competition(comp.id)``."""
 34        params: dict[str, Any] = {"offset": offset, "limit": limit}
 35        if phase:
 36            params["phase"] = phase
 37        resp = self._http.request("GET", "/competitions", params=params)
 38        _raise_for_status(resp)
 39        return _extract_items(resp.json(), CompetitionInfo)
 40
 41    def get(self, competition_id: str) -> CompetitionDetail:
 42        """Full competition detail including resolved challenge summaries
 43        (``.challenges``). Same as ``client.competition(id).detail()``."""
 44        resp = self._http.request("GET", f"/competitions/{competition_id}")
 45        _raise_for_status(resp)
 46        return CompetitionDetail.model_validate(resp.json())
 47
 48    def attachments(self, competition_id: str) -> CompetitionAttachmentsResponse:
 49        """The competition's rules / handbook / notice files, in display
 50        order and every language's (``locale`` ``""`` is for everyone).
 51        Read these before playing — they are the rules as the organiser
 52        published them. Fetch one with :meth:`download_attachment`."""
 53        resp = self._http.request("GET", f"/competitions/{competition_id}/attachments")
 54        _raise_for_status(resp)
 55        return CompetitionAttachmentsResponse.model_validate(resp.json())
 56
 57    def download_attachment(self, competition_id: str, attachment_id: str) -> bytes:
 58        """One competition file's raw bytes (id from :meth:`attachments`)."""
 59        resp = self._http.request(
 60            "GET", f"/competitions/{competition_id}/attachments/{attachment_id}"
 61        )
 62        _raise_for_status(resp)
 63        return resp.content
 64
 65    def start_virtual(self, competition_id: str) -> CompetitionInfo:
 66        """Replay a finished contest on your own clock.
 67
 68        Returns the *replay*, which is an ordinary competition you are
 69        already registered in — so every other call takes its
 70        ``.id`` unchanged: launch instances, submit answers, submit
 71        patches, read its board.
 72
 73        **Idempotent.** Asking twice returns the replay you already
 74        started rather than minting a second sitting, so a retry cannot
 75        split one attempt across two competitions.
 76
 77        Refused (400) when a replay is not defined: the source has not
 78        finished, has no window, is multi-session, never ran, or is
 79        itself a replay. The error code says which.
 80        """
 81        resp = self._http.request("POST", f"/competitions/{competition_id}/virtual")
 82        _raise_for_status(resp)
 83        return CompetitionInfo.model_validate(resp.json())
 84
 85    def ghosts(self, competition_id: str) -> VirtualGhostBoard:
 86        """The source contest's standings at the instant this replay has
 87        reached, with you merged in and ranked among them.
 88
 89        Takes the **replay's** id, not the source's. At T+40 minutes
 90        into your run you see the contest as it stood at T+40 minutes on
 91        the day — which is what makes a replay a race rather than an
 92        exercise, since a board holding only your own solves answers
 93        nothing.
 94
 95        Refused (400 ``not_a_virtual_sitting``) on an ordinary
 96        competition. A source that has since been deleted costs the
 97        ghosts and nothing else: the reply comes back with no items and
 98        the run itself keeps working.
 99        """
100        resp = self._http.request("GET", f"/competitions/{competition_id}/ghosts")
101        _raise_for_status(resp)
102        return VirtualGhostBoard.model_validate(resp.json())
103
104    def agent_actions(self, competition_id: str) -> AgentActionsPage:
105        """One scrubbed line per agent saying what it is doing now.
106
107        :meth:`agent_pulse`'s sibling, and the whole difference is that
108        this one carries content. What makes that publishable is two
109        gates, neither of which stands in for the other:
110
111        * the line is a single tool call picked out of the newest
112          finished conversation, run through the platform's secret-shape
113          table and cut — so the round's minted flag and the untrusted
114          markup do not travel;
115        * a row exists only for an entrant whose **author** opted in
116          (``POST /eval/runs/{run_id}/wall``), which is the answer to
117          the one rule no scrubbing dissolves: the conversation belongs
118          to the person who ran it.
119
120        ⚠️ **An empty ``rows`` is four different things and the three
121        numbers beside it say which**: ``indexing: false`` is a
122        deployment with no transcript index, ``entrants: 0`` is nobody
123        having entered an agent, ``published: 0`` is nobody's author
124        having agreed, and all three set with no rows is everybody
125        having agreed and no call having finished yet.
126
127        ⚠️ **At most ``max_rows`` entrants are read**, because each one
128        costs an archived-transcript read; the reply publishes that
129        ceiling beside ``published`` so a truncated answer cannot read
130        as a complete one.
131
132        Refused (403 ``silent_standings``) on a silent competition, for
133        :meth:`agent_pulse`'s reason.
134
135        Wraps ``GET /competitions/{competition_id}/agent-actions``."""
136        resp = self._http.request("GET", f"/competitions/{competition_id}/agent-actions")
137        _raise_for_status(resp)
138        return AgentActionsPage.model_validate(resp.json())
139
140    def agent_pulse(self, competition_id: str, *, limit: int = 200) -> AgentPulsePage:
141        """What this event's agents have been asking a model, without it.
142
143        One lane per entrant, one beat per **finished** model call. The
144        gateway composes a record once, at completion — streamed
145        completions included — so no caller of this can see a
146        conversation in flight, whatever cadence they poll at.
147
148        ⚠️ **Carries no prompt and no completion, and cannot be made
149        to.** A relayed conversation holds the challenge's own minted
150        answer, which on an AWD gamebox is the round's flag, and its
151        prompt half is written by whoever is attacking the challenge —
152        so what travels is the shape of the traffic: how much each call
153        wrote, how long it took, and what went wrong. The bodies live
154        behind ``/admin/llm-calls/{id}`` and the run's own author-scoped
155        route, both of which rule on who may read them.
156
157        Empty means one of two opposite things and the reply says
158        which: ``indexing: false`` is a deployment that keeps no
159        transcript index at all.
160
161        Refused (403 ``silent_standings``) on a silent competition, like
162        the board — a lane per entrant saying who is still working is
163        the field's progress.
164        """
165        resp = self._http.request(
166            "GET",
167            f"/competitions/{competition_id}/agent-pulse",
168            params={"limit": limit},
169        )
170        _raise_for_status(resp)
171        return AgentPulsePage.model_validate(resp.json())
class CompetitionsResource:
 25class CompetitionsResource:
 26    """Browse competitions. To act within one, use ``client.competition(id)``."""
 27
 28    def __init__(self, http: BaseHttpClient) -> None:
 29        self._http = http
 30
 31    def list(self, phase: str = "", offset: int = 0, limit: int = 50) -> PagedList[CompetitionInfo]:
 32        """Public competition list. ``phase`` ∈ ``"" | "upcoming" |
 33        "running" | "past"`` ("" = all). Start here, then scope into one
 34        with ``client.competition(comp.id)``."""
 35        params: dict[str, Any] = {"offset": offset, "limit": limit}
 36        if phase:
 37            params["phase"] = phase
 38        resp = self._http.request("GET", "/competitions", params=params)
 39        _raise_for_status(resp)
 40        return _extract_items(resp.json(), CompetitionInfo)
 41
 42    def get(self, competition_id: str) -> CompetitionDetail:
 43        """Full competition detail including resolved challenge summaries
 44        (``.challenges``). Same as ``client.competition(id).detail()``."""
 45        resp = self._http.request("GET", f"/competitions/{competition_id}")
 46        _raise_for_status(resp)
 47        return CompetitionDetail.model_validate(resp.json())
 48
 49    def attachments(self, competition_id: str) -> CompetitionAttachmentsResponse:
 50        """The competition's rules / handbook / notice files, in display
 51        order and every language's (``locale`` ``""`` is for everyone).
 52        Read these before playing — they are the rules as the organiser
 53        published them. Fetch one with :meth:`download_attachment`."""
 54        resp = self._http.request("GET", f"/competitions/{competition_id}/attachments")
 55        _raise_for_status(resp)
 56        return CompetitionAttachmentsResponse.model_validate(resp.json())
 57
 58    def download_attachment(self, competition_id: str, attachment_id: str) -> bytes:
 59        """One competition file's raw bytes (id from :meth:`attachments`)."""
 60        resp = self._http.request(
 61            "GET", f"/competitions/{competition_id}/attachments/{attachment_id}"
 62        )
 63        _raise_for_status(resp)
 64        return resp.content
 65
 66    def start_virtual(self, competition_id: str) -> CompetitionInfo:
 67        """Replay a finished contest on your own clock.
 68
 69        Returns the *replay*, which is an ordinary competition you are
 70        already registered in — so every other call takes its
 71        ``.id`` unchanged: launch instances, submit answers, submit
 72        patches, read its board.
 73
 74        **Idempotent.** Asking twice returns the replay you already
 75        started rather than minting a second sitting, so a retry cannot
 76        split one attempt across two competitions.
 77
 78        Refused (400) when a replay is not defined: the source has not
 79        finished, has no window, is multi-session, never ran, or is
 80        itself a replay. The error code says which.
 81        """
 82        resp = self._http.request("POST", f"/competitions/{competition_id}/virtual")
 83        _raise_for_status(resp)
 84        return CompetitionInfo.model_validate(resp.json())
 85
 86    def ghosts(self, competition_id: str) -> VirtualGhostBoard:
 87        """The source contest's standings at the instant this replay has
 88        reached, with you merged in and ranked among them.
 89
 90        Takes the **replay's** id, not the source's. At T+40 minutes
 91        into your run you see the contest as it stood at T+40 minutes on
 92        the day — which is what makes a replay a race rather than an
 93        exercise, since a board holding only your own solves answers
 94        nothing.
 95
 96        Refused (400 ``not_a_virtual_sitting``) on an ordinary
 97        competition. A source that has since been deleted costs the
 98        ghosts and nothing else: the reply comes back with no items and
 99        the run itself keeps working.
100        """
101        resp = self._http.request("GET", f"/competitions/{competition_id}/ghosts")
102        _raise_for_status(resp)
103        return VirtualGhostBoard.model_validate(resp.json())
104
105    def agent_actions(self, competition_id: str) -> AgentActionsPage:
106        """One scrubbed line per agent saying what it is doing now.
107
108        :meth:`agent_pulse`'s sibling, and the whole difference is that
109        this one carries content. What makes that publishable is two
110        gates, neither of which stands in for the other:
111
112        * the line is a single tool call picked out of the newest
113          finished conversation, run through the platform's secret-shape
114          table and cut — so the round's minted flag and the untrusted
115          markup do not travel;
116        * a row exists only for an entrant whose **author** opted in
117          (``POST /eval/runs/{run_id}/wall``), which is the answer to
118          the one rule no scrubbing dissolves: the conversation belongs
119          to the person who ran it.
120
121        ⚠️ **An empty ``rows`` is four different things and the three
122        numbers beside it say which**: ``indexing: false`` is a
123        deployment with no transcript index, ``entrants: 0`` is nobody
124        having entered an agent, ``published: 0`` is nobody's author
125        having agreed, and all three set with no rows is everybody
126        having agreed and no call having finished yet.
127
128        ⚠️ **At most ``max_rows`` entrants are read**, because each one
129        costs an archived-transcript read; the reply publishes that
130        ceiling beside ``published`` so a truncated answer cannot read
131        as a complete one.
132
133        Refused (403 ``silent_standings``) on a silent competition, for
134        :meth:`agent_pulse`'s reason.
135
136        Wraps ``GET /competitions/{competition_id}/agent-actions``."""
137        resp = self._http.request("GET", f"/competitions/{competition_id}/agent-actions")
138        _raise_for_status(resp)
139        return AgentActionsPage.model_validate(resp.json())
140
141    def agent_pulse(self, competition_id: str, *, limit: int = 200) -> AgentPulsePage:
142        """What this event's agents have been asking a model, without it.
143
144        One lane per entrant, one beat per **finished** model call. The
145        gateway composes a record once, at completion — streamed
146        completions included — so no caller of this can see a
147        conversation in flight, whatever cadence they poll at.
148
149        ⚠️ **Carries no prompt and no completion, and cannot be made
150        to.** A relayed conversation holds the challenge's own minted
151        answer, which on an AWD gamebox is the round's flag, and its
152        prompt half is written by whoever is attacking the challenge —
153        so what travels is the shape of the traffic: how much each call
154        wrote, how long it took, and what went wrong. The bodies live
155        behind ``/admin/llm-calls/{id}`` and the run's own author-scoped
156        route, both of which rule on who may read them.
157
158        Empty means one of two opposite things and the reply says
159        which: ``indexing: false`` is a deployment that keeps no
160        transcript index at all.
161
162        Refused (403 ``silent_standings``) on a silent competition, like
163        the board — a lane per entrant saying who is still working is
164        the field's progress.
165        """
166        resp = self._http.request(
167            "GET",
168            f"/competitions/{competition_id}/agent-pulse",
169            params={"limit": limit},
170        )
171        _raise_for_status(resp)
172        return AgentPulsePage.model_validate(resp.json())

Browse competitions. To act within one, use client.competition(id).

CompetitionsResource(http: ctfy.sdk.base.BaseHttpClient)
28    def __init__(self, http: BaseHttpClient) -> None:
29        self._http = http
def list( self, phase: str = '', offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.server.models.CompetitionInfo]:
31    def list(self, phase: str = "", offset: int = 0, limit: int = 50) -> PagedList[CompetitionInfo]:
32        """Public competition list. ``phase`` ∈ ``"" | "upcoming" |
33        "running" | "past"`` ("" = all). Start here, then scope into one
34        with ``client.competition(comp.id)``."""
35        params: dict[str, Any] = {"offset": offset, "limit": limit}
36        if phase:
37            params["phase"] = phase
38        resp = self._http.request("GET", "/competitions", params=params)
39        _raise_for_status(resp)
40        return _extract_items(resp.json(), CompetitionInfo)

Public competition list. phase ∈ "" | "upcoming" | "running" | "past" ("" = all). Start here, then scope into one with client.competition(comp.id).

def get( self, competition_id: str) -> ctfy.server.models.CompetitionDetail:
42    def get(self, competition_id: str) -> CompetitionDetail:
43        """Full competition detail including resolved challenge summaries
44        (``.challenges``). Same as ``client.competition(id).detail()``."""
45        resp = self._http.request("GET", f"/competitions/{competition_id}")
46        _raise_for_status(resp)
47        return CompetitionDetail.model_validate(resp.json())

Full competition detail including resolved challenge summaries (ctfy.sdk.resources.challenges). Same as client.competition(id).detail().

def attachments( self, competition_id: str) -> ctfy.server.models.CompetitionAttachmentsResponse:
49    def attachments(self, competition_id: str) -> CompetitionAttachmentsResponse:
50        """The competition's rules / handbook / notice files, in display
51        order and every language's (``locale`` ``""`` is for everyone).
52        Read these before playing — they are the rules as the organiser
53        published them. Fetch one with :meth:`download_attachment`."""
54        resp = self._http.request("GET", f"/competitions/{competition_id}/attachments")
55        _raise_for_status(resp)
56        return CompetitionAttachmentsResponse.model_validate(resp.json())

The competition's rules / handbook / notice files, in display order and every language's (locale "" is for everyone). Read these before playing — they are the rules as the organiser published them. Fetch one with download_attachment().

def download_attachment(self, competition_id: str, attachment_id: str) -> bytes:
58    def download_attachment(self, competition_id: str, attachment_id: str) -> bytes:
59        """One competition file's raw bytes (id from :meth:`attachments`)."""
60        resp = self._http.request(
61            "GET", f"/competitions/{competition_id}/attachments/{attachment_id}"
62        )
63        _raise_for_status(resp)
64        return resp.content

One competition file's raw bytes (id from attachments()).

def start_virtual( self, competition_id: str) -> ctfy.server.models.CompetitionInfo:
66    def start_virtual(self, competition_id: str) -> CompetitionInfo:
67        """Replay a finished contest on your own clock.
68
69        Returns the *replay*, which is an ordinary competition you are
70        already registered in — so every other call takes its
71        ``.id`` unchanged: launch instances, submit answers, submit
72        patches, read its board.
73
74        **Idempotent.** Asking twice returns the replay you already
75        started rather than minting a second sitting, so a retry cannot
76        split one attempt across two competitions.
77
78        Refused (400) when a replay is not defined: the source has not
79        finished, has no window, is multi-session, never ran, or is
80        itself a replay. The error code says which.
81        """
82        resp = self._http.request("POST", f"/competitions/{competition_id}/virtual")
83        _raise_for_status(resp)
84        return CompetitionInfo.model_validate(resp.json())

Replay a finished contest on your own clock.

Returns the replay, which is an ordinary competition you are already registered in — so every other call takes its .id unchanged: launch instances, submit answers, submit patches, read its board.

Idempotent. Asking twice returns the replay you already started rather than minting a second sitting, so a retry cannot split one attempt across two competitions.

Refused (400) when a replay is not defined: the source has not finished, has no window, is multi-session, never ran, or is itself a replay. The error code says which.

def ghosts( self, competition_id: str) -> ctfy.server.models.VirtualGhostBoard:
 86    def ghosts(self, competition_id: str) -> VirtualGhostBoard:
 87        """The source contest's standings at the instant this replay has
 88        reached, with you merged in and ranked among them.
 89
 90        Takes the **replay's** id, not the source's. At T+40 minutes
 91        into your run you see the contest as it stood at T+40 minutes on
 92        the day — which is what makes a replay a race rather than an
 93        exercise, since a board holding only your own solves answers
 94        nothing.
 95
 96        Refused (400 ``not_a_virtual_sitting``) on an ordinary
 97        competition. A source that has since been deleted costs the
 98        ghosts and nothing else: the reply comes back with no items and
 99        the run itself keeps working.
100        """
101        resp = self._http.request("GET", f"/competitions/{competition_id}/ghosts")
102        _raise_for_status(resp)
103        return VirtualGhostBoard.model_validate(resp.json())

The source contest's standings at the instant this replay has reached, with you merged in and ranked among them.

Takes the replay's id, not the source's. At T+40 minutes into your run you see the contest as it stood at T+40 minutes on the day — which is what makes a replay a race rather than an exercise, since a board holding only your own solves answers nothing.

Refused (400 not_a_virtual_sitting) on an ordinary competition. A source that has since been deleted costs the ghosts and nothing else: the reply comes back with no items and the run itself keeps working.

def agent_actions(self, competition_id: str) -> ctfy.server.models.AgentActionsPage:
105    def agent_actions(self, competition_id: str) -> AgentActionsPage:
106        """One scrubbed line per agent saying what it is doing now.
107
108        :meth:`agent_pulse`'s sibling, and the whole difference is that
109        this one carries content. What makes that publishable is two
110        gates, neither of which stands in for the other:
111
112        * the line is a single tool call picked out of the newest
113          finished conversation, run through the platform's secret-shape
114          table and cut — so the round's minted flag and the untrusted
115          markup do not travel;
116        * a row exists only for an entrant whose **author** opted in
117          (``POST /eval/runs/{run_id}/wall``), which is the answer to
118          the one rule no scrubbing dissolves: the conversation belongs
119          to the person who ran it.
120
121        ⚠️ **An empty ``rows`` is four different things and the three
122        numbers beside it say which**: ``indexing: false`` is a
123        deployment with no transcript index, ``entrants: 0`` is nobody
124        having entered an agent, ``published: 0`` is nobody's author
125        having agreed, and all three set with no rows is everybody
126        having agreed and no call having finished yet.
127
128        ⚠️ **At most ``max_rows`` entrants are read**, because each one
129        costs an archived-transcript read; the reply publishes that
130        ceiling beside ``published`` so a truncated answer cannot read
131        as a complete one.
132
133        Refused (403 ``silent_standings``) on a silent competition, for
134        :meth:`agent_pulse`'s reason.
135
136        Wraps ``GET /competitions/{competition_id}/agent-actions``."""
137        resp = self._http.request("GET", f"/competitions/{competition_id}/agent-actions")
138        _raise_for_status(resp)
139        return AgentActionsPage.model_validate(resp.json())

One scrubbed line per agent saying what it is doing now.

agent_pulse()'s sibling, and the whole difference is that this one carries content. What makes that publishable is two gates, neither of which stands in for the other:

  • the line is a single tool call picked out of the newest finished conversation, run through the platform's secret-shape table and cut — so the round's minted flag and the untrusted markup do not travel;
  • a row exists only for an entrant whose author opted in (POST /eval/runs/{run_id}/wall), which is the answer to the one rule no scrubbing dissolves: the conversation belongs to the person who ran it.

⚠️ An empty rows is four different things and the three numbers beside it say which: indexing: false is a deployment with no transcript index, entrants: 0 is nobody having entered an agent, published: 0 is nobody's author having agreed, and all three set with no rows is everybody having agreed and no call having finished yet.

⚠️ At most max_rows entrants are read, because each one costs an archived-transcript read; the reply publishes that ceiling beside published so a truncated answer cannot read as a complete one.

Refused (403 silent_standings) on a silent competition, for agent_pulse()'s reason.

Wraps GET /competitions/{competition_id}/agent-actions.

def agent_pulse( self, competition_id: str, *, limit: int = 200) -> ctfy.server.models.AgentPulsePage:
141    def agent_pulse(self, competition_id: str, *, limit: int = 200) -> AgentPulsePage:
142        """What this event's agents have been asking a model, without it.
143
144        One lane per entrant, one beat per **finished** model call. The
145        gateway composes a record once, at completion — streamed
146        completions included — so no caller of this can see a
147        conversation in flight, whatever cadence they poll at.
148
149        ⚠️ **Carries no prompt and no completion, and cannot be made
150        to.** A relayed conversation holds the challenge's own minted
151        answer, which on an AWD gamebox is the round's flag, and its
152        prompt half is written by whoever is attacking the challenge —
153        so what travels is the shape of the traffic: how much each call
154        wrote, how long it took, and what went wrong. The bodies live
155        behind ``/admin/llm-calls/{id}`` and the run's own author-scoped
156        route, both of which rule on who may read them.
157
158        Empty means one of two opposite things and the reply says
159        which: ``indexing: false`` is a deployment that keeps no
160        transcript index at all.
161
162        Refused (403 ``silent_standings``) on a silent competition, like
163        the board — a lane per entrant saying who is still working is
164        the field's progress.
165        """
166        resp = self._http.request(
167            "GET",
168            f"/competitions/{competition_id}/agent-pulse",
169            params={"limit": limit},
170        )
171        _raise_for_status(resp)
172        return AgentPulsePage.model_validate(resp.json())

What this event's agents have been asking a model, without it.

One lane per entrant, one beat per finished model call. The gateway composes a record once, at completion — streamed completions included — so no caller of this can see a conversation in flight, whatever cadence they poll at.

⚠️ Carries no prompt and no completion, and cannot be made to. A relayed conversation holds the challenge's own minted answer, which on an AWD gamebox is the round's flag, and its prompt half is written by whoever is attacking the challenge — so what travels is the shape of the traffic: how much each call wrote, how long it took, and what went wrong. The bodies live behind /admin/llm-calls/{id} and the run's own author-scoped route, both of which rule on who may read them.

Empty means one of two opposite things and the reply says which: indexing: false is a deployment that keeps no transcript index at all.

Refused (403 silent_standings) on a silent competition, like the board — a lane per entrant saying who is still working is the field's progress.