Skip to content

System operations

This page documents the operations that do not touch chats: service calls, storage, timers, the mechanics of ui.* calls, badges and notifications. It also describes the call envelope, the async mechanism and the common limits. Everything here follows the host code (broker.cpp and friends); where a value is a limit, it is the value the host enforces.

Related pages: Call protocol (ops) (summary), Network operations, Files, media and images, Text formatting, Errors and limits, Async.

Every call is a map with the operation name and its arguments:

# raw form: {"op": <name>, "a": {<arguments>}, "async": <bool, optional>}
r = freshgram.call({"op": "storage.get", "a": {"key": "counter"}})
# -> {"ok": True, "v": 3} or {"ok": False, "e": "denied", "m": "storage.private"}
Field Type Meaning
op text operation name. Missing or not text: invalid (“no operation”)
a map arguments. May be omitted (then all arguments are absent). Present but not a map: invalid (“arguments must be a map”)
async bool true makes the call asynchronous. Only a real true counts; any other value is ignored

The answer is {"ok": true, "v": <result>} or {"ok": false, "e": "<code>", "m": "<message>"}. The Python wrappers return v and raise an exception that matches e (see Errors and limits). Codes used by this page: denied, invalid, not_found, quota, limit, unavailable, failed; network operations add transport codes (see Network operations).

For every call the host checks, in this order:

  1. Global call budget: more than 200 calls in the last second: limit “too many calls”.
  2. If async is true: the async path.
  3. Hook gate: while the plugin handles message.outgoing / message.incoming, only meta.info, log, time.now and messages.set_outgoing, messages.drop_outgoing, messages.set_incoming, messages.hide_incoming are allowed. Everything else: unavailable “not while changing an outgoing message”.
  4. Await-only gate: time.sleep, files.pick, files.save and every fs.* called without async: invalid “ must be awaited”.
  5. Per-operation rate limit (see limits). It is counted before permission and argument checks, so a denied or invalid call still uses up its slot.
  6. Permission check, then argument validation, then the work.
Limit Value Error
Calls per second, per plugin instance (one plugin and account) 200 (sliding one second window), asynchronous calls included limit “too many calls”
Pending async calls 32 limit “too many calls are pending”
Host calls per event dispatch 256 limit “too many calls for one event”
Total time spent inside host calls per event 60 s limit “the host calls of this event took too long”
Event run time (without host call time) runtime.limits.eventTimeoutMs, 10 to 60000 ms, default 500 the event is aborted
One request or response frame 128 MiB an answer over the limit is replaced by limit “the answer is too large”
Result of an async call (call.result) 128 MiB minus 1 KiB limit “the answer is too large”
net.fetch request body / response body 100 MiB / 100 MiB limit
Plugin storage value / keys / quota 1 MiB / 4096 / runtime.limits.storageMb (0 to 1024 MiB, default 10) limit / quota / quota
Files read or written through files.* or fs.*, one call 100 MiB limit
Media returned by media.get 100 MiB limit
Image input / output / side / pixels 16 MiB / 32 MiB / 8192 px / 24 Mpx limit
Clipboard text 64 KiB invalid (write) or limit (read)
Open sockets 16 per kind (TCP, UDP, WebSocket), per plugin instance limit “too many open sockets”
One socket chunk (net.socket.send) 1 MiB (TCP), 65507 bytes (UDP) limit
WebSocket message 1 MiB limit
Plugin message (plugins.send) 64 KiB encoded, depth 12 limit / invalid
Message text (messages.send and similar) 4096 bytes UTF-8 (caption 4096 bytes) invalid
Timers 32 per plugin and account limit

Per-operation rate limits (all measured on the plugin instance):

Operations Limit
messages.send, stickers.send, messages.forward, messages.send_media, messages.edit_media, messages.send_poll burst of 5, refilled one per second (shared bucket)
messages.edit, messages.delete, messages.react, chats.mark_read, users.photo, channels.photo burst of 20, refilled one per 200 ms (shared bucket)
ui.toast one per 2 s
notify.show one per 5 s
image.* one per 100 ms (shared by all four)
users.resolve, channels.resolve one per 1 s (shared); limit “usernames are resolved too often”
chats.ban, chats.unban one per 1 s (shared); limit “members are moderated too often”
chats.members, chats.admins one per 1 s (shared); limit “members are listed too often”
bots.inline_query, bots.press_button one per 1 s (shared); limit “bots are asked too often”
ui.dialog.show, new ui.window.show after a user action, or at most once per 60 s
ui.window.update (and show of an open window) one per 100 ms per window
fs.write 512 MiB written per rolling 60 s
datagrams of one UDP socket 200 per second

