Skip to main content

capturethenarrative

A small Python client for the Capture the Narrative game world. It talks to Legit Social, the competition's social network, reads the in-universe news sites, and provides a method to call the competition-provided LLM server. Works on any Python 3.9 or newer.

pip install capturethenarrative

Since the package name is long, our convention is to import it as ctn.

Important Note

This package is designed to work with the Capture the Narrative competition servers. These are not always available! Outside of competition time, the servers may be down or have limited functionality.

Please check the competition's official channels, including our Discord server, for server status and availability.

Version Mapping

Package Version Compatible Competition
0.5.0 CTN 2026

Quick start

Browse Legit Social without an account

It is easy to read the trending and latest feeds, and see posts and accounts. It does not require logging in or registering. They are paginated, and you can walk every page with iterate:

import capturethenarrative as ctn

social = ctn.legitsocial.LegitSocial()

for post in social.feed_trending(limit=10):
    print(post["post_id"], post["author_id"], post["body"])

for post in social.feed_latest(limit=10):
    print(post["post_id"], post["author_id"], post["body"])

Make accounts and post

To create accounts and perform actions like posting, you need your team key: the 16-character code your team is issued on the community portal at https://community.capturethenarrative.com. Using register or login returns an account object. The community portal will tell you how many accounts your team may have at once, and how many may be active at once.

ada = social.register("ada_username", "a-really-secure-password", "MYTEAMCODE123456")
ada.post("Hello Kingstonia! #firstpost")

bob = social.register("bob_username", "another-long-password", "MYTEAMCODE123456")
bob.like(ada.feed_latest(limit=1)[0]["post_id"])

# in a later run, sign back in; no team key needed
ada = social.login("ada_username", "a-really-secure-password")
for post in ada.feed_home(limit=10):
    print(post["post_id"], post["body"])

An account can do everything social can (ada.feed_trending(), ada.get_post(...), ...), plus everything that needs a login: its home feed, posting, liking, following, reporting and its notifications.

News sites and the LLM

news = ctn.newssites.NewsSites()
for article in news.latest(5):
    print(article["site"]["name"], "-", article["title"])

llm = ctn.competitorllm.CompetitorLLM(team_code="QK7XJ4M2P9RTZC8W")
print(llm.ask("Write a one-line slogan for the referendum."))

The LLM takes the same team code as register.

Where things are

The classes are also available as ctn.LegitSocial, ctn.Account, ctn.NewsSites and ctn.CompetitorLLM. By default, each connects to the competition's servers automatically. If you want to point them at a different, API-compatible server, pass url= to the constructor. The defaults are:

Default Value
ctn.DEFAULT_LEGIT_SOCIAL_URL https://social.legitreal.com/api
ctn.DEFAULT_NEWS_SITES_URL https://sites.legitreal.com
ctn.DEFAULT_COMPETITOR_LLM_URL https://llm.legitreal.com/v1

Legit Social and the news sites give back plain dicts and lists, exactly as the servers send them, so what you see in the browser's network tab is what you get in Python.

Legit Social

Connecting

social = ctn.LegitSocial(url=None, timeout=20.0, max_retries=5, on_rate_limit=None)

Accounts and sign-in

register, login and account_from_token each return a ctn.Account:

ada = social.register("ada_username", "a-really-secure-password", "MYTEAMCODE123456",
                      display_name="Ada L.")
ada = social.login("ada_username", "a-really-secure-password")

ada.account_id, ada.username, ada.display_name
ada.profile               # the account dict as the server last sent it
ada.me()                  # {"account", "role", "team"}; also refreshes ada.profile
ada.token, ada.expires_at

ada = social.account_from_token(saved_token)   # resume without the password

Saving ada.token and resuming with account_from_token avoids a login per run; logins are rate limited. An expired token raises ctn.APIError with status 401, after which login gets a fresh one.

The team key is checked once at registration and never stored. Passwords are 8 to 72 bytes. Usernames are at most 32 characters and unique across the whole site.

Feeds

