A script is one of the two kinds of action an automation rule can take. When the rule fires, the script runs once, with the data of the event that triggered it, and calls platform functions directly. Scripts suit fixed, predictable steps — award an achievement, move a member into a group, set a medal — that should run the same way every time.
Scripts are written in Starlark, a small deterministic dialect of Python. Assignment, if/elif/else, for, lists, dicts, tuples, comprehensions and function definitions all work as they do in Python.
A script cannot open files, make network requests, or import anything. It can read the event data and call the functions on this page. Both are predeclared, so a script sees them without any import.
The script is checked for syntax when you save the rule; it is not run at that point.
Limit | Value |
|---|---|
Running time | 5 minutes |
Execution steps | 1,000,000 |
The deadline is what actually bounds a run. The step limit is a backstop against a runaway loop — walking a contest scoreboard costs a few hundred steps per row, so it sits far above what a real rule needs. Exceeding either limit stops the script, and the error is written to the rule's log.
Beyond the usual Starlark restrictions, several optional features are turned off:
Restriction | What to do instead |
|---|---|
No | Iterate with |
No recursion | A function cannot call itself. |
No | Use a list or a dict. |
No global rebinding | Keep counters and accumulators inside a function. |
if and for are allowed at the top level.
Global rebinding is the one that catches people out. A top-level name may be bound once; a second assignment to the same top-level name — including one inside the body of a top-level for — is a compile error, cannot reassign global …. A local variable inside a function has no such restriction:
def count_official(participants):
n = 0
for p in participants:
if not p.unofficial:
n = n + 1 # local, fine
return n
total, participants = eolymp_participants_list(contest_id = contest.id, size = 500)
printf("official entrants: %d", count_official(participants))The variables a script receives depend on the rule's trigger; Triggers and conditions lists which. Each is an object whose fields you read with a dot — submission.verdict, member.attributes.grade, contest.problem_count. Timestamps are RFC 3339 text.
All functions take keyword arguments. Arguments written as name= may be omitted. Reading functions return a value; writing functions return nothing unless noted.
eolymp_achievements_assign(member_id, achievement_id, qty=, inc_by=, reference=)
Grant an achievement. The count is set to 1 by default; qty sets it to an exact number and inc_by increases it. reference is a deduplication key — repeated runs carrying the same reference apply once.
eolymp_credits_grant(member_id, amount, reference, note=, expires_at=)
Grant credits to a member. amount must be positive. reference is required and makes the grant unique per member: the first grant returns True, and a second grant with the same reference returns False instead of paying out again. expires_at is an RFC 3339 timestamp.
eolymp_credits_list_grants(reference=, member_id=, size=, offset=) # → (total, [grant])
Lists grants already made, narrowed to one reference, one member, or both. A rule that pays out in rounds uses it to see who has been paid under a reference.
eolymp_emails_send(member_id, template, params=, locale=, reference=, email_type=)
Send a templated email to a member. template is the path of the email template and params is a dict of values passed to it. A non-empty reference makes the send permanently unique per member — that member is never emailed twice under the same reference — and an empty reference switches that off. email_type defaults to a general, quota-counted, unsubscribable type; the account and security type is reserved and is rejected.
eolymp_members_get(id) # → member eolymp_members_list(filters=, search=, size=, offset=, sort=, order=) # → (total, [member]) eolymp_members_set_attributes(member_id, attributes) eolymp_members_set_preferences(member_id, locale=, timezone=, runtime=) eolymp_members_add_group(member_id, group_id) eolymp_members_remove_group(member_id, group_id) eolymp_members_set_active(member_id, active)
attributes is a dict of attribute keys to text or whole numbers, merged onto the member's existing attributes; keys you do not list are untouched, and passing an empty dict does nothing. set_preferences changes only the fields you pass, and runtime is the member's default solution language.
eolymp_groups_get(id) # → group eolymp_groups_list(filters=, size=, offset=) # → (total, [group])
eolymp_participants_get(contest_id, participant_id) # → participant eolymp_participants_list(contest_id, filters=, search=, size=, offset=, sort=, order=) # → (total, [participant]) eolymp_participants_set_official(contest_id, participant_id, official) eolymp_participants_set_medal(contest_id, participant_id, medal) eolymp_participants_set_extra_time(contest_id, participant_id, seconds) eolymp_participants_set_active(contest_id, participant_id, active) eolymp_participants_disqualify(contest_id, participant_id, requalify=, reason=)
medal accepts "gold", "silver", "bronze", "honorable_mention" or "none"; anything else is an error rather than a silent no-medal. disqualify takes requalify = True to reverse itself, and reason is explanatory text stored with the disqualification.
eolymp_contests_get(id) # → contest eolymp_contests_list(filters=, search=, size=, offset=) # → (total, [contest]) eolymp_contests_get_submission(contest_id, id) # → contest submission eolymp_contests_list_submissions(contest_id, filters=, after=, size=, offset=) # → (total, [contest submission]) eolymp_contests_update(id, contest)
eolymp_contests_update takes a dict shaped like the contest eolymp_contests_get returns and writes exactly the keys it carries; read-only keys such as id, format and status are ignored, so a contest read back can be changed and handed straight to it. A nested block such as scoreboard_config is replaced as a whole, so carry forward the parts of it you are not changing.
A contest's own pages, such as its overview and rules, are read and written with their own functions:
eolymp_contests_list_pages(contest_id, filters=, locale=, size=, offset=) # → (total, [page]) eolymp_contests_get_page(contest_id, id, locale=) # → page eolymp_contests_create_page(contest_id, path, title, content, visibility=) # → page id eolymp_contests_update_page(contest_id, id, locale=, path=, title=, content=, visibility=)
content is Markdown. eolymp_contests_update_page writes the page itself when locale is empty and that language's translation otherwise.
eolymp_scoreboard_list_rows(contest_id, mode=, filters=, size=, offset=, sort=, order=) # → (total, [row]) eolymp_scoreboard_list_attributes(contest_id, size=, offset=) # → (total, [attribute]) eolymp_scoreboard_set_attribute(contest_id, attribute_key, label=, index=) eolymp_scoreboard_remove_attribute(contest_id, attribute_key)
Read a contest's standings. mode is "main" — the default, the final standing — or "frozen", "upsolve" or "virtual"; an unrecognised mode is an error. A participant's own score says nothing about placement, so a rule handing out medals or prizes works from here.
The attribute functions manage the scoreboard's attribute columns, each showing a member attribute such as a school or a grade. attribute_key is the member attribute's key, label the column header, and index the column's position. eolymp_scoreboard_set_attribute adds the column when the scoreboard does not show it yet and changes it otherwise; a label or index it is not given keeps its current value.
A combined scoreboard ranks members across several contests.
eolymp_scoreboards_list(filters=, search=, size=, offset=) # → (total, [scoreboard]) eolymp_scoreboards_get(id) # → scoreboard eolymp_scoreboards_create(name, slug=, visibility=, best_of=) # → scoreboard id eolymp_scoreboards_update(id, name=, slug=, visibility=, best_of=) eolymp_scoreboards_set_contest(scoreboard_id, contest_id, label=, index=) eolymp_scoreboards_remove_contest(scoreboard_id, contest_id) eolymp_scoreboards_set_attribute(scoreboard_id, attribute_key, label=, index=) eolymp_scoreboards_remove_attribute(scoreboard_id, attribute_key) eolymp_scoreboards_list_rows(scoreboard_id, mode=, filters=, size=, offset=, order=) # → (total, [combined row])
visibility is "public" or "private", and best_of counts only a member's best results toward the total. eolymp_scoreboards_update changes only the fields it is given.
eolymp_scoreboards_set_contest and eolymp_scoreboards_set_attribute add the contest or column when the scoreboard does not have it yet and change it otherwise, so a rule can call them on every run. A label or index they are not given keeps its current value, and a contest added without an index goes to the end. Removing a contest also removes the members who were on the scoreboard only through it.
mode is "main" — the default — "frozen" or "upsolve"; an unrecognised mode or visibility is an error.
eolymp_problems_get(id) # → problem eolymp_problems_list(filters=, search=, size=, offset=, sort=, order=) # → (total, [problem])
eolymp_submissions_get(id) # → submission eolymp_submissions_list(filters=, after=, size=, offset=) # → (total, [submission]) eolymp_submissions_aggregate(group_by=, filters=, metric=, range_start=, range_end=) # → [bucket]
group_by takes one or more dimensions — "SUBMITTED_AT", "VERDICT", "STATUS" — and an unknown name is an error. metric defaults to "COUNT". Without range_start and range_end only the last 30 days are counted. Each bucket has dimensions and count.
eolymp_pages_get(id, locale=) # → page eolymp_pages_list(filters=, locale=, size=, offset=) # → (total, [page])
eolymp_posts_list(filters=, search=, locale=, size=, offset=) # → (total, [post]) eolymp_posts_get(id, locale=) # → post eolymp_posts_create(content, type_id=, labels=, featured=) # → post id eolymp_posts_update(id, locale=, content=)
content is Markdown, and a post's title is its first heading. A post created by a rule has no author and stays a draft: publishing it is left to a person. eolymp_posts_update writes the post itself when locale is empty and that language's translation otherwise.
eolymp_newsletters_list(search=, locale=, size=, offset=) # → (total, [newsletter]) eolymp_newsletters_get(id, locale=) # → newsletter eolymp_newsletters_create(name, subject, content) # → newsletter id eolymp_newsletters_update(id, locale=, subject=, content=) eolymp_newsletters_import_recipients(id, filters=)
A rule can prepare a newsletter but not send it. content is Markdown. A created newsletter has no recipients; eolymp_newsletters_import_recipients adds every member matching filters — for example {"group_id": "<group-id>"} or {"inactive": False} — and the import completes in the background after the call returns. Sending or scheduling the newsletter is left to a person.
eolymp_rules_trigger(id, references=) # → log id
Starts another rule, as if it had been run by hand from its Trigger entry. references is a dict of the entities that rule's trigger needs, keyed as the trigger's references are: contest_id, member_id, problem_id and so on. In a dry run the call is recorded and not made.
Timestamps are RFC 3339 text and Starlark has no clock or duration type, so time arithmetic goes through these.
time_diff(a, b) # → a − b, in whole seconds; negative when a is earlier time_shift(timestamp, seconds) # → RFC 3339 string convert_time(timestamp, timezone) # → (local RFC 3339 string, UTC offset such as "+02:00") format_time(timestamp, layout) # → text
timezone is an IANA name, for example "Europe/Kyiv". An empty or malformed timestamp is an error, not a zero date.
format_time writes a timestamp out using a Go layout: "2006-01-02" for a date, "15:04" for a time of day, "Monday" for the day name, which is always in English. It uses the offset the timestamp already carries, so pass it the local value convert_time returned to get the date and time in that timezone.
render_template(template, data=) # → text
Renders a Go template, with {{ }} directives, against the values in data. It suits building an email body or a reply from values the script computed.
random_int(min, max) # inclusive at both ends random_choice(seq) random_sample(seq, k) # k distinct elements, shuffled random_shuffle(seq)
Every value a script sees is identical on every run, so a hand-rolled shuffle would produce a fixed permutation. These exist for raffles, prize draws and sampling. random_sample asked for more elements than the sequence holds returns all of them shuffled rather than failing, and passing a string where a list is expected is an error. The generator is not suitable for tokens or keys.
printf(format, *args) # %s text, %d whole numbers, %v any value print(*args)
Both append a message to the rule's log.
Every *_list function returns two values — the total number of matches and the current page:
total, members = eolymp_members_list(size = 5)
printf("space has %d members", total)
for m in members:
printf("- %s", m.display_name)size and offset page through results. search, sort and order ("asc" or "desc") are available where the underlying list supports them.
filters is a dict keyed by field name. Each field maps either to a bare value, meaning equality, or to a dict of operator to value:
{"verdict": "ACCEPTED"} # equals
{"level": {"gte": 5}} # greater than or equal
{"display_name": {"contains": "team"}} # substring
{"created_at": {"gt": "2026-01-01T00:00:00Z"}} # after a dateOperator | Meaning |
|---|---|
| equals (the default) |
| not equal |
| greater than |
| greater than or equal |
| less than |
| less than or equal |
| contains substring |
| starts with |
Values may be text, whole numbers, True or False, or an RFC 3339 timestamp string. An unknown field name or operator stops the script with an error.
Reading functions work normally. The first writing function is recorded in the log as Dry run, with the arguments it would have used, and then raises — which stops the script there. A dry run therefore shows what the first write would have been, not the whole sequence of writes a real run would perform.
The fields available on each object, whether it arrives as event data or comes back from a function.
Object | Fields |
|---|---|
submission |
|
contest submission |
|
member |
|
contest |
|
participant |
|
row |
|
attribute |
|
scoreboard |
|
combined row |
|
score |
|
ticket |
|
reply |
|
problem |
|
statement |
|
comment |
|
group |
|
grant |
|
page |
|
post |
|
newsletter |
|
schedule |
|
space |
|
A few of those fields need unpacking:
member attributes is an object keyed by attribute key, read as member.attributes.grade.
contest classification is an object with year, series, scale, difficulty, country, region and city.
contest rating_config has rated and max_rating; scoreboard_config has visibility, tie_breaker, freezing_time, unfreeze_delay, attempt_penalty, no_spoiler_ui, share_key and hide_disqualified; certification_config has enabled, affiliation and signers, a list of items with name and title. These are the blocks eolymp_contests_update replaces as a whole.
contest submission problem_id is the contest's problem, not the archive problem.
row is a scoreboard row, and its id is the participant id. It is also the row variable of the Participant finalized trigger.
scoreboard contests is a list in scoreboard order, each item with contest_id, index, label, name, status, starts_at and ends_at; attributes is a list of attribute columns.
combined row contests is a list, each item with contest_id, score, penalty and counted — whether that result is among the ones best_of counts. attributes is an object keyed by attribute key, read as row.attributes.school, holding the member's value for each attribute column.
score breakdown is a list, each item with problem_id, solved, score, percentage and attempts.
submission lang is the short language, such as cpp, while runtime is the full runtime id.
comment content is the comment as plain text, code blocks included. reply_to is the id of the comment it answers, empty for a top-level comment, and revision counts edits.
Grant an achievement for an accepted submission, once per problem — trigger Submission completed:
if submission.verdict == "ACCEPTED":
eolymp_achievements_assign(
member_id = submission.member_id,
achievement_id = "<achievement-id>",
reference = submission.problem_id,
)Award medals from the final standings — trigger Contest action, run after the contest ends and before finalizing it:
total, rows = eolymp_scoreboard_list_rows(contest_id = contest.id, mode = "main", size = 500)
def medal_for(rank):
if rank <= 1:
return "gold"
elif rank <= 3:
return "silver"
elif rank <= 6:
return "bronze"
return ""
for r in rows:
if r.unofficial or r.disqualified:
continue
m = medal_for(r.rank)
if m:
eolymp_participants_set_medal(contest_id = contest.id, participant_id = r.id, medal = m)
printf("%s → %s", r.id, m)Add every contest of a series to a season scoreboard, with a school column — trigger Contest action, run on any contest of the series:
total, contests = eolymp_contests_list(filters = {"series": contest.series}, size = 100)
for c in contests:
eolymp_scoreboards_set_contest(scoreboard_id = "<scoreboard-id>", contest_id = c.id, label = c.name)
eolymp_scoreboards_set_attribute(scoreboard_id = "<scoreboard-id>", attribute_key = "school", label = "School")Route new members into a group by attribute — trigger Member changed:
if member.attributes.grade > 9:
eolymp_members_add_group(member_id = member.id, group_id = "<senior-group-id>")
else:
eolymp_members_add_group(member_id = member.id, group_id = "<junior-group-id>")