An operation that hits its rate limit answers limit. Message-level operations are documented in the message and chat API pages.

A synchronous call blocks the handler until the host answers. With "async": true the host answers at once with a ticket, does the work on a background thread, and later delivers the result as an event.

# Python (freshgram.aio does all of this for you)
from freshgram import aio
data = await aio.storage.get("k")
# raw form
ack = freshgram.call({"op": "net.fetch", "a": {"url": "https://example.org"}, "async": True})
# ack == {"ok": True, "v": {"ticket": 7}}

The immediate answer is {"ok": true, "v": {"ticket": N}}. Later the plugin receives:

{"type": "call.result", "ticket": 7, "ok": true, "v": <result>}
{"type": "call.result", "ticket": 7, "ok": false, "e": "<code>", "m": "<message>"}

That is, call.result is the normal answer map (ok, v or e and m) with type and ticket added.

Property Behavior
Tickets integers starting at 1, increasing, unique per plugin instance (plugin and account)
Max pending 32 per plugin instance. The 33rd async call is refused at once with limit “too many calls are pending” (no ticket)
Budget an async call also uses one of the 200 calls per second
Errors at submit time refused immediately in the ack (ok: false): limit (budget or pending), unavailable (hook active, or async not available: “awaitable calls”), invalid (op cannot be awaited; time.sleep with a bad ms)
Errors at run time arrive in call.result (denied, invalid, quota, rate limits and so on), because the operation runs later. Rate limits are evaluated when the work starts
Work threads one pool of 8 threads shared by all plugins; socket calls (net.tcp.*, net.udp.*, net.ws.*, net.socket.*) run in order on the plugin’s own socket thread
Cancellation there is no per-call cancellation. aio.wait_for stops waiting but the call still runs and its result is dropped. When the plugin stops or unloads, pending calls are abandoned and no call.result is delivered
Oversized result replaced by limit “the answer is too large”
Hooks async calls are refused (unavailable) while a message hook is handled
Operation Sync Async
time.sleep no (invalid, “must be awaited”) yes, only async
files.pick, files.save, fs.list, fs.stat, fs.read, fs.write, fs.mkdir, fs.remove, fs.rename no (invalid, “must be awaited”) yes, only async
plugin.unload, messages.set_outgoing, messages.drop_outgoing, messages.set_incoming, messages.hide_incoming yes no (invalid “this call cannot be awaited”)
every other operation yes yes

In the Python SDK freshgram.files and freshgram.fs exist only as aio.files and aio.fs; aio.sleep(seconds) sends time.sleep with ms = max(0, int(seconds * 1000)). It is not clamped: more than 60 seconds raises Invalid.

No permission. Allowed inside hooks.

Argument Type Required
none

Result:

{"api": 7, "plugin": "<plugin id>", "granted": ["storage.private", ...], "chats": "all" | [chat ids]}
Field Meaning
api host API version (7)
plugin the plugin id
granted permissions that are actually granted now (sorted)
chats "all", or the list of chat ids the user selected for the plugin
freshgram.info() # raw: {"op": "meta.info"}

Errors: none beyond the common ones.

No permission. Allowed inside hooks.

Argument Type Required Validation
text text yes up to 4096 bytes; otherwise invalid “text”

Result: null. The line is added to the plugin log: only the first 500 bytes are kept, the log keeps the last 200 lines (the app may show or forward them). The Python freshgram.log(text) shortens text to its last 4000 bytes before sending. Python print goes to the error stream and is separate.

freshgram.log("started") # raw: {"op": "log", "a": {"text": "started"}}

No permission. Allowed inside hooks. No arguments.

Result: integer, unix time in seconds, server-corrected (not the device clock); falls back to the system clock when the app does not provide a server time.

freshgram.time() # raw: {"op": "time.now"}

No permission. Async only (a sync call answers invalid “time.sleep must be awaited”).

Argument Type Required Validation
ms integer yes 0 to 60000; otherwise invalid “ms” (the error is in the ack)

Result when the time has passed: null. It uses a ticket and counts toward the 32 pending calls but does not occupy a worker thread.

await aio.sleep(1.5) # raw: {"op": "time.sleep", "a": {"ms": 1500}, "async": true}

No permission. Sync only. No arguments. Result: null.

Marks the instance for unloading: the plugin stops after the current event finishes, and the app starts it again on the next event, command, menu click or settings change that activates it. Pending async calls are abandoned. Timers survive an unload (they are removed when the plugin is disabled, uninstalled, or the account logs out).

freshgram.unload() # raw: {"op": "plugin.unload"}

Permission: storage.private for all storage.* operations (denied “storage.private” otherwise). The storage is a key to value map private to the plugin.