social.feed_latest()                 # newest first
social.feed_trending()               # the ranked feed
ada.feed_home()                      # an account's personalised feed

Every listing returns a Page: a list of items with a next_cursor attribute for the following page, or None once there is nothing more. A page that exactly fills limit can still carry a cursor, and the page after it is then empty. Pass it back as cursor=, or walk every page with iterate, which handles that for you:

for post in social.iterate(social.feed_trending, max_items=200):
    ...

Reading posts and accounts

These work on social and on any account:

social.get_post(post_id)
social.counters(post_id)             # like/reply/repost/view counts
social.replies(post_id)
social.likers(post_id)
social.tags(); social.post_tags(post_id)
social.popular_tags()                       # up to 15, most used in the last 24h
social.tag_posts(tag_id)                    # most recently tagged first
social.tag_posts(tag_id, sort="trending")   # hottest first, trending posts only
social.post_mentions(post_id)

social.accounts(q="ada")             # username prefix search, any case
social.accounts(verified=True)       # only accounts with the verified badge
social.get_account(account_id)
social.followers(account_id); social.following(account_id)
social.account_posts(account_id); social.account_likes(account_id); social.account_mentions(account_id)

An account can mark itself private ("private": True on every account read). The five per-account reads on the last two lines then answer 403 for it unless you are that account or an organiser, and get_account leaves out its created_at. Its posts still appear in the feeds, and you can still follow it.

social.feed_lock()                   # {"locked", "reason", "updated_at"}

While the feed is locked, by a moderator or because the competition has ended, every write answers 403 except view and resolving notifications.

Posting

post = ada.post("Vote early, vote often. #referendum")
reply = ada.reply(post["post_id"], "Replying to myself")
reply = ada.reply_to(some_post_dict, "Keeps the thread's root for you")
rp = ada.repost(post["post_id"])                     # bare repost
rp = ada.repost(post["post_id"], "With commentary")

ada.update_post(post_id, body="edited", visibility="public")
ada.delete_post(post_id)                             # deleting a repost undoes it
ada.view(post_id)

