This is the complete reference for StayPresent: every parameter, every behavior, and every edge case. For a quicker overview, see README.md.
- Requirements
- Getting Started
- Web Server Configuration (
staypresent.web) - Process Execution (
staypresent.run) - Self-Ping / Keep-Warm (
staypresent.ping/staypresent.cron) - Built-in Defaults (
/,/health,/status) - The Built-in Markdown Renderer
- API Reference
- Logging
- Deployment Notes
- Boot-Level Restart (
staypresent.restart) - Frequently Asked Questions
- Python 3.8+
- Flask
- waitress (optional, but highly recommended for production)
Every feature described in this document — including full Markdown rendering, tables, theming, and page metadata — works with these requirements alone. No additional package is needed for Markdown support; StayPresent ships its own renderer.
Install the package via standard package managers.
Standard Installation:
pip install staypresent
Production Installation (Recommended):
To suppress development server warnings and utilize a production-grade WSGI server, install the prod extra. This automatically provisions waitress.
pip install staypresent[prod]
import staypresent
staypresent.run("bot.py")This is enough to:
- Start a background web server on
0.0.0.0:8080(usingwaitressif installed, otherwise Flask's dev server with a one-time warning). - Serve
{"message": "I'm Present"}as JSON at/,{"status": "ok"}at/health, and a built-in status page at/status. - Launch
bot.pyas a subprocess and keep it running, automatically restarting it up to 5 times if it crashes. - Shut everything down cleanly on
Ctrl+C/SIGTERM.
The staypresent.web module dictates the HTTP response(s) served by the background web server. Every function accepts an optional path argument (default "/"), so you can host multiple independent responses at once — see Section 3.7. If left unconfigured, "/" defaults to a JSON response ({"message": "I'm Present"}), "/health" defaults to {"status": "ok"}, and "/status" defaults to a built-in status page (see Section 3.5) — all three are implicit defaults, active until you register something else at that path.
Basic Usage Example:
import staypresent
staypresent.web.json({
"status": "running"
})
staypresent.run("bot.py")Returns a text/plain response.
import staypresent
staypresent.web.text("Service Operational")Signature: staypresent.web.text(message: str, path: str = "/", services_name: str = None, services_description: str = None, status: bool = False) -> None
services_name/services_description and status are shared by every staypresent.web.*() function — see Section 3.6 and Section 3.5 respectively.
Returns an application/json response. The dictionary/list you pass is deep-copied at call time, so mutating the original object afterwards does not change the live response — call json() again to update it.
import staypresent
staypresent.web.json({
"status": "online",
"version": "1.2.0"
})Signature: staypresent.web.json(data: Any, path: str = "/", services_name: str = None, services_description: str = None, status: bool = False) -> None
Note:
datais validated for JSON-serializability immediately, at call time — passing something that can't be serialized (e.g. a custom object with no JSON representation) raisesTypeErrorright away, rather than only surfacing as a 500 error on the first incoming request.
Reads and serves an HTML file on every request. This allows for dynamic, on-disk updates without restarting the Python process.
import staypresent
staypresent.web.html("templates/index.html")Signature: staypresent.web.html(file_path: str, path: str = "/", exclude: list = None, services_name: str = None, services_description: str = None, status: bool = False) -> None
Note: Any static assets (CSS, JS, images) located in the same directory as the target HTML file are automatically served. Path traversal is strictly prohibited by internal security checks (
send_from_directoryrefuses to serve anything that would escape the target directory).
⚠️ Security — this covers the whole directory, not just referenced assets. "Automatically served" means every file insidefile_path's directory becomes reachable to anyone who requests it by name, not only the specific CSS/JS/image files actually linked from the page. If a.env, a bot's own source file, or a.git/directory happens to sit in that same directory, it's downloadable the same way. This is intentional — it's what makes relative asset links work without extra configuration — but the scope is easy to miss, so keepfile_pathin a directory containing only files you're comfortable serving publicly, or useexclude(below) to block specific files outright. StayPresent logs a one-timeWARNINGper directory (see Section 9) the first timehtml()/markdown()exposes it, as a reminder.
For any path other than the default "/", a bare request to that path is redirected (HTTP 308) to a trailing-slash version — see Section 3.7 for why this matters.
Blocking specific files with exclude:
staypresent.web.html("template/index.html", exclude=[".env", ".git", "*.py", "secrets.json"])exclude is a list of filename/extension/glob patterns that are never served out of file_path's directory, regardless of any other route also serving that same directory — a request for a matching file gets a plain 404, the same as if it didn't exist. Matching rules:
- A plain extension like
".env"is shorthand for the glob"*.env"— anything ending in.envis excluded. - An entry already containing a wildcard (
*,?,[seq]) is used exactly as given. - Anything else (e.g.
"secrets.json") matches that exact filename. - Matching is case-insensitive and applies at any depth under the directory, not just directly inside it — excluding
".git"also blockssub/.git/config, not only a top-level file literally named.git. - An entry containing
/(e.g."private/secret.txt") is matched against the whole relative path instead of a single filename, at any depth (so it also matchesa/private/secret.txt).
If two or more html()/markdown() routes serve out of the same directory with different exclude lists, every request resolving to that directory is checked against the union of all of them — an exclusion set by any one route applies no matter which route's registration actually handles a given request.
Reads a .md file and renders it to clean, GitHub-styled HTML on every request — the same on-disk-editable model as html(). Rendering is handled entirely by StayPresent's own built-in renderer (see Section 7); no external package is required.
import staypresent
staypresent.web.markdown("CHANGELOG.md")
⚠️ Security note:markdown()serves neighboring static assets the exact same wayhtml()does — see the callout in Section 3.3. Everything infile_path's directory becomes servable, not just files referenced from the rendered page, unless excluded viaexclude(below).
Signature:
staypresent.web.markdown(
file_path: str,
path: str = "/",
mode: str = "auto",
favicon: str = None,
title: str = None,
description: str = None,
exclude: list = None,
services_name: str = None,
services_description: str = None,
status: bool = False,
) -> None| Parameter | Type | Default | Description |
|---|---|---|---|
file_path |
str |
Required | Path to the .md file to serve. Must exist at call time (checked immediately — raises FileNotFoundError otherwise). |
path |
str |
"/" |
Route to host this page on. See Section 3.7. |
mode |
str |
"auto" |
Color scheme for the rendered page. One of "light", "dark", or "auto". Case-insensitive and whitespace-tolerant (" Dark " is accepted); None is treated the same as the default, "auto". Anything else raises ValueError. |
favicon |
str or None |
None |
Adds a <link rel="icon"> tag. A direct URL (http://, https://, or a protocol-relative //...) is used as-is. Any other value is treated as a relative path next to file_path, resolved the same way neighboring static assets already are — and, like file_path itself, its existence is checked immediately at call time, raising FileNotFoundError if it's missing, rather than silently 404ing only once a browser actually requests it. Must be a str or None — anything else raises TypeError. Also raises ValueError if exclude (below) would itself block the favicon from ever being served. |
title |
str or None |
None |
Sets <title> and an Open Graph og:title meta tag. Falls back to the Markdown file's own filename (e.g. "guide.md") when omitted. Must be a str or None. |
description |
str or None |
None |
Adds a <meta name="description"> tag and an og:description Open Graph tag — useful for link-preview cards on social platforms/chat apps. Must be a str or None. |
exclude |
list or None |
None |
Filename/extension/glob patterns to block from file_path's directory. See Section 3.3 for the matching rules — they're identical to html()'s. |
services_name |
str or None |
None |
Display name for this process's row on the status page. See Section 3.6. Note this is distinct from description above — that one's the page's own <meta name="description">, this one's the status page's. |
services_description |
str or None |
None |
Description shown under that same status-page row. See Section 3.6. |
status |
bool |
False |
Whether this route gets its own row on the status page. See Section 3.5. |
How mode affects the page:
mode="auto"(default): the page includes<meta name="color-scheme" content="light dark">and no forced theme attribute — the browser/OS decides viaprefers-color-scheme, and the visitor's own preference wins.mode="light"/mode="dark": the page includes<meta name="color-scheme" content="light">(or"dark") and adata-theme="light"/data-theme="dark"attribute on the rendered<article>, forcing that theme for every visitor regardless of their own OS/browser setting.
How favicon resolution works:
staypresent.web.markdown("docs/guide.md", path="/docs", favicon="favicon.png")Here, favicon.png must exist in the same directory as docs/guide.md — checked immediately when markdown() is called, so a typo surfaces right away instead of only once a visitor's browser requests the icon. Once the page is served (at /docs/, after the trailing-slash redirect described in 3.7), the browser requests /docs/favicon.png, which StayPresent resolves via the same static-asset lookup used for CSS/JS/images referenced from HTML/Markdown content.
staypresent.web.markdown("docs/guide.md", favicon="https://example.com/icon.ico")Here, the URL is inserted into the page as-is — no local file is needed, and no static-asset lookup happens.
Full example:
import staypresent
staypresent.web.markdown(
"docs/guide.md",
path="/docs",
mode="dark",
favicon="favicon.png",
title="Project Docs",
description="Everything you need to get started.",
)
staypresent.run("bot.py")staypresent.web.status() serves a built-in, auto-updating status page: a service list, rolling uptime percentages, and an incident history, all derived automatically from what StayPresent already observes about each bot (launches, crashes, restarts, hangs, permanent failures). Nothing on the page is fabricated — a metric StayPresent doesn't actually have (e.g. response time for a background bot that isn't itself an HTTP endpoint) is simply left off.
You don't have to call this at all: a status page is served at /status by default the moment you call staypresent.run(), the same way /health and / already have built-in defaults (see Section 6). Call status() yourself to customize the title, theme, footer, or admin key, or to move it to a different path.
import staypresent
staypresent.web.status(
title="Groundflare Bot Status",
copyright="Groundflare Inc.",
footer_links=[{"label": "Contact Support", "url": "https://support.groundflare/support"}],
api_key="a-long-random-secret",
mode="dark",
timezone="Asia/Kolkata", # or the shorthand alias: timezone="IST" - default is "auto"
time_format="24h", # default is "12h"
)
staypresent.run("bot.py")Signature:
staypresent.web.status(
title: str = None,
path: str = "/status",
copyright: str = None,
footer_links: list = None,
api_key: str = None,
trust_proxy_headers: bool = False,
mode: str = "auto",
favicon: str = None,
description: str = None,
poll_seconds: float = 15,
timezone: str = "auto",
time_format: str = "12h",
status: bool = False,
) -> None| Parameter | Type | Default | Description |
|---|---|---|---|
title |
str or None |
None |
Page title/heading. Defaults to "Service Status". |
path |
str |
"/status" |
Route to host the page on. Same trailing-slash redirect behavior as html()/markdown() for non-root paths, since the page's CSS/JS/data endpoint are served relative to it. |
copyright |
str or None |
None |
Footer copyright line (e.g. "Groundflare Inc." renders "© <year> Groundflare Inc."). Omitted entirely when not given; this is separate from, and doesn't replace, the "Powered by StayPresent" credit, which always appears. |
footer_links |
list or None |
None |
A list of {"label": ..., "url": ...} dicts shown as a row of footer links. Omitted entirely when not given. |
api_key |
str or None |
None |
Secret enabling the admin login (see below). Leave unset and StayPresent generates a random key for this process's lifetime and logs it at INFO, so the admin view works with zero setup; pass a fixed string for a key that survives a restart, or api_key="" to disable the admin view for this page entirely. |
trust_proxy_headers |
bool |
False |
If True, admin-login rate limiting uses the client IP from X-Forwarded-For instead of the raw TCP connection. Only enable this if a reverse proxy you control is guaranteed to sit in front of every request and sets/overwrites that header itself — otherwise a client can forge it to pin a lockout on someone else's IP. |
mode |
str |
"auto" |
Color scheme — "light", "dark", or "auto" (follows the visitor's OS/browser preference). Same meaning as markdown()'s mode. |
favicon |
str or None |
None |
Optional favicon. A direct URL (http://, https://, //, or data:) is used as-is; anything else is used as a plain href, so it must already be servable from somewhere (e.g. a path registered with html()) — unlike markdown(), there's no local file directory to resolve a relative filename against. Left unset, the page shows a small dot favicon that updates live to reflect overall status (green/amber/red); setting favicon disables that live color-swapping. |
description |
str or None |
None |
<meta name="description">/Open Graph description. Defaults to a generic "<title> - live status, powered by StayPresent." when omitted. |
poll_seconds |
float |
15 |
How often (seconds) an open status page polls its own live data endpoint. Must be positive. Lower values mean fresher data at the cost of more requests from every open tab. |
timezone |
str |
"auto" |
Timezone every date/time on the page is shown in — incident timestamps, an admin's log-line timestamps, the "Last updated" indicator, and the copyright line's year. Defaults to "auto": each visitor's own browser/OS timezone, detected client-side, so two people looking at the same page from different countries each see times in their own local zone. Set an explicit zone instead for a fixed, same-for-everyone timezone — a standard IANA zone key (e.g. "Asia/Kolkata", "America/New_York") or a short, unambiguous alias — see below. A fixed zone (anything other than "auto"/"UTC") requires Python 3.9+'s stdlib zoneinfo module (or the backports.zoneinfo package on older Pythons); "auto" and "UTC" never need it, since the server's own rendering (e.g. for a non-browser API consumer of the JSON data endpoint) always falls back to UTC either way — a real "auto" resolution only ever happens in the visitor's own browser. |
time_format |
str |
"12h" |
Clock style for every time shown on the page — "12h" (e.g. "2:30 PM", the default) or "24h" (e.g. "14:30"). Applies everywhere timezone above does. |
status |
bool |
False |
Whether the status page itself gets a row on itself (labeled "Status - Web"). Doesn't affect whether the page works or is reachable — only whether it lists itself. |
Timezone aliases:
A handful of common, low-ambiguity abbreviations are accepted as shorthand for timezone: "IST" → "Asia/Kolkata", "GMT" → "UTC", "EST"/"EDT" → "America/New_York", "CST"/"CDT" → "America/Chicago", "MST"/"MDT" → "America/Denver", "PST"/"PDT" → "America/Los_Angeles", "BST" → "Europe/London", "CET" → "Europe/Paris", "JST" → "Asia/Tokyo", "AEST" → "Australia/Sydney". This is intentionally a short list — several common abbreviations genuinely mean different zones in different regions ("IST" alone could mean India, Israel, or Irish Standard Time; "CST" could mean US Central, China, or Cuba Standard Time), so StayPresent picks one specific, common meaning for each entry above rather than guessing at the rest. If the region you meant isn't one of these, pass the real IANA zone key instead (e.g. "America/Chicago", "Asia/Shanghai") — that's always accepted unmodified. An unrecognized alias or an invalid IANA key raises ValueError at registration time, with the full alias list and a pointer to the IANA zone database in the error message.
How timezone="auto" actually works: the server has no way to know a visitor's timezone before their browser has loaded the page and run its JS — a fixed backend process can't resolve "auto" into a real zone for a JSON response by itself. So timezone="auto" (and the "UTC" default it falls back to server-side) works in two layers: the JSON data endpoint always includes both a raw Unix-epoch timestamp and a server-rendered fallback string (in UTC, for "auto") for every incident/log line — useful as-is for a script or curl hitting that endpoint directly — while the status page's own bundled JS re-renders every one of those raw timestamps in the browser's actual local timezone (via Intl.DateTimeFormat().resolvedOptions().timeZone) and chosen time_format before showing them. In other words: humans looking at the page in a browser get true per-visitor personalization; anything else consuming the JSON directly gets a sane, fixed UTC default.
Public view vs. admin view:
The page itself, its CSS/JS, and its live data endpoint are always visible to everyone, regardless of api_key — the same way a real status page's overall up/down state is public by design. What api_key gates is extra technical incident detail: the actual process exit code, and a recent stdout/stderr tail per service. A plain visitor still sees that an incident happened and its current status (e.g. "Service disruption detected — automatically recovering"), just described without exit codes or logs. An admin unlocks that detail via a small "Admin" link in the page's footer, entering the key either through the login form (which then sends it as an X-API-Key header) or by appending ?key=... to the page URL.
Failed admin-login attempts are rate-limited per client IP: after 5 wrong keys within a 15-minute window, further attempts from that IP are rejected outright until the window passes. The key comparison itself uses a constant-time check, so a wrong guess's rejection speed can't be used to infer how many leading characters were correct.
What the page shows:
- Service rows — one per bot given to
staypresent.run()(named after its own file/module by default; see Section 3.6 to rename it), plus one perstaypresent.web.*()route registered withstatus=True. Each row shows live status (operational/degraded/offline), uptime over 24h/7d/30d and lifetime, and a restart count. - Uptime chart — a per-service uptime percentage chart, rendered client-side via Chart.js (loaded from a CDN —
cdn.jsdelivr.net— so the status page itself requires outbound access to that host to render the chart; every other part of the page works with no external dependency). - Incident history — a merged, most-recent-first feed of crashes, hangs, permanent failures, and recoveries across every visible service. Shows the most recent 20 by default; append
?history=1to the page's data endpoint to fetch up to 200.
Bots hidden via staypresent.run(status=False) (or a bots[i]['status'] override) don't get a row and don't contribute to the overall status or incident feed — they're still fully supervised, just not shown.
By default, a bot's status-page row is named after its own file/module path, and the web server's row is expanded into one entry per staypresent.web.*() route registered with status=True (see Section 3.5). services_name/services_description override that default display name and add a description, shown under the name on the status page.
These can be set from whichever call is most convenient — the last one made for a given target always wins:
-
staypresent.run(services_name=..., services_description=...)— only valid with zero or one bot configured process-wide (raisesTypeErrorwith more than one, since a plain string can't unambiguously name one of several bots). With no bot at all (web_server=True, nobot_file/bot_module/bots), this renames the web server's own row instead. -
A
bots[i]entry's own"services_name"/"services_description"keys — the way to tag bots individually when there's more than one:staypresent.run(bots=[ {"file": "telegram_bot.py", "services_name": "Telegram Bot"}, {"file": "discord_bot.py", "services_name": "Discord Bot", "services_description": "Community server"}, ])
-
Any
staypresent.web.text()/json()/html()/markdown()call's ownservices_name/services_description— applies process-wide (there's no per-page variant), the same targetrun()'s ownservices_namewould resolve to:staypresent.web.markdown( "CHANGELOG.md", services_name="Telegram Bot", services_description="Customer chat support", )
A key that doesn't match any actual bot still gets its own row on the status page — useful for tracking an external dependency (e.g. a database, a third-party API) that StayPresent itself doesn't supervise. That row is always shown as "operational," since StayPresent has no live signal for something outside its own supervision.
text(), json(), html(), markdown(), and status() all accept a path keyword argument. This lets a single StayPresent instance host several independent responses at once — useful for giving each of multiple bots (see Section 4.1) its own status endpoint.
import staypresent
staypresent.web.json({"status": "online"}, path="/")
staypresent.web.text("bot #2 is alive", path="/bot2")
staypresent.web.html("dashboard.html", path="/dashboard")
staypresent.web.markdown("CHANGELOG.md", path="/changelog")Path rules:
- A path is normalized: surrounding whitespace is stripped (so
" /status"or"/status "— easy to introduce via copy-paste or an f-string — are treated the same as"/status"instead of silently registering an unreachable route), it must start with/, repeated slashes are collapsed, and a trailing slash is stripped ("/status/"and"/status"refer to the same route). - Paths must not contain
?or#— StayPresent raisesValueErrorif they do, since those characters belong in a query string/fragment, not a route. pathmust be a non-emptystr— any other type raisesTypeError, an empty/whitespace-only string raisesValueError.
Static asset resolution and trailing slashes:
- For
html()/markdown()at any path other than"/", a bare request to that path (e.g./dashboard) is automatically redirected (HTTP 308) to a trailing-slash version (/dashboard/). This is required for relative asset links inside the file (href="style.css",src="images/logo.png", a relativefavicon) to resolve against that file's own directory rather than its parent.text()/json()responses don't need this, since they have no static assets to resolve. - Static asset lookups use the longest matching path prefix across every registered
html()/markdown()route, so nested/overlapping mounts (e.g."/"and"/dashboard"both hosting HTML/Markdown) resolve unambiguously to the right directory — a request for/dashboard/style.cssis served fromdashboard.html's directory, not the root page's. - Asset lookups use
send_from_directoryinternally, which refuses to serve any path that would escape the target directory (no path traversal via../, absolute paths, etc.).
Managing registered paths:
staypresent.web.paths() # -> ['/', '/bot2', '/changelog', '/dashboard', '/health', '/status']
staypresent.web.get_all() # -> {'/': {'type': 'json', 'value': {...}}, '/bot2': {...}, ...}
staypresent.web.get("/bot2") # -> {'type': 'text', 'value': 'bot #2 is alive'}
staypresent.web.remove("/bot2") # -> True (was registered) / False (wasn't)paths() and get_all() include StayPresent's own implicit defaults (/, /health, /status) whenever they haven't been overridden by an explicit registration — see Section 6.
Registering the same path more than once:
text()/json()/html()/markdown()/status() always overwrite whatever was previously registered at their path — there's no error or refusal for reusing a path, since re-registering the same path is exactly how you update a live response (e.g. calling json() again with a fresh payload to refresh a status endpoint). That case stays completely silent.
What StayPresent does watch for is the response type at a path changing — e.g. a path held a "json" response and a later call registers "text" there instead. That's a much stronger signal that two different call sites (two different bots, or two unrelated parts of the same script) didn't realize they were both claiming the same path, and only the most recent one will ever be served. In that case, a WARNING-level log line is emitted through the "staypresent" logger (see Section 9) naming the path and both response types involved, so the collision is visible instead of silently serving the wrong thing. Nothing is blocked either way — the newest registration always wins — this only affects what gets logged.
To retrieve the currently configured response payload for debugging or testing:
current_state = staypresent.web.get()
# Returns: {"type": "json", "value": {"status": "online", ...}}get() defaults to path="/"; pass a different path to inspect any other registered route, including /status and /health even when nothing has been explicitly registered there — get() surfaces StayPresent's own implicit defaults the same way get_all()/paths() do (see Section 6). For a Markdown entry, the returned dict also includes mode, favicon, title, and description keys; for a status-page entry, it includes title, copyright, footer_links, api_key, mode, and the rest of status()'s own fields.
Both get() and get_all() return an independent deep copy of the state — mutating the returned dict (including a nested "value" dict/list for a "json" entry) afterwards has no effect on what StayPresent actually serves. Call json()/text()/html()/markdown()/status() again to change the live response instead.
The run function is the primary entry point. It spawns the web server and concurrently executes one or more target Python scripts ("bots").
import staypresent
staypresent.run(
"bot.py",
host="0.0.0.0",
port=8080,
threads=8
)bot_file also accepts a list of paths, launching and independently supervising each one:
import staypresent
staypresent.run(["telegram_bot.py", "discord_bot.py"])bot_args/env, if given alongside a list, are applied identically to every bot in it. For per-bot arguments or environment variables, use bots instead — a list of dicts, one per bot:
import staypresent
staypresent.run(bots=[
{"file": "telegram_bot.py", "args": ["--verbose"]},
{"file": "discord_bot.py", "env": {"SHARD": "0"}},
{"file": "worker.py"},
])bots is mutually exclusive with bot_file/bot_args/env — a TypeError is raised if both styles are mixed. Each entry in bots requires a "file" key; "args" (list) and "env" (dict) are optional per entry.
With multiple bots:
- Every bot file is validated to exist before the server starts (a missing file raises
FileNotFoundErrorimmediately, before anything is launched). - If launching the very first attempt of any bot fails (e.g. bad interpreter, out of file descriptors), any bots already started are terminated first, and the failure is raised immediately to the caller — no leaked processes.
- Each bot gets its own restart counter and is monitored on its own thread — a crash (and subsequent restart) in one bot has no effect on the others.
staypresent.run()waits for every bot to finish (or fail permanently) before returning or exiting the process.Ctrl+C/SIGTERMterminates the web server and all bot processes.- Bots are tracked internally by their position in the list/
botsarray, not by filename — two bots can share the exact same filename (e.g.shard_a/bot.pyandshard_b/bot.py) with no functional conflict whatsoever; each still gets its own process, its own restart counter, and its own independent supervision.
Log labels for bots with the same filename: every log line about a bot (crash, restart, giving up) identifies it with a label like bot[0] 'worker.py'. Normally that's just the filename. But if two or more bots in the same run() call share a filename, using just the filename there would make otherwise-distinct bots indistinguishable in the logs — two lines both reading bot[0] 'bot.py' crashed / bot[1] 'bot.py' crashed force you to cross-reference your own list order to know which is which. StayPresent detects this automatically: whenever a filename collides with another bot's in the same run, every bot sharing that filename is labeled with its full file path instead (e.g. bot[0] 'shard_a/bot.py' / bot[1] 'shard_b/bot.py'), so the logs stay unambiguous. Bots with a filename that doesn't collide with anything keep the shorter, plain filename label.
bot_file runs a bot the same way python <file> would from the command line — which breaks for a bot that's actually a module inside a package relying on package-relative imports (from . import something):
ImportError: attempted relative import with no known parent package
bot_module runs it the way python -m <module> would instead — exactly as if you'd typed that yourself from the project root:
import staypresent
staypresent.run(bot_module="mypkg.bot")It mirrors bot_file everywhere else:
- A single string or a list of strings, for one or several module-based bots.
bot_args/envapply identically to every module in the list, same as withbot_file.- In
bots, use"module"instead of"file"per entry — and you can freely mix file-based and module-based bots in the samebotslist:
import staypresent
staypresent.run(bots=[
{"file": "telegram_bot.py", "args": ["--verbose"]},
{"module": "discord_bot.worker", "env": {"SHARD": "0"}},
])Rules and differences from bot_file:
bot_moduleis mutually exclusive withbot_fileat the top level (TypeErrorif both are given); inbots, each entry must set exactly one of"file"/"module"(TypeErrorif both or neither are set).- Unlike a file path, a module path is not checked for existence before the server starts. Verifying a module is importable safely requires actually importing it (running its parent packages'
__init__.py), whichrun()deliberately avoids as a side effect in the orchestrating process. A missing or misspelled module therefore isn't caught up front the way a missing file is — instead, that bot process starts, Python itself reportsNo module named '...', and it exits non-zero, which is then handled by the normal crash/restart logic like any other crash. - The subprocess is launched with the same working directory as your orchestrating script (
staypresent.run()'s caller) — sobot_module="mypkg.bot"needs to be resolvable from wherever you actually run your script, exactly like typingpython -m mypkg.botfrom that same location would. - Log labels for module-based bots use the dotted module name (e.g.
bot[0] 'mypkg.bot'). The same filename-collision handling from above applies to module names too — if two bots share the exact same module string, there's no more specific fallback than the module name itself (unlike a file path, a module name has no further-qualifying "directory" to fall back to), so those log lines are distinguished only by theirbot[i]index.
Crash recovery only ever catches a bot that actually exits. A bot that's still running but stuck — deadlocked, blocked forever on I/O, spinning in an infinite loop — never produces a non-zero exit code, so it's invisible to the crash/restart logic above no matter how long it sits there.
staypresent.heartbeat(), called periodically from inside your bot, closes that gap:
# worker.py
import time
import staypresent
while True:
staypresent.heartbeat()
do_one_unit_of_work()
time.sleep(1)# app.py
import staypresent
staypresent.run("worker.py", heartbeat_timeout=30)If worker.py goes more than heartbeat_timeout seconds without calling heartbeat(), StayPresent logs it as "possibly hung," terminates the process, and hands it to the normal crash/restart handling described in Crash Recovery Protocol — the same way it would handle any other crash, just triggered by silence instead of a bad exit code. On the status page, this shows up as its own "unresponsive" incident, followed by the usual crash/restart trail.
heartbeat_timeout is None by default (no hang detection at all — a bot that never calls heartbeat() is never penalized unless you opt in). It's cheap enough to call on every iteration of a tight loop; there's no need to throttle it yourself. staypresent.heartbeat() is a harmless no-op when called outside a heartbeat_timeout-configured run (including when the script is run standalone, with no StayPresent supervision at all), so it's safe to leave in a bot that might run either way.
Every combination of "bot(s)" and "web server" is valid except having neither:
- Bot(s), no web server —
staypresent.run("worker.py", web_server=False). Full crash/restart supervision, no HTTP server at all;host/port/production/threadsare ignored (a non-default value for any of them logs a warning), and everystaypresent.webregistration is inert, since nothing is listening to serve it. - Web server, no bot(s) —
staypresent.run()with nobot_file/bot_module/botsgiven at all. Useful for a deployment that only needs to expose a status/health page, with no process for StayPresent itself to supervise. - Both — the default, and everything documented elsewhere in this section.
- Neither (
web_server=Falsewith no bot configured) — raisesTypeErrorimmediately, since that combination would do nothing at all.
| Parameter | Type | Default | Description |
|---|---|---|---|
bot_file |
str or list[str] |
None |
Target Python script(s) to execute concurrently, launched as python <file> .... Mutually exclusive with bot_module and with bots. Optional as long as web_server=True — omit bot_file/bot_module/bots entirely to run the web server with no bot at all (see Section 4.4). |
bot_module |
str or list[str] |
None |
Dotted module path(s) to execute concurrently instead, launched as python -m <module> .... See Section 4.2. Mutually exclusive with bot_file and with bots. |
host |
str |
"0.0.0.0" |
Network interface to bind the web server. Ignored (with a warning if non-default) when web_server=False. |
port |
int |
8080 |
Port allocation for the web server. Must be between 0 and 65535. Ignored (with a warning if non-default) when web_server=False. |
production |
bool |
True |
Utilizes waitress if available. Set to False to force Flask's dev server. Ignored (with a warning if non-default) when web_server=False. |
threads |
int |
4 |
Worker threads for waitress (requires production=True and waitress actually installed). Must be at least 1. If it can't take effect — production=False, waitress isn't installed, or web_server=False — a non-default value is silently accepted but has no effect on the resulting server; a warning is logged in that case so the mismatch shows up in your logs rather than looking like it worked. |
restart_on_crash |
bool |
True |
Relaunches a bot upon a non-zero exit code. |
max_restarts |
int |
5 |
Maximum consecutive restart attempts, per bot. Must be >= 0. |
restart_delay |
float |
2.0 |
Seconds to wait before process respawn. Must be >= 0. This wait is interruptible: a Ctrl+C/SIGTERM arriving during it wakes the bot's monitor thread immediately instead of only after the full delay elapses, so shutdown isn't held up waiting out a restart backoff — see the note on shutdown ordering below. |
restart_reset_after |
float |
60.0 |
Seconds of continuous uptime required to reset a bot's crash counter to zero. Must be >= 0. |
heartbeat_timeout |
float or None |
None |
Seconds a bot may go without calling staypresent.heartbeat() before it's treated as hung, terminated, and handed to the normal crash/restart logic. None (default) disables hang detection entirely. Must be > 0 if given. See Section 4.3. |
bot_args |
list |
None |
CLI arguments to pass to every bot in bot_file/bot_module (e.g., ["--verbose"]). Must be a list, not a bare string. Ignored when bots is used, and has no effect (with a warning) if no bot is configured at all. |
env |
dict |
None |
Environment variables injected into every bot in bot_file/bot_module, merged over the current process's environment. Keys must be strings (values are coerced to strings automatically). Ignored when bots is used, and has no effect (with a warning) if no bot is configured at all. |
bots |
list[dict] |
None |
Per-bot configuration: [{"file": ..., "args": [...], "env": {...}, "services_name": ..., "services_description": ..., "status": ...}, {"module": ...}, ...], each with exactly one of "file"/"module" — "args", "env", "services_name", "services_description", and "status" are all optional per entry. Mutually exclusive with bot_file/bot_module/bot_args/env. |
install_signal_handlers |
bool |
True |
Whether run() installs its own SIGINT/SIGTERM handlers for graceful shutdown. When True, any handler your own script already registered for those signals before calling run() is chained: run()'s handler runs its own cleanup (terminating bot processes, logging active cron pingers) first, then calls your previously-installed handler afterward, rather than silently discarding it. Set to False to skip installing StayPresent's handlers entirely — useful if your script wants full, exclusive control over shutdown signaling. |
web_server |
bool |
True |
Whether to start the background web server at all. False runs bot(s) only — see Section 4.4. Requires at least one bot; web_server=False with no bot configured raises TypeError. |
services_name |
str or None |
None |
Display name for this call's one bot (or the web server's own row, if no bot is configured) on the status page. Only valid with zero or one bot configured process-wide — see Section 3.6. |
services_description |
str or None |
None |
Description shown under that same status-page row. Same single-bot-only rule as services_name. |
status |
bool |
True |
Whether the bot(s) this call configures get their own row on the status page. Defaults to True (shown unless turned off) — the opposite default from every staypresent.web.*() route's own status=. Override per bot via that bot's own bots[i]['status']. Has no effect on the web server's own row. |
Note on calling
run()twice:run()uses a single, shared, module-level Flask app (staypresent.server.app), so it's designed to be called exactly once per process. A second call raisesRuntimeErrorimmediately, explaining the issue and pointing at passing every bot to onerun()call instead (viabot_file/bot_moduleas a list, or viabots) — see Section 4.1. Without this check, a second call would instead fail later with a genericOSError: [Errno 98] Address already in usefrom trying to bind the samehost/porttwice, with no indication of the real cause. This "already called" state is only claimed once every argument above has passed validation and the server is actually about to start — a first call that fails validation (a missing bot file, an out-of-rangeport, etc.) never claims it, so it doesn't stand in the way of a later, valid call; you'll always see the real error from whichever call is actually invalid, not a misleading "already called" error from a later one.
StayPresent strictly monitors every bot's subprocess lifecycle, independently:
- Clean Exits: An exit code of
0is treated as an intentional shutdown for that bot and bypasses restart logic. - Hangs: A bot that's still running but has stopped doing anything is not, by itself, caught by any of the exit-code-based rules below — see Hang Detection (
heartbeat_timeout) for the opt-in mechanism that catches this case and feeds it into the same restart logic as a crash. - Signals: Interruptions (
SIGINT/Ctrl+C,SIGTERM) initiate a clean teardown of the server and every bot. If your own script already installed a handler forSIGINT/SIGTERMbefore callingrun(), it's chained — called after StayPresent's own cleanup finishes, not silently replaced (seeinstall_signal_handlersin the parameter table above). Signal handlers can only be registered on the main thread — ifrun()is called from elsewhere, a warning is logged and graceful signal handling is skipped rather than crashing. A signal arriving while a bot is in itsrestart_delaybackoff (waiting to respawn after a crash) is handled safely: shutdown and the pending respawn are synchronized so exactly one of them wins — either the respawn is cancelled before it happens, or shutdown terminates the freshly-respawned process too - so a shutdown can never leave an untracked, un-terminated bot process running. - Consecutive-Crash Budget:
max_restartscounts consecutive crashes. If a bot stays up for at leastrestart_reset_afterseconds after a restart, its counter resets to 0 — so a long-running bot that occasionally crashes once isn't penalized for crashes from long ago. - Terminal Failures: If
max_restartsis exhausted for a bot, or if restarts are disabled and it crashes, that bot is marked as permanently failed (but other bots keep running). Once every bot has finished, if any bot ended in a permanently-failed state,staypresent.run()exits the main process with a non-zero exit code, so platform-level orchestrators (Docker, systemd, Render, Railway) correctly detect the failure and can apply their own restart policy. When more than one bot fails, the process's own exit code is deterministically the lowest-indexed failing bot's own exit code (not whichever bot's monitor thread happened to finish first). - Relaunch Failures: If the OS itself refuses to spawn a replacement process during a restart (out of file descriptors/memory, a process-count ulimit, etc.), that's treated as a terminal failure for that bot rather than crashing the monitor thread.
None of the above survives the whole machine rebooting, though — a reboot kills the Python process (and every bot it's supervising) outright, with nothing left running to restart anything. See Section 11 for staypresent.restart(), which covers that case specifically.
Every one of these transitions — a start, a crash, a hang, a permanent failure, a clean exit, a recovery — is also recorded for the status page described in Section 3.5, if you're using it. This tracking runs regardless of whether you've called staypresent.web.status() at all; it costs nothing extra either way.
Some free-tier hosts spin a service down after a period of inactivity, even with an open HTTP port. staypresent.ping() and staypresent.cron() are optional helpers for periodically hitting your own public URL to prevent that — nothing is pinged unless you call one of these yourself.
Sends a single synchronous HTTP GET request and reports the result.
import staypresent
result = staypresent.ping("https://my-bot.onrender.com")
# {'url': 'https://my-bot.onrender.com/', 'ok': True, 'status_code': 200, 'elapsed': 0.42, 'error': None}Signature: staypresent.ping(host: str, port: int = None, path: str = "/", timeout: float = 10.0, https: bool = None) -> dict
| Parameter | Description |
|---|---|
host |
A bare hostname/IP, "host:port"-style string, or a full URL ("https://..."). If a full URL is given, port/path/https are ignored (and a warning is logged if you passed non-default values for them anyway). "0.0.0.0"/"::" are treated as "127.0.0.1", since they're bind addresses rather than something you can send an outgoing request to. |
port |
Port to connect to (1–65535). Ignored if host is a full URL. |
path |
URL path to request. Ignored if host is a full URL. |
timeout |
Seconds to wait for a response before giving up. Must be > 0. |
https |
Force https (True) or http (False). If omitted: defaults to https for anything that isn't a local address, and http for "127.0.0.1"/"localhost"/"::1" — matching what staypresent.run() itself serves locally. |
Return value: a dict with keys url, ok (True for any 2xx/3xx response), status_code, elapsed (seconds, rounded to 3 decimals), and error (a short description on failure — HTTP error status, timeout, DNS failure, connection refused, etc. — otherwise None).
Starts a background thread that repeatedly calls ping() on a schedule.
import staypresent
handle = staypresent.cron("https://my-bot.onrender.com", interval=300) # every 5 minutesSignature:
staypresent.cron(
host: str,
port: int = None,
path: str = "/",
interval: float = 300.0,
repeat: bool = True,
timeout: float = 10.0,
https: bool = None,
on_success=None,
on_failure=None,
) -> CronHandle| Parameter | Description |
|---|---|
host, port, path, https |
Same as ping() — describe the URL to hit. Validated immediately (raises on bad input) rather than failing silently on the first tick. |
interval |
Seconds between pings. Must be > 0. |
repeat |
If True (default), pings forever until stopped. If False, pings exactly once. |
timeout |
Per-request timeout in seconds, same as ping(). |
on_success |
Optional fn(result) called after each successful ping. An exception raised inside it is logged and swallowed — it never kills the background thread. |
on_failure |
Optional fn(result) called after each failed ping. Same exception-safety as on_success. |
Returns: a CronHandle:
handle.stop(wait: bool = False, timeout: float = None) # stop the background pinger; safe to call more than once
handle.is_running # property: True/False
handle.url # property: the URL this cron job pingsCron pingers run on daemon background threads, so they're never registered with staypresent.run()'s own shutdown handling the way bot processes are — in practice this doesn't matter, since daemon threads are torn down automatically when the process exits. If you do want visibility into what's still active (e.g. for your own logging), staypresent.active_cron_handles() returns every currently-running CronHandle from any past call to cron():
import staypresent
for handle in staypresent.active_cron_handles():
print(handle.url, handle.is_running)Note:
active_cron_handles()finds aCronHandleregardless of whether you kept a reference to it yourself — including the common "fire-and-forget" pattern where the return value ofcron()is discarded entirely (e.g.staypresent.cron("https://my-bot.onrender.com")with no assignment) for a keep-warm pinger you never intend to.stop(). The registry holds its own reference to every handle until that handle's.stop()is called (or its background thread has otherwise exited), so a discarded return value never causes the job to silently disappear from introspection.
staypresent.run() itself calls this during its Ctrl+C/SIGTERM shutdown sequence and logs any cron pinger(s) still running at that point, purely for visibility — it does not stop or wait on them; they're daemon threads and exit with the process regardless.
Three paths are served automatically the moment the web server starts, unless you register something of your own there:
| Path | Default response |
|---|---|
/ |
{"message": "I'm Present"} (JSON) |
/health |
{"status": "ok"} (JSON) — useful for platform health-check probes and uptime monitors that expect a dedicated path separate from your regular response(s). |
/status |
A full status page — see Section 3.5. |
These are defaults, not reservations: if you register your own response at any of the three via staypresent.web.text()/json()/html()/markdown()/status(), your response is served there instead, and the built-in default is simply never reached. staypresent.web.get(), get_all(), and paths() all report these three paths as active even when nothing has been explicitly registered at them, since a real response genuinely is being served there.
staypresent.web.markdown() uses a Markdown-to-HTML renderer that ships with StayPresent itself — no markdown package or any other dependency is installed or required. It supports the constructs people actually use in READMEs, changelogs, and docs:
- Headings — both ATX (
# Heading) and Setext (Heading\n===/Heading\n---) styles, levels 1–6. Each heading gets a stable, GitHub-compatibleidslug (lowercased, punctuation stripped, spaces turned into hyphens, de-duplicated with a numeric suffix on repeats) for anchor links — computed from the heading's rendered text, so a heading containing a link, image, or emphasis (e.g.## [Docs](url) Guide) slugs from "Docs Guide", not from the raw, unstripped Markdown source (which would otherwise fuse the link's URL into the id). - Emphasis —
**bold**/__bold__,*italic*/_italic_,***bold italic***, and~~strikethrough~~. - Inline code —
`code`(including multi-backtick spans like``code with abacktick`` `). - Links & images —
[text](url "optional title")and, plus bare autolinks (<https://example.com>). A destination may contain one level of balanced parens (e.g.https://en.wikipedia.org/wiki/Foo_(bar)) without being truncated at the inner). - Lists — unordered (
-,*,+) and ordered (1.), including nested lists (bullet-in-numbered and numbered-in-bullet nesting both work correctly), multi-paragraph list items, and GitHub-style tight/loose rendering (a list with a blank line between any two items wraps every item's text in<p>; one without doesn't). An ordered list that starts at a number other than 1 (e.g.5. five) renders with the matchingstartattribute. A list item can also contain other nested block content indented under it — a fenced code block, a blockquote, or a heading — which is common in step-by-step instructions (e.g. a numbered step followed by an indented```code example). Task lists (- [ ] todo,- [x] done) render as a disabled, GitHub-styled checkbox (checked for[x]/[X]) rather than literal[ ]/[x]text — only when the marker is the very first thing in the item (matching GFM), so a stray[ ]mid-sentence is left alone. - Blockquotes — including nested blockquotes.
- Code blocks — both fenced (
```or~~~, with a language tag for aclass="language-xxx"hook — only the first word of the fence's info string is used for this, so```python title="app.py"still highlights as Python, with the rest of the info string simply ignored) and indented (4-space/tab) code blocks. - Tables — GitHub-flavored pipe tables, including column alignment (
:--,--:,:-:). A table's header row is recognized as its own block even when it directly follows a paragraph line with no blank line in between (matching GitHub's own behavior) —intro line\n| a | b |\n|---|---|renders the intro as a<p>followed by a separate<table>, rather than folding the table's pipe-delimited rows into the paragraph as literal text. - Horizontal rules —
---,***,___. - Hard line breaks — a line ending in two-or-more spaces, or a trailing backslash.
- Raw HTML passthrough — a line starting with a recognized block-level HTML tag (e.g.
<p align="center">...) or an HTML comment is passed through verbatim, unescaped — the same "HTML block" behavior CommonMark/GitHub use, which is what makes centered logo/badge headers at the top of a README work correctly. - Backslash escapes —
\*,\_,\`, etc. for literal punctuation.
Everything else (plain text, and any of the constructs above) is HTML-escaped before rendering, so Markdown source containing <, >, &, or literal <script> tags cannot inject markup into the page — only recognized raw-HTML blocks are passed through, and even those are limited to a fixed list of known block-level tag names.
Escaping is applied exactly once per character, including inside link/image URLs and titles and inside autolinks — so a URL with a query string like [docs](https://example.com/x?a=1&b=2) renders as href="https://example.com/x?a=1&b=2" (a single, correctly-escaped &), not a mangled &amp;b=2, and an autolink like <https://example.com/x?a=1&b=2> keeps its full URL instead of being truncated at the &.
Link/image URLs are checked against a scheme blocklist, independent of the HTML-escaping above (escaping neutralizes markup injection; this neutralizes an executable destination). Destinations enclosed in angle brackets (e.g. <javascript:alert(1)>), percent-encoded scheme tokens (e.g. %6a%61%76%61%73%63%72%69%70%74:), or containing embedded control characters are automatically normalized, unquoted, and sanitized. javascript:/vbscript: (run arbitrary script directly) and file: (local filesystem access) are rejected for both links and images; data: (which can smuggle a full HTML document, script and all, into a click) is additionally rejected for links specifically. A rejected URL falls back to plain, already-escaped text — [click me](javascript:alert(1)) and [click me](<javascript:alert(1)>) render as the plain text click me, not a working link — while ordinary http(s)://, relative (/path, ./file), and anchor (#section) URLs, and data: images (a common, inert way to embed a small icon inline), are unaffected.
Nested inline content inside link/image text — code spans, emphasis/bold/strikethrough, and images all work correctly when nested inside a link's text, including the very common "clickable badge/logo" README pattern:
[`code`](https://example.com)
[**bold link**](https://example.com)
[](https://example.com)
renders as:
<a href="https://example.com"><code>code</code></a>
<a href="https://example.com"><strong>bold link</strong></a>
<a href="https://example.com"><img src="badge.svg" alt="badge"></a>Internally, code spans/escapes/images are protected from further processing by being replaced with an opaque placeholder token before links are parsed, then restored afterward — restoration happens in reverse (highest-placeholder-index-first) order, since a placeholder can only ever have a lower-index placeholder nested inside it (never a higher one), which is what lets a placeholder trapped inside a link's own stashed HTML resolve correctly instead of leaking a raw \x00N\x00 token into the page. A link's text also goes through emphasis/bold/strikethrough substitution directly (not just the surrounding page text), so **bold** inside link text renders as <strong>, not literal asterisks.
Rendered Markdown is wrapped in <article class="markdown-body"> and styled with a bundled stylesheet closely modeled on GitHub's own .markdown-body styles — headings, tables, code blocks, blockquotes, and images all get sensible, familiar styling with no configuration needed. The stylesheet supports:
prefers-color-scheme-based automatic dark mode whenmode="auto"(the default) — no code needed on your end, it's built into the shipped CSS.- Forced light/dark via a
data-theme="light"/data-theme="dark"attribute when you passmode="light"/mode="dark"tostaypresent.web.markdown().
This is a lightweight renderer, not a full CommonMark implementation. It does not support: footnotes, definition lists as a distinct syntax (beyond raw HTML passthrough), custom containers/admonitions, LaTeX/math rendering, reference-style links/images ([text][ref] + [ref]: url elsewhere in the document — only the inline [text](url) form works), or blockquote "lazy continuation" (a paragraph line inside a > blockquote that omits the leading > ends the blockquote there rather than continuing it, unlike CommonMark's own more permissive rule). For any of these, render your Markdown to HTML with a different tool ahead of time and serve the result with staypresent.web.html() instead.
web.text(message: str, path: str = "/", services_name: str = None, services_description: str = None, status: bool = False)– Configurespathto return plain text.web.json(data: Any, path: str = "/", services_name: str = None, services_description: str = None, status: bool = False)– Configurespathto return a JSON payload (deep-copied).web.html(file_path: str, path: str = "/", exclude: list = None, services_name: str = None, services_description: str = None, status: bool = False)– Configurespathto serve an HTML file, read fresh every request, alongside neighboring static files (except any matchingexclude).web.markdown(file_path: str, path: str = "/", mode: str = "auto", favicon: str = None, title: str = None, description: str = None, exclude: list = None, services_name: str = None, services_description: str = None, status: bool = False)– Configurespathto serve a Markdown file rendered to styled HTML, read and re-rendered fresh every request, alongside neighboring static files (except any matchingexclude). See Section 3.4.web.status(title: str = None, path: str = "/status", copyright: str = None, footer_links: list = None, api_key: str = None, trust_proxy_headers: bool = False, mode: str = "auto", favicon: str = None, description: str = None, poll_seconds: float = 15, timezone: str = "auto", time_format: str = "12h", status: bool = False)– Configurespathto serve the built-in status page. See Section 3.5.web.remove(path: str = "/")– Stops hosting a response atpath. ReturnsTrue/False.web.get(path: str = "/")– Returns the currently configured response state forpathas a dictionary, including StayPresent's own implicit defaults at/,/health, and/status, or{}if nothing is registered or defaulted there.web.get_all()– Returns every path currently hosting a response — including the implicit defaults above — and its state, as one dictionary.web.paths()– Returns a sorted list of every path currently hosting a response, including the implicit defaults above.
run(bot_file: str | list[str] = None, bot_module: str | list[str] = None, host: str = "0.0.0.0", port: int = 8080, production: bool = True, threads: int = 4, restart_on_crash: bool = True, max_restarts: int = 5, restart_delay: float = 2.0, restart_reset_after: float = 60.0, heartbeat_timeout: float = None, bot_args: list = None, env: dict = None, bots: list[dict] = None, install_signal_handlers: bool = True, web_server: bool = True, services_name: str = None, services_description: str = None, status: bool = True)– Starts the HTTP server, your bot process(es), or both. RaisesRuntimeErrorif called more than once in the same process, andTypeErrorif given neither a bot norweb_server=True— see the notes in Section 4.heartbeat()– Call from inside a bot to signal it's still alive; only meaningful when the parentrun()call setheartbeat_timeout. See Section 4.3.ping(host: str, port: int = None, path: str = "/", timeout: float = 10.0, https: bool = None) -> dict– Sends a synchronous HTTP request.cron(host: str, port: int = None, path: str = "/", interval: float = 300.0, repeat: bool = True, timeout: float = 10.0, https: bool = None, on_success=None, on_failure=None) -> CronHandle– Runs scheduled background keep-warm requests.active_cron_handles() -> list[CronHandle]– Returns every currently-runningCronHandlefrom any past call tocron(). See Section 5.restart(policy: str = "no", name: str = None, command: list = None, working_directory: str = None, restart_sec: float = 3, enable_linger: bool = True) -> dict– Installs (and enables) a user-level systemd unit so this script survives a full machine reboot, not just an in-process crash.policymirrors Docker's own--restartflag:"no"(default, does nothing),"always","on-failure"[:N],"unless-stopped". Linux only. See Section 11.
StayPresent logs through its own "staypresent" logger, configured with a single dedicated StreamHandler and logger.propagate = False. It deliberately never calls logging.basicConfig() or otherwise touches the root logger, so it won't clobber, duplicate, or reformat log output your bot script has already configured for its own, unrelated loggers.
You'll see log lines for:
- Web server startup (
waitressvs. the Flask dev-server fallback, and which host/port it bound to) — or, withweb_server=False, a line noting no web server is being started. - Each bot process starting, crashing, restarting, exhausting its restart budget, going unresponsive (see Hang Detection), or exiting cleanly.
- Signal-triggered shutdowns (
SIGINT/SIGTERM), and a previously-installed handler being chained afterward, if any. - Unexpected web-server thread failures (both at startup and later during the run).
- Cron pinger start/stop events (at
INFO) and individual failed pings (atWARNING, viaping()'s own logging). - A one-time
WARNINGper directory the first timeweb.html()/web.markdown()exposes it as a static-asset fallback — see the security note in Section 3.3. - The auto-generated admin key for a status page with no
api_keygiven, atINFO— see Section 3.5.
Each bot's own stdout/stderr output continues to print to the console exactly as it did before — it's captured (to feed the status page's log tail) and echoed, not redirected or silenced.
If you want to adjust the log level or attach your own handler, grab the logger directly:
import logging
logging.getLogger("staypresent").setLevel(logging.DEBUG)- Render / Railway / Koyeb / Heroku: these platforms typically require your service to bind an HTTP port and respond to requests to be considered "healthy".
staypresent.run("bot.py")satisfies that requirement immediately with zero configuration — just make surehost="0.0.0.0"(the default) and thatportmatches whatever the platform expects (often provided via aPORTenvironment variable — read it yourself and pass it toport=). - Docker/systemd: because
staypresent.run()exits with a non-zero code when a bot ultimately fails to stay up (see Crash Recovery Protocol), your container/service's own restart policy (restart: always,Restart=on-failure, etc.) can act as a second, outer safety net beyond StayPresent's own internal restarts. - Free-tier inactivity spin-down: if your host spins the service down after a period of no inbound traffic (as opposed to just requiring an open port), pair
staypresent.run()withstaypresent.cron()pinging your own public URL — see Section 5.
Everything in Section 4's Crash Recovery Protocol only works while your Python process is actually alive — it's staypresent.run() itself watching over its own bots. A full machine reboot (or a kill -9 on the process, or the machine losing power) takes that process down with nothing left running to restart anything. staypresent.restart() covers that specific gap on Linux, by installing a real, OS-managed systemd --user unit for your script — the same mechanism Docker itself ultimately relies on (dockerd is a systemd unit too), scoped down to what a plain user-level unit can promise. It mirrors Docker's own --restart flag's four values directly — "no" (the default, for both), "always", "on-failure"[:max-retries], "unless-stopped" — see Restart Policy Values below for exactly what each one does here and where it diverges from real Docker behavior.
import staypresent
staypresent.restart(policy="always")
staypresent.run("bot.py")Call this once, from the same script that calls staypresent.run() — typically right before it, as above. It does not start or relaunch anything by itself — for any policy other than "no", it only writes the unit file and runs systemctl --user enable, so systemd will launch your script the next time this user logs in (or, with lingering enabled — see enable_linger below — on every future boot regardless of login). It deliberately never runs systemctl --user start for you: doing so from inside the very script that's already running right now would silently launch a second, independent copy of your bot alongside this one. staypresent.restart()'s return value (and its own log line) includes the exact systemctl --user start <name>.service command to run yourself once you're ready to hand off to systemd — stop this script by hand first, so you don't end up with two copies.
policy="no" (the default, same as Docker) is different from the other three in one important way: it's a true no-op — nothing is written to disk, systemctl/loginctl are never invoked, every other argument is ignored, and it never even checks whether you're on Linux. That's deliberate: it's what makes staypresent.restart() safe to leave in a script unconditionally on any platform, with nothing to configure and nothing to clean up later — you only pay the Linux/systemd requirement once you actually opt into a real policy.
Signature:
staypresent.restart(
policy: str = "no",
name: str = None,
command: list = None,
working_directory: str = None,
restart_sec: float = 3,
enable_linger: bool = True,
) -> dict| Parameter | Type | Default | Description |
|---|---|---|---|
policy |
str |
"no" |
One of "no", "always", "on-failure" (optionally "on-failure:N"), or "unless-stopped" — see Restart Policy Values below. |
name |
str or None |
None |
Systemd unit name (without .service). Defaults to "staypresent-<script filename>" (e.g. "staypresent-bot" for bot.py). Only letters, digits, -, _ allowed if given explicitly. Ignored when policy="no". |
command |
list or None |
None |
The exact command to (re)launch, as an argv list (e.g. ["python3", "/home/alice/bot/bot.py"]). Defaults to the currently running interpreter and script — right for the common case of calling this from the top of the same script staypresent.run() is called from. Give this explicitly if sys.argv[0] is empty in your context (e.g. a python3 -c ... one-liner) — there's nothing to auto-derive a path from otherwise (raises ValueError). Ignored when policy="no". |
working_directory |
str or None |
None |
Directory the command runs from. Defaults to the current working directory when restart() is called. Ignored when policy="no". |
restart_sec |
float |
3 |
Seconds systemd waits before each restart attempt. Ignored when policy="no". |
enable_linger |
bool |
True |
Also attempts loginctl enable-linger for the current user. Without this, the unit only starts once that user has an active login session — fine on a desktop, but on a headless server it means the unit won't come back after a reboot until someone logs in as this user again. Best-effort: some systems restrict who can enable their own lingering, so a failure here is logged as a WARNING (with the exact command to try yourself) rather than raised. Ignored when policy="no". |
Returns a dict: {"name", "unit_path", "command", "policy", "linger_enabled", "start_command", "installed"} — start_command is the exact command to run yourself when ready to hand off to systemd. For policy="no", "installed" is False and every other key besides "policy" is None.
Raises (for "always"/"on-failure"/"unless-stopped" only — never for policy="no", regardless of platform) RuntimeError if not running on Linux, if systemctl isn't available, or if systemctl --user enable itself fails (e.g. no user systemd/D-Bus session at all — common in some minimal containers); TypeError/ValueError for invalid arguments (an unrecognized policy, a name with disallowed characters, an empty command list, a negative restart_sec, ":N" attached to anything other than "on-failure" — same as Docker's own --restart flag rejects that combination too — or command left unset with an empty sys.argv[0], with nothing to auto-derive a path from).
policy |
What it does here | vs. Docker |
|---|---|---|
"no" (default) |
Nothing at all — no unit written, no systemctl/loginctl calls, no Linux check. |
Exact match — Docker's "no" also means "never automatically restart, for any reason", which is simply what not calling this function achieves. |
"always" |
Restart=always — systemd restarts the process no matter how it exits, including a clean/manual stop. |
Close match. |
"on-failure" / "on-failure:N" |
Restart=on-failure — only restarts on a crash (non-zero exit, signal, etc.), not a clean exit. :N sets StartLimitBurst=N (default 5 without :N) within a 5-minute StartLimitIntervalSec window. |
Not exact. Docker's max-retries is a lifetime total, counted forever. Systemd's StartLimitBurst is a rolling-window rate limiter — a process failing once every 10 minutes would restart forever here, since it never accumulates N failures inside any single 5-minute window, unlike Docker's literal cap. Close enough to stop a tight crash loop, not the same guarantee. |
"unless-stopped" |
Implemented as plain Restart=always — logs a WARNING every time it's used, spelling out the gap below. |
No real equivalent. Docker's unless-stopped restarts always, except a manual stop persists across a later daemon/machine restart. Systemd has no primitive for that: a systemctl --user stop doesn't durably record "a human wanted this off" anywhere a later boot would consult, and an enabled unit starts again on the very next boot/login regardless of a prior manual stop. Concretely: stop the service by hand, reboot, and systemd will start it again (unlike real unless-stopped) — run systemctl --user disable <name>.service instead for a stop that actually sticks. Prefer plain "always" unless you specifically want this closest-available approximation. |
Other differences from Docker:
- Docker's daemon (
dockerd, itself a systemd unit) is one always-installed system service coordinating every container's restart policy.staypresent.restart()instead creates one ordinarysystemctl --userunit per script — there's no separate "StayPresent daemon"; systemd itself is the entire mechanism. - A Docker container is restarted by a system-wide daemon regardless of whether anyone is logged into the host. A
systemctl --userunit fundamentally depends on that user's own systemd instance existing, which needs either an active login session or lingering enabled — there's no "always-on regardless of login" mode for a user-level unit short of lingering (enable_linger=True, the default).
No. If it isn't installed, StayPresent automatically falls back to Flask's built-in development server and logs a one-time warning. For production environments, installing the prod extra is recommended:
pip install staypresent[prod]
No. Markdown rendering (headings, tables, emphasis, links, images, code blocks, theming, favicon/title/description support — everything in Section 7) is built into StayPresent itself. No optional package is required.
Yes. Pass a list of file paths to bot_file:
staypresent.run(["telegram_bot.py", "discord_bot.py"])Each bot is supervised (and restarted on crash) independently. For per-bot arguments or environment variables, use the bots parameter instead — see Section 4.1.
Use bot_module instead of bot_file. bot_file runs your bot the way python bot.py would, which breaks package-relative imports (from . import something) the exact same way running it directly from the command line would. bot_module runs it as python -m mypkg.bot instead:
staypresent.run(bot_module="mypkg.bot")See Section 4.2 for the full rules, including how to mix file- and module-based bots together via bots.
Nothing breaks. Bots are tracked by their position in the list, not by filename, so staypresent.run(["shard_a/bot.py", "shard_b/bot.py"]) runs both as fully independent processes with independent crash recovery. The only thing that changes is cosmetic: StayPresent detects the shared filename and automatically shows each bot's full path in the logs instead of just the filename, so crash/restart messages stay unambiguous. See Section 4.1.
The most recently registered one wins — this is also how you intentionally update a response (e.g. calling json() again to refresh a status payload), so it never raises an error. If the response type at that path actually changes (e.g. it was json and a later call registers text there), StayPresent logs a WARNING so you can spot an unintended collision between two bots or two parts of your code. See Section 3.7.
Yes. text(), json(), html(), markdown(), and status() all accept a path argument (default "/"):
staypresent.web.json({"status": "online"})
staypresent.web.text("bot #2 status", path="/bot2")See Section 3.7 for the full rules (trailing-slash redirects, static asset resolution, etc.).
Pass mode="light", mode="dark", or leave it on the default mode="auto" to follow the visitor's own OS/browser preference automatically:
staypresent.web.markdown("guide.md", mode="dark")See Section 3.4 for full details.
Yes, directly from Python:
staypresent.web.markdown(
"guide.md",
favicon="favicon.png", # or a direct URL
title="My Docs",
description="A short summary for link previews.",
)See Section 3.4 for how favicon resolution works for local files vs. direct URLs.
Yes. Set restart_on_crash=False. The affected bot will simply stop after its first crash, and staypresent.run() will still exit with a non-zero code once every bot has finished (in case others are still running).
If restart_on_crash=True (the default), StayPresent will attempt to restart it up to max_restarts times, waiting restart_delay seconds between each attempt. If your bot stays alive for restart_reset_after seconds after a restart, its crash counter resets to zero. See Crash Recovery Protocol for the full behavior, including what happens with multiple bots.
No. StayPresent logs only to its own "staypresent" logger and never touches Python's root logger (it does not call logging.basicConfig()). See Section 9.
It's a default, not a reservation. If you don't configure anything at /health, it returns {"status": "ok"}. If you do register a response there via staypresent.web, yours is served instead. See Section 6.
No. A status page is served at /status by default the moment staypresent.run() starts, with no configuration at all. Call staypresent.web.status() yourself only if you want to change its title, theme, footer links, or admin key. See Section 3.5.
Anyone — the page itself, its live data, and the overall up/down status are public by design, the same as a real status page. What's gated behind the optional api_key admin login is extra technical detail: the actual process exit code behind a crash, and a recent stdout/stderr tail per service. Without admin access, an incident still shows up with a plain-language description ("Service disruption detected — automatically recovering"), just without that detail. See Section 3.5.
Pass status=False to staypresent.run() (hides every bot that call configures), or set "status": False on that one bot's own entry in bots=[...] (overrides run()'s own default for just that bot). The bot is still fully supervised — crash detection and restarts are unaffected — it just doesn't get a row. See Section 3.5.
Yes, with staypresent.heartbeat(). Call it periodically from inside your bot's own loop, and pass heartbeat_timeout=<seconds> to staypresent.run(); a bot that goes longer than that without checking in is terminated and restarted the same way a crash would be. See Section 4.3.
Yes to both. staypresent.run("worker.py", web_server=False) supervises a bot with no HTTP server. staypresent.run() with no bot_file/bot_module/bots runs the web server on its own. The only combination that's rejected is both at once, since that would do nothing. See Section 4.4.
Yes, with exclude:
staypresent.web.html("templates/index.html", exclude=[".env", ".git", "*.py", "secrets.json"])A request for anything matching an exclude pattern gets a plain 404, the same as if the file didn't exist. See Section 3.3.
Pass timezone="Asia/Kolkata" (or common aliases like "IST", "EST") and time_format="24h" to staypresent.web.status(). By default, timezone="auto" detects the visitor's local browser timezone.
Yes. If called outside a heartbeat_timeout-configured run (or in a standalone script without StayPresent supervision), it acts as a harmless no-op.
Failed attempts are rate-limited per client IP—after 5 wrong keys within a 15-minute window, further attempts from that IP are rejected. If you are behind a reverse proxy, set trust_proxy_headers=True so rate limiting uses X-Forwarded-For instead of the raw proxy connection.
How can I prevent sensitive files like .env or source code from being exposed via html() or markdown()?
Use the exclude parameter with patterns or filenames: exclude=[".env", "*.py", ".git", "secrets.json"]. Any request matching an excluded pattern receives a standard 404 response as if the file does not exist.
Not by itself. restart_on_crash only works while the Python process is alive; a machine reboot terminates that process and everything it supervises.
For boot-level recovery on Linux, call staypresent.restart(policy="always") once to install and enable a systemd user service that can relaunch your script after login or reboot. See Section 11.
Yes. The default policy="no" is a true no-op: it does not write files, run systemctl or loginctl, or require Linux.
Boot-level behavior is only enabled when you explicitly use policy="always", "on-failure", or "unless-stopped".
No. It installs and enables the systemd service but deliberately does not start it immediately. Starting it from the current process would create a second copy of your bot.
The returned configuration includes the start_command you can use when you're ready to hand the process over to systemd.
no— default; installs nothing.always— restarts the service regardless of how it exits.on-failure— restarts only after a failure; it can optionally include a restart limit such ason-failure:3.unless-stopped— behaves likealwaysunder systemd, with an important limitation: a manual stop does not persist across a reboot.
See Restart Policy Values for the exact behavior.
No. Unlike Docker's unless-stopped, StayPresent implements this policy using systemd's Restart=always. If you manually stop an enabled service and then reboot, systemd can start it again.
To prevent it from starting on the next boot, disable the service with systemctl --user disable <name>.service.
When enable_linger=True (the default), StayPresent attempts to enable systemd user lingering for the current user. This allows the user service to run after a reboot even when that user has not logged in interactively.
If enabling linger fails, StayPresent logs a warning instead of failing the restart setup. You can run the suggested loginctl enable-linger command manually if your system requires it.
A non-default restart policy raises RuntimeError when boot-level restart cannot be configured—for example, when running on a non-Linux system, when systemctl is unavailable, or when the user systemd session cannot be enabled.
policy="no" does not raise these errors because it performs no system-level setup.
No. It is intended for scripts running directly on Linux hosts such as bare-metal machines, VMs, or VPSs.
If your bot already runs under Docker, use Docker's restart policy (or your orchestrator's equivalent). You generally do not need staypresent.restart() inside the container.