Automation scripts
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.
The language
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.
Limits
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.
What the environment disables
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))Reading the event data
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.
Functions
All functions take keyword arguments. Arguments written as name= may be omitted. Reading functions return a value; writing functions return nothing unless noted.
Achievements
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.
Credits
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.
Emails
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.
Members
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.
Groups
eolymp_groups_get(id) # → group
eolymp_groups_list(filters=, size=, offset=) # → (total, [group])Participants
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.
Contests
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.
Scoreboard
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.
Combined scoreboards
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.
Problems
eolymp_problems_get(id) # → problem
eolymp_problems_list(filters=, search=, size=, offset=, sort=, order=) # → (total, [problem])Submissions
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.
Pages
eolymp_pages_get(id, locale=) # → page
eolymp_pages_list(filters=, locale=, size=, offset=) # → (total, [page])Posts
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.
Newsletters
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.
Rules
eolymp_rules_trigger(id, references=) # → log idStarts 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.
Time
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) # → texttimezone 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.
Templates
render_template(template, data=) # → textRenders 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
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.
Logging
printf(format, *args) # %s text, %d whole numbers, %v any value
print(*args)Both append a message to the rule's log.
Lists and filters
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.
What a script does in a dry run
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.
Objects
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
attributesis an object keyed by attribute key, read asmember.attributes.grade.contest
classificationis an object withyear,series,scale,difficulty,country,regionandcity.contest
rating_confighasratedandmax_rating;scoreboard_confighasvisibility,tie_breaker,freezing_time,unfreeze_delay,attempt_penalty,no_spoiler_ui,share_keyandhide_disqualified;certification_confighasenabled,affiliationandsigners, a list of items withnameandtitle. These are the blockseolymp_contests_updatereplaces as a whole.contest submission
problem_idis the contest's problem, not the archive problem.row is a scoreboard row, and its
idis the participant id. It is also therowvariable of the Participant finalized trigger.scoreboard
contestsis a list in scoreboard order, each item withcontest_id,index,label,name,status,starts_atandends_at;attributesis a list of attribute columns.combined row
contestsis a list, each item withcontest_id,score,penaltyandcounted— whether that result is among the onesbest_ofcounts.attributesis an object keyed by attribute key, read asrow.attributes.school, holding the member's value for each attribute column.score
breakdownis a list, each item withproblem_id,solved,score,percentageandattempts.submission
langis the short language, such ascpp, whileruntimeis the full runtime id.comment
contentis the comment as plain text, code blocks included.reply_tois the id of the comment it answers, empty for a top-level comment, andrevisioncounts edits.
Examples
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>")