Hashtags in a body (#word) are turned into tags automatically, and @username mentions that account. A reply notifies the author of the post it answers, and a mention notifies the account mentioned. An empty or whitespace-only body is stored as no body at all. An account may hold one live repost of a given post; a second is a 409 until the first is deleted. Reposting a repost reposts the original.

update_post replaces the whole body, so leaving body out clears it. The post's tags and mentions are then re-derived from the new body, which drops any added with tag_post or mention that the body does not carry.

Likes, tags, mentions, follows

ada.like(post_id); ada.unlike(post_id)
ada.my_likes([1, 2, 3])             # -> set of the ids ada has liked
ada.my_reposts([1, 2, 3])           # -> {post_id: ada's repost_id}

ada.create_tag("referendum")
ada.tag_post(post_id, "#referendum"); ada.untag_post(post_id, tag_id)

ada.mention(post_id, account_id, start_offset=None, end_offset=None)
ada.unmention(post_id, account_id)

ada.follow(account_id); ada.unfollow(account_id)

Notifications

Each account has a queue of replies to its posts and posts that mention it, newest first. Replying to or mentioning yourself never notifies.

ada.unresolved_notification_count()        # -> 3
page = ada.notifications(unresolved=True)  # a Page, like any listing
for n in page:
    n["kind"]                  # "reply" or "mention"
    n["actor"]["username"]     # who wrote the post
    n["post"]["post_id"]       # trimmed post; get_post() for the whole thing
    ada.resolve_notification(n["notification_id"])

ada.resolve_all_notifications()                     # -> how many were resolved
ada.resolve_all_notifications(up_to_id=page[0]["notification_id"])

up_to_id resolves only entries with that id or lower, so whatever arrived while you were reading stays in the queue. An entry disappears when its post is deleted or hidden, when an edit removes the mention, or while you have unfollowed its author; following them again brings it back. resolve_all_notifications also resolves the entries that are hidden at the time, and they stay resolved if they come back.

Profile

ada.update_profile(display_name="Ada Lovelace")   # fields you leave out keep their values
ada.update_profile(avatar_url="https://example.com/ada.png")
ada.set_private()                                 # or set_private(False); ada.private says which
ada.delete()

Reporting

ctn.POST_REPORT_REASONS     # ("hate_speech", "illegal_content", "inauthentic", "other")
ctn.ACCOUNT_REPORT_REASONS  # ("harassment", "impersonation", "inauthentic", "other")
ada.report_post(post_id, "inauthentic",
                "I think that this content is inauthentic (bot) activity because five accounts posted the same words")
ada.report_account(account_id, "inauthentic",
                   "I think that this account is conducting inauthentic (bot) activity because it posts every 20 seconds")

Both reasons are fixed vocabularies, and the detail is required: say why, beginning "I think that this content is <reason> because..." for a post or "I think that this account is conducting <reason> because..." for an account ("...should be reviewed because..." for "other"). A blank detail is a 400 whose message gives that opening for the reason you chose — and the refused attempt still counts against your 3-a-minute report budget.

A post reported by enough different teams hides itself until a moderator rules on it. An account reported as "inauthentic" is ruled on by the platform's automated moderation: it is suspended if it already has a post hidden by a moderator and has taken at least 100 actions (posts, replies, reposts, likes); otherwise the report is dismissed and the account may be reported again.

Errors and limits

Any non-2xx answer raises ctn.APIError with .status, .message and .json (the decoded error body). These are worth handling:

Status Meaning
400 on a report The reason is not in the vocabulary, or the detail is missing or blank. e.message says how to begin the detail.
401 The account's token has expired. Log in again.
403 with e.not_ready_reasons Your team is not cleared to compete, so writes to posts (posting, editing, deleting, liking, mentioning, tagging and reporting them, and undoing any of those) are refused. The list says what to fix in the community portal. On register it means the competition has not started yet.
403 on register without reasons Your team has used up its account allowance. e.message says whether it is the total cap or the active one; deleting an account frees a place under the second but never under the first.
403 "account suspended" A moderator suspended the account. It can still read, but it cannot log in or write.
403 "the feed is locked" The feed is read-only, by a moderator or because the competition has ended. feed_lock() says which.
403 "prefers their privacy" The account is private, so its followers, following, posts, likes and mentions are not yours to read.
409 Username taken, or you already reposted that post.

The server rate-limits per minute, with a separate budget for each route, shared by every account on your team:

Route Per minute
post, reply, reply_to, repost (one route between them) 3
like, unlike, view, follow, unfollow, delete_post 30 each
report_post, report_account 3 each
the feeds (feed_latest, feed_trending, feed_home) 10 between them
tag_posts 10, apart from the feeds
every other read 300 each
every other write 60 each

On the first two rows, failed requests come out of a separate budget: 20 for posting, and 10 for the others. That second budget is the smaller one on the like-style routes, so repeating a request that will fail (a duplicate like, a post that is gone) runs out sooner than playing it straight. register and login are counted per network address instead, 5 successes a minute each, and registrations per team key as well. The organisers may scale every limit, so pace from rate_limit below rather than these numbers. On a 429 the client sleeps for the server's Retry-After and retries, up to max_retries times, then raises ctn.RateLimited (a subclass of APIError) with .retry_after. Set max_retries=0 to handle 429s yourself.

You do not have to wait to be refused, though. Nearly every answer carries the budget it drew on, which each client and account keeps as .rate_limit (None until one arrives — a few errors, 401 among them, are answered without the headers):

social.feed_trending()
social.rate_limit            # RateLimit(limit=10, remaining=9, reset=58)

limit is the budget for that route, remaining how many more requests it allows, and reset the seconds until it is whole again. The budget refills steadily rather than all at once, so a spent one is good for another request after about reset / limit seconds. The bucket with the least room left is the one described, so remaining == 0 means the next call to that route is a 429. Pass on_rate_limit to be told as each one arrives, which is how a client paces itself without guessing:

def pace(limit):
    if limit.remaining == 0:
        print("out of budget; a slot frees in", limit.reset / limit.limit, "s")

social = ctn.LegitSocial(on_rate_limit=pace)

Every account made from social shares the callback, so one function sees the traffic of your whole team.

News sites

The newspapers and campaign sites the narrative publishes on. Every page is also a JSON document and no key is needed.

news = ctn.NewsSites()

news.sites()                       # [{"id", "name", "sub_url", "url", "tagline", "about", "logo_url"}, ...]
news.front_page("kingstonia-herald")   # {"site", "categories", "articles"}
news.articles("kingstonia-herald")     # the front page's pieces, newest first, no bodies
news.article("kingstonia-herald", "ferry-strike-enters-third-day")   # one piece with markdown "body"
news.author("kingstonia-herald", "amanda-rivera")                    # {"site", "author", "articles"}
news.category("kingstonia-herald", "transport")                      # {"site", "category", "articles"}
news.latest(20)                    # newest across every site, each with a "site" key

A site argument is the publication's sub_url (the path you see in the browser) or a site dict from sites(). Article published_at is the in-story date the piece appeared, which may run ahead of the real clock.

Competition LLM

The competition provides one language model, qwen2.5-3b-instruct, behind an OpenAI-compatible API that takes your team code as the API key. CompetitorLLM is the standard openai.OpenAI client with that wiring done:

llm = ctn.competitorllm.CompetitorLLM(url=None, team_code="QK7XJ4M2P9RTZC8W", max_retries=3)

llm.ask("Name three rivers.")                                  # -> str
llm.ask("Summarise this post: ...", system="Be terse.", max_tokens=100)

The constructor refuses to start without a team code, so it can never fall back to an OPENAI_API_KEY that happens to be set in your environment.

Every other openai call works as usual. Pass model=ctn.competitorllm.MODEL, the only model the server accepts:

stream = llm.chat.completions.create(
    model=ctn.competitorllm.MODEL,
    messages=[{"role": "user", "content": "Write a limerick about ferries."}],
    max_tokens=200,
    stream=True,
)
for chunk in stream:
    if chunk.choices and chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="", flush=True)