Properties:

  • One store per plugin (shared by all the accounts of that plugin), kept in the plugin’s data/store.cbor. It is written atomically and the previous good copy is kept as a backup; a damaged file is restored from the backup (the loss is written to the plugin log).
  • Values are stored as CBOR. Allowed value types: null, bool, integer (64-bit), float, bytes, text (valid UTF-8), array, map with text keys. In Python: None, bool, int, float, bytes, str, list, dict[str, ...]. Nesting deeper than 16 levels is rejected (reported as limit). Bytes and text stay distinct types.
  • Size limits: encoded value up to 1 MiB; key 1 to 128 bytes; at most 4096 keys.
  • Key rules: non-empty text, at most 128 bytes (UTF-8 bytes, not characters), no byte below 0x20 and no 0x7F. Anything else is invalid “key”. Keys are compared byte by byte; storage.list returns them in that order.
  • Quota semantics: the storage uses len(key) + len(CBOR value) for every entry. The quota is runtime.limits.storageMb MiB from the manifest (default 10, maximum 1024; 0 makes every write fail). A write that replaces a key counts the new size minus the old size of that entry. The check and the write are one atomic step.
Argument Type Required Validation
key text yes key rules above, else invalid “key”

Result: the stored value, or null when the key does not exist. Errors: denied, invalid “key”, failed “a damaged value” (stored bytes do not decode), unavailable “storage”.

n = freshgram.storage.get("counter", 0) # default is applied by the SDK when the result is null
# raw: {"op": "storage.get", "a": {"key": "counter"}}

A stored null cannot be told apart from a missing key.

Argument Type Required Validation
key text yes key rules
value any yes must be present (null is allowed); invalid “value” if absent

Result: null.

Error Trigger
invalid “key” / “value” bad key; value absent
limit “the value is too large” encoded value over 1 MiB, or it cannot be encoded (too deep)
quota “too many keys” a new key while 4096 keys exist
quota “the quota is used up” used bytes minus the old entry plus the new entry exceeds the quota
freshgram.storage.set("counter", 4)
# raw: {"op": "storage.set", "a": {"key": "counter", "value": 4}}
Argument Type Required
key text yes

Result: bool, true if the key existed. Deleting a missing key is not an error. A missing, non-text or invalid key (see the key rules) is invalid “key”.

Argument Type Required Default Validation
prefix text no "" up to 128 bytes, else invalid “prefix or limit”
limit integer no 100 below 1 is invalid; values over 1000 are clamped to 1000; a non-integer is silently ignored (100), it is not invalid

Result: array of key texts, sorted, all starting with prefix.

freshgram.storage.keys("user:", limit=50)
# raw: {"op": "storage.list", "a": {"prefix": "user:", "limit": 50}}

No arguments. Result: {"used": <bytes>, "quota": <bytes>, "keys": <count>}.

No permission is checked by the host. Timers are held in memory while the app runs (they are not saved across app restarts) and wake the plugin with a timer event {"type": "timer", "id": "<id>"}, starting it if it is not running. A one-shot timer is removed when it fires; a repeating timer fires again after each period, counted from the moment it fired. Timers are removed when the plugin is disabled or uninstalled and when the account logs out.

Argument Type Required Default Validation
id text yes 1 to 64 bytes, no control characters; else invalid “id”
delay integer, ms yes 1000 to 604800000 (7 days); else invalid “delay must be from 1000 ms to 7 days”
repeat bool no false not a bool: invalid (same message)

Result: null. Setting an existing id replaces that timer. A 33rd distinct timer: limit “the timer was not set”. unavailable “timers” if the app has no timer service.

freshgram.timers.set("tick", 60, repeat=True) # seconds in Python, converted to ms
# raw: {"op": "timers.set", "a": {"id": "tick", "delay": 60000, "repeat": true}}

The Python wrapper sends delay = int(seconds * 1000) and omits repeat when it is false.

Argument Type Required
id text yes (same rules as above)

Result: bool, true if a timer was removed.

No arguments. Result: array of the ids of the timers that are currently set.

The item schema of pages, dialogs and windows is documented in UI. This section covers permissions, arguments around the page, and limits.

A user action is a click-like event delivered to the plugin: menu.click, badge.click, command, settings.*, toast.click, dialog.click, dialog.change, dialog.long_click, window.click, window.change, window.long_click. Closing events (dialog.close, window.close, toast.close) are not user actions. A user action stays “recent” for 10 seconds.

Permission ui.toast.

Argument Type Required Default Validation
text text yes up to 200 bytes (empty is allowed)
title text no none up to 64 bytes, no newline
style text no "default" "default" or "undo"
duration integer, seconds no 5 1 to 8
action map no none {"id": text 1-32 bytes, "label": text up to 40 bytes}; label is required unless style is "undo"

style: "undo" requires an action; without one the call fails with invalid “undo needs an action”. Result: null. The shown text is prefixed with the plugin id. A click on the button arrives as toast.click, the end of the toast as toast.close with reason "action" or "timeout"; the host remembers the last 16 offered action ids.

Errors: denied “ui.toast”; invalid “text”, “style”, “duration”, “title”, “action”, “undo needs an action”; limit “toasts are shown too often” (one per 2 s); unavailable “toast actions” (the app cannot offer toast buttons) or other unavailable.

freshgram.ui.toast("Saved", action="undo-1", undo=True, duration=6)
# raw: {"op": "ui.toast", "a": {"text": "Saved", "style": "undo", "duration": 6, "action": {"id": "undo-1"}}}

Permission ui.settings_page. Argument page (map, required): validated against the page schema; a bad page is invalid with the validation message. Result null. unavailable “settings page” if the manifest declares no settings page. No rate limit.

Permission ui.settings_page. No arguments. Allowed only within 10 seconds after a user action, and it consumes that action: otherwise denied “only right after a user action”. Result null.

Permission ui.dialog (denied “ui.dialog”; unavailable “dialogs” without a UI service).

Operation Arguments Notes
ui.dialog.show page (map, required) page schema errors are invalid. Rate limit: refused with limit “dialogs open after a user action” when no dialog is open, there was no user action in the last 10 s, and a dialog or window was opened in the last 60 s
ui.dialog.close none always null; resets the “dialog open” state

Result of show: null.

ui.window.show, ui.window.update, ui.window.close

Section titled “ui.window.show, ui.window.update, ui.window.close”

Permission ui.dialog.

Argument Type Required Validation
id text yes up to 32 characters of a-z 0-9 _ - .; else invalid “id”
page map show, update page schema
mode text no (show) "layer" (default) or "window"; an already open window keeps its mode

Rules:

  • At most 3 windows are open at once: another new one is limit “too many windows are open”.
  • Opening a new window follows the same rule as dialogs: a user action in the last 10 s, or no dialog/window opened in the last 60 s; else limit “windows open after a user action”.
  • Showing or updating an open window is limited to one change per 100 ms per window: limit “windows are updated too often”.
  • ui.window.update for a window that is not open: not_found “the window is not open”.
  • unavailable “windows” if the app could not show the window (a window opened by this call is not counted).
  • ui.window.close always answers null.

Results are null.

freshgram.ui.window("stats", "Stats", items, mode="window")
# raw: {"op": "ui.window.show", "a": {"id": "stats", "mode": "window", "page": {"title": "Stats", "items": [...]}}}

Permission ui.badges.

Argument Type Required Validation
user integer yes greater than 0, not the service account 777000 (denied “this user”); else invalid “user”
items array yes up to 4 entries; an empty array removes the badges of that user

Each item is a map with exactly the keys id, icon, image, tooltip:

Key Rule
id required, unique inside the list, up to 32 characters of a-z 0-9 _ - .
icon built-in or plugin icon name, 1 to 32 characters of a-z 0-9 _ -
image bytes, up to 64 KiB. PNG with both sides at most 128 px, or JPEG (size checked later by the app)
tooltip optional text, up to 80 bytes

Exactly one of icon and image per item. A bad list is invalid with the message. Setting badges for a 257th distinct user is quota “too many users have badges” (256 users at most). Result null; unavailable “badges” without a UI service. A click on a badge arrives as badge.click.

freshgram.badges.set(12345, [{"id": "vip", "icon": "star", "tooltip": "VIP"}])
# raw: {"op": "badges.set", "a": {"user": 12345, "items": [{"id": "vip", "icon": "star", "tooltip": "VIP"}]}}

No arguments. Removes every badge of the plugin and frees all 256 slots. Result null.

Permission notifications.

Argument Type Required Validation
text text yes 1 to 256 bytes
title text no up to 64 bytes (default: the app name)

Result null. Rate limit: one per 5 s (limit “notifications are shown too often”). invalid “title or text”; not_found if the account is gone; unavailable if the app did not answer in time (about 1.5 s); if the app refuses, the code it reports (usually failed) with the message “the notification was not shown”. The app shows a system notification when native notifications are enabled, otherwise a toast.

freshgram.notify.show("Done", title="Export")
# raw: {"op": "notify.show", "a": {"text": "Done", "title": "Export"}}

For clipboard.*, files.*, fs.*, media.* and image.* see Files, media and images; for net.* see Network operations.