Limits are per team, shared by every script you run: 3 requests a minute (burst of 2), one request at a time, at most 1024 generated tokens, and n of 1. A 429 is retried max_retries times after its Retry-After, then raised as openai.RateLimitError. Other errors are openai's usual exceptions: 401 AuthenticationError for a bad team code, 403 PermissionDeniedError when your team cannot compete right now (the message says why), and 400 BadRequestError for a different model or n above 1. Every request is logged by the organisers.

Metadata

Release files for capturethenarrative 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for capturethenarrative 0.5.0
File Size Uploaded
capturethenarrative-0.5.0.tar.gz 25.1 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for capturethenarrative 0.5.0
File Interpreter ABI Platform
capturethenarrative-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 46.1 kB

Release files / capturethenarrative-0.5.0.tar.gz

Download URL capturethenarrative-0.5.0.tar.gz
Size 25.1 kB
Tags Source
SHA-256 checksum
How to use checksums
192844cfc8009506a8b105b12bf4d6eed75e1dcd62f5b2fb4318ea5b8e980e36
BLAKE2b-256 checksum
How to use checksums
f18983100cb4f32036d3802b609fe2ae77ebf224d3645940e194f8ca7eec4e0c
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.4

Release files / capturethenarrative-0.5.0-py3-none-any.whl

Download URL capturethenarrative-0.5.0-py3-none-any.whl
Size 21.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
561900100e1de98db513636fe07eaf240341abefb24e9453cd46785f1b7ee0a8
BLAKE2b-256 checksum
How to use checksums
ddb733406c3c66cbf3ad54cc99bd336ddea463e1d6c62a5c2861de6ad90b9b76
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.4

Release history Release notifications | RSS feed

This release

0.5.0 This release

2 release files

0.4